diff --git a/mrp_mto_with_stock/README.rst b/mrp_mto_with_stock/README.rst new file mode 100644 index 000000000..fa154d0f2 --- /dev/null +++ b/mrp_mto_with_stock/README.rst @@ -0,0 +1,85 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +================== +MRP MTO with Stock +================== + +This module extends the functionality of Manufacturing to support the creation +of procurements only for a part of the raw material. +It has 2 modes. The default one allow you to pull +from stock until the quantity on hand is zero, and then create a procurement +to fulfill the MO requirements. In this mode, the created procurements must +be the ones fulfilling the MO that has generated it. +The other mode is based on the forecast quantity. It will allow to pull from +stock until the forecast quantity is zero and then create a procurement for +the missing products. In this mode, there is no link between the procurement +created and MO that has generated it. The procurement may be used to fulfill +another MO. + +Configuration +============= + +To configure this module, you need to: + +#. Go to the products you want to follow this behaviour. +#. In the view form go to the tab *Inventory* and set the *Manufacturing + MTO/MTS Locations*. Any other location not specified here will have the + standard behavior. + +If you want to use the second mode, based on forecast quantity +#. Go to the warehouse you want to follow this behaviour. +#. In the view form go to the tab *Warehouse Configuration* and set the + *MRP MTO with forecast stock*. You still need to configure the products + like described in last step. + +Usage +===== + +To use this module, you need to: + +#. Go to *Manufacturing* and create a Manufacturing Order. +#. Click on *Check availability*. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/129/10.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* John Walsh +* Lois Rilo +* Florian da Costa + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/mrp_mto_with_stock/__init__.py b/mrp_mto_with_stock/__init__.py new file mode 100644 index 000000000..a7129c69a --- /dev/null +++ b/mrp_mto_with_stock/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# Copyright 2015 John Walsh +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/mrp_mto_with_stock/__manifest__.py b/mrp_mto_with_stock/__manifest__.py new file mode 100644 index 000000000..ef669a7fb --- /dev/null +++ b/mrp_mto_with_stock/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# Copyright 2015 John Walsh +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "MRP MTO with Stock", + "summary": "Fix Manufacturing orders to pull from stock until qty is " + "zero, and then create a procurement for them.", + "author": "John Walsh, Eficent, Odoo Community Association (OCA)", + "website": "https://odoo-community.org/", + "category": "Manufacturing", + "version": "10.0.1.0.0", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": ["mrp"], + "data": [ + 'views/product_template_view.xml', + 'views/stock_warehouse.xml', + ], + "demo": ['demo/product.xml'], +} diff --git a/mrp_mto_with_stock/demo/product.xml b/mrp_mto_with_stock/demo/product.xml new file mode 100644 index 000000000..4a4d61a8c --- /dev/null +++ b/mrp_mto_with_stock/demo/product.xml @@ -0,0 +1,128 @@ + + + + + + + TOP + + 600.00 + 400.00 + product + + + TODO + MANUF + + + + + Subproduct 1 + + 300.00 + 100.00 + product + + + TODO + MANUF 1-1 + + + + + + Subproduct 2 + + 100.00 + 30.00 + product + + + TODO + MANUF 1-2 + + + + + + Subproduct 1-1 + + 10.00 + 3.00 + product + + + TODO + MANUF 1-1-1 + + + + + Subproduct 2-1 + + 10.00 + 3.00 + product + + + TODO + MANUF 1-2-1 + + + + + + + 10 + + + + + 5 + + 1 + + + + + + 2 + + 1 + + + + + + + 10 + + + + + 2 + + 1 + + + + + + + 10 + + + + + 4 + + 1 + + + + + diff --git a/mrp_mto_with_stock/models/__init__.py b/mrp_mto_with_stock/models/__init__.py new file mode 100644 index 000000000..c617a225f --- /dev/null +++ b/mrp_mto_with_stock/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# Copyright 2015 John Walsh +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import mrp_production +from . import product_template +from . import stock_warehouse diff --git a/mrp_mto_with_stock/models/mrp_production.py b/mrp_mto_with_stock/models/mrp_production.py new file mode 100644 index 000000000..dc6a585f5 --- /dev/null +++ b/mrp_mto_with_stock/models/mrp_production.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# Copyright 2015 John Walsh +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models +import logging +_logger = logging.getLogger(__name__) + + +class MrpProduction(models.Model): + _inherit = 'mrp.production' + + @api.multi + def _adjust_procure_method(self): + # Si location => By pass method... + super(MrpProduction, self)._adjust_procure_method() + + @api.multi + def action_assign(self): + """Reserves available products to the production order but also creates + procurements for more items if we cannot reserve enough (MTO with + stock). + @returns True""" + # reserve all that is available (standard behaviour): + res = super(MrpProduction, self).action_assign() + # try to create procurements: + move_obj = self.env['stock.move'] + for production in self: + warehouse = production.location_src_id.get_warehouse() + mto_with_no_move_dest_id = warehouse.mrp_mto_mts_forecast_qty + for move in self.move_raw_ids: + if (move.state == 'confirmed' and move.location_id in + move.product_id.mrp_mts_mto_location_ids and not + mto_with_no_move_dest_id): + domain = [('product_id', '=', move.product_id.id), + ('move_dest_id', '=', move.id)] + if move.group_id: + domain.append(('group_id', '=', move.group_id.id)) + procurement = self.env['procurement.order'].search(domain) + if not procurement: + # We have to split the move because we can't have + # a part of the move that have ancestors and not the + # other else it won't ever be reserved. + qty_to_procure = (move.remaining_qty - + move.reserved_availability) + if qty_to_procure < move.product_uom_qty: + move.do_unreserve() + new_move_id = move.split( + qty_to_procure, + restrict_lot_id=move.restrict_lot_id, + restrict_partner_id=move.restrict_partner_id) + new_move = move_obj.browse( + new_move_id) + move.action_assign() + else: + new_move = move + + proc_dict = self._prepare_mto_procurement( + new_move, qty_to_procure, + mto_with_no_move_dest_id) + self.env['procurement.order'].create(proc_dict) + + if (move.state == 'confirmed' and move.location_id in + move.product_id.mrp_mts_mto_location_ids and + move.procure_method == 'make_to_stock' and + mto_with_no_move_dest_id): + qty_to_procure = production.get_mto_qty_to_procure(move) + if qty_to_procure > 0.0: + proc_dict = self._prepare_mto_procurement( + move, qty_to_procure, mto_with_no_move_dest_id) + proc_dict.pop('move_dest_id', None) + self.env['procurement.order'].create(proc_dict) + return res + + def _prepare_mto_procurement(self, move, qty, mto_with_no_move_dest_id): + """Prepares a procurement for a MTO product.""" + origin = ((move.group_id and move.group_id.name + ":") or "") + \ + ((move.name and move.name + ":") or "") + 'MTO -> Production' + group_id = move.group_id and move.group_id.id or False + route_ids = self.env.ref('stock.route_warehouse0_mto') + warehouse_id = (move.warehouse_id.id or (move.picking_type_id and + move.picking_type_id.warehouse_id.id or False)) + vals = { + 'name': move.name + ':' + str(move.id), + 'origin': origin, + 'company_id': move.company_id and move.company_id.id or False, + 'date_planned': move.date, + 'product_id': move.product_id.id, + 'product_qty': qty, + 'product_uom': move.product_uom.id, + 'location_id': move.location_id.id, + 'group_id': group_id, + 'route_ids': [(6, 0, route_ids.ids)], + 'warehouse_id': warehouse_id, + 'priority': move.priority, + } + if not mto_with_no_move_dest_id: + vals['move_dest_id'] = move.id + return vals + + @api.multi + def get_mto_qty_to_procure(self, move): + self.ensure_one() + stock_location_id = move.location_id.id + move_location = move.with_context(location=stock_location_id) + virtual_available = move_location.product_id.virtual_available + qty_available = move.product_id.uom_id._compute_quantity( + virtual_available, move.product_uom) + if qty_available >= 0: + return 0.0 + else: + if abs(qty_available) < move.product_uom_qty: + return abs(qty_available) + return move.product_uom_qty diff --git a/mrp_mto_with_stock/models/product_template.py b/mrp_mto_with_stock/models/product_template.py new file mode 100644 index 000000000..a6e1142bf --- /dev/null +++ b/mrp_mto_with_stock/models/product_template.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + mrp_mts_mto_location_ids = fields.Many2many( + comodel_name='stock.location', + string='Manufacturing MTO/MTS Locations', + help='These manufacturing locations will create procurements when ' + 'there is no stock availale in the source location.') diff --git a/mrp_mto_with_stock/models/stock_warehouse.py b/mrp_mto_with_stock/models/stock_warehouse.py new file mode 100644 index 000000000..75d3f30f0 --- /dev/null +++ b/mrp_mto_with_stock/models/stock_warehouse.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Akretion +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockWarehouse(models.Model): + _inherit = 'stock.warehouse' + + mrp_mto_mts_forecast_qty = fields.Boolean( + string="MRP MTO with forecast stock", + help="When you use Mrp_mto_with_stock, the procurement creation is " + "based on reservable stock by default. Check this option if " + "you prefer base it on the forecast stock. In this case, the " + "created procurements won't be linked to the raw material moves") diff --git a/mrp_mto_with_stock/tests/__init__.py b/mrp_mto_with_stock/tests/__init__.py new file mode 100644 index 000000000..f8065ee2e --- /dev/null +++ b/mrp_mto_with_stock/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_mrp_mto_with_stock diff --git a/mrp_mto_with_stock/tests/test_mrp_mto_with_stock.py b/mrp_mto_with_stock/tests/test_mrp_mto_with_stock.py new file mode 100644 index 000000000..c9d844cde --- /dev/null +++ b/mrp_mto_with_stock/tests/test_mrp_mto_with_stock.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestMrpMtoWithStock(TransactionCase): + + def setUp(self, *args, **kwargs): + super(TestMrpMtoWithStock, self).setUp(*args, **kwargs) + self.production_model = self.env['mrp.production'] + self.bom_model = self.env['mrp.bom'] + self.stock_location_stock = self.env.ref('stock.stock_location_stock') + self.manufacture_route = self.env.ref( + 'mrp.route_warehouse0_manufacture') + self.uom_unit = self.env.ref('product.product_uom_unit') + self.warehouse = self.env.ref('stock.warehouse0') + + self.top_product = self.env.ref( + 'mrp_mto_with_stock.product_product_manufacture_1') + self.subproduct1 = self.env.ref( + 'mrp_mto_with_stock.product_product_manufacture_1_1') + self.subproduct2 = self.env.ref( + 'mrp_mto_with_stock.product_product_manufacture_1_2') + self.subproduct_1_1 = self.env.ref( + 'mrp_mto_with_stock.product_product_manufacture_1_1_1') + + self.main_bom = self.env.ref( + 'mrp_mto_with_stock.mrp_bom_manuf_1') + + def _get_production_vals(self): + return { + 'product_id': self.top_product.id, + 'product_qty': 1, + 'product_uom_id': self.uom_unit.id, + 'bom_id': self.main_bom.id, + } + + def _update_product_qty(self, product, location, quantity): + """Update Product quantity.""" + product_qty = self.env['stock.change.product.qty'].create({ + 'location_id': location.id, + 'product_id': product.id, + 'new_quantity': quantity, + }) + product_qty.change_product_qty() + return product_qty + + def test_manufacture_with_forecast_stock(self): + """ + Test Manufacture mto with stock based on forecast quantity + and no link between sub assemblies MO's and Main MO raw material + """ + + self.warehouse.mrp_mto_mts_forecast_qty = True + + self._update_product_qty(self.subproduct1, self.stock_location_stock, + 2) + self._update_product_qty(self.subproduct2, self.stock_location_stock, + 4) + + self.production = self.production_model.create( + self._get_production_vals()) + + # Create MO and check it create sub assemblie MO. + self.production.action_assign() + + self.assertEqual(self.production.availability, 'partially_available') + + self.assertEquals(self.subproduct1.virtual_available, 0) + + procurement_subproduct1 = self.env['procurement.order'].search( + [('product_id', '=', self.subproduct1.id), + ('group_id', '=', self.production.procurement_group_id.id)]) + + self.assertEquals(len(procurement_subproduct1), 1) + self.assertEquals(procurement_subproduct1.product_qty, 3) + + production_sub1 = procurement_subproduct1.production_id + self.assertEqual(production_sub1.state, 'confirmed') + self.assertEqual(production_sub1.product_qty, 3) + + self._update_product_qty(self.subproduct1, self.stock_location_stock, + 7) + + # Create second MO and check it does not create procurement + self.production2 = self.production_model.create( + self._get_production_vals()) + self.production2.action_assign() + procurement_subproduct1_2 = self.env['procurement.order'].search( + [('product_id', '=', self.subproduct1.id), + ('group_id', '=', self.production2.procurement_group_id.id)]) + self.assertEquals(len(procurement_subproduct1_2), 0) + self.assertEquals(self.production2.availability, 'assigned') + self.production2.do_unreserve() + + self.assertEquals(self.subproduct1.virtual_available, 0) + + self.production.action_assign() + # We check if first MO is able to assign it self even if it has + # previously generate procurements, it would not be the case in the + # other mode (without mrp_mto_mts_reservable_stock on warehouse) + self.assertEquals(self.production.availability, 'assigned') + + self.assertEquals(self.subproduct1.virtual_available, 0) + + def test_manufacture_with_reservable_stock(self): + """ + Test Manufacture mto with stock based on reservable stock + and there is a link between sub assemblies MO's and Main MO raw + materi al + """ + + self._update_product_qty(self.subproduct1, self.stock_location_stock, + 2) + self._update_product_qty(self.subproduct2, self.stock_location_stock, + 4) + + self.production = self.production_model.create( + self._get_production_vals()) + + self._update_product_qty(self.subproduct_1_1, + self.stock_location_stock, 50) + + # Create MO and check it create sub assemblie MO. + self.production.action_assign() + self.assertEqual(self.production.state, 'confirmed') + + procurement_sub1 = self.env['procurement.order'].search( + [('product_id', '=', self.subproduct1.id), + ('move_dest_id', 'in', self.production.move_raw_ids.ids)]) + self.assertEquals(len(procurement_sub1), 1) + + procurement_sub2 = self.env['procurement.order'].search( + [('product_id', '=', self.subproduct2.id), + ('move_dest_id', 'in', self.production.move_raw_ids.ids)]) + self.assertEquals(len(procurement_sub2), 0) + + production_sub1 = procurement_sub1.production_id + self.assertEqual(production_sub1.product_qty, 3) + production_sub1.action_assign() + self.assertEqual(production_sub1.availability, 'assigned') + + wizard_obj = self.env['mrp.product.produce'] + default_fields = ['lot_id', 'product_id', 'product_uom_id', + 'product_tracking', 'consume_line_ids', + 'production_id', 'product_qty', 'serial'] + wizard_vals = wizard_obj.with_context(active_id=production_sub1.id).\ + default_get(default_fields) + + wizard = wizard_obj.create(wizard_vals) + wizard.do_produce() + self.assertTrue(production_sub1.check_to_done) + self.assertEquals(self.subproduct1.qty_available, 2) + production_sub1.button_mark_done() + self.assertEquals(self.subproduct1.qty_available, 5) + self.assertEqual(self.production.availability, 'assigned') diff --git a/mrp_mto_with_stock/views/product_template_view.xml b/mrp_mto_with_stock/views/product_template_view.xml new file mode 100644 index 000000000..cbe221f8f --- /dev/null +++ b/mrp_mto_with_stock/views/product_template_view.xml @@ -0,0 +1,20 @@ + + + + + + + product.template.form - mrp_mto_with_stock + extension + product.template + + + + + + + + + diff --git a/mrp_mto_with_stock/views/stock_warehouse.xml b/mrp_mto_with_stock/views/stock_warehouse.xml new file mode 100644 index 000000000..68a4266ce --- /dev/null +++ b/mrp_mto_with_stock/views/stock_warehouse.xml @@ -0,0 +1,17 @@ + + + + + + + stock.warehouse + + + + + + + + +