diff --git a/mrp_mto_with_stock/README.rst b/mrp_mto_with_stock/README.rst index 6d17ccada..fa154d0f2 100644 --- a/mrp_mto_with_stock/README.rst +++ b/mrp_mto_with_stock/README.rst @@ -7,9 +7,16 @@ MRP MTO with Stock ================== This module extends the functionality of Manufacturing to support the creation -of procurements when there is no stock available. This allow you to pull from -stock until the quantity on hand is zero, and then create a procurement -to fulfill the MO requirements. +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 ============= @@ -21,17 +28,23 @@ To configure this module, you need to: 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 *Confirm Production*. +#. 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/9.0 + :target: https://runbot.odoo-community.org/runbot/129/10.0 Bug Tracker =========== @@ -54,6 +67,7 @@ Contributors * John Walsh * Lois Rilo +* Florian da Costa Maintainer ---------- diff --git a/mrp_mto_with_stock/__openerp__.py b/mrp_mto_with_stock/__manifest__.py similarity index 78% rename from mrp_mto_with_stock/__openerp__.py rename to mrp_mto_with_stock/__manifest__.py index 928dbc421..ef669a7fb 100644 --- a/mrp_mto_with_stock/__openerp__.py +++ b/mrp_mto_with_stock/__manifest__.py @@ -10,10 +10,14 @@ "author": "John Walsh, Eficent, Odoo Community Association (OCA)", "website": "https://odoo-community.org/", "category": "Manufacturing", - "version": "9.0.1.0.0", + "version": "10.0.1.0.0", "license": "AGPL-3", "application": False, "installable": True, "depends": ["mrp"], - "data": ['views/product_template_view.xml'], + "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 index c4a684fa2..c617a225f 100644 --- a/mrp_mto_with_stock/models/__init__.py +++ b/mrp_mto_with_stock/models/__init__.py @@ -5,3 +5,4 @@ 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 index 281fc9ea8..dc6a585f5 100644 --- a/mrp_mto_with_stock/models/mrp_production.py +++ b/mrp_mto_with_stock/models/mrp_production.py @@ -3,7 +3,7 @@ # Copyright 2015 John Walsh # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from openerp import api, models +from odoo import api, models import logging _logger = logging.getLogger(__name__) @@ -11,32 +11,69 @@ _logger = logging.getLogger(__name__) class MrpProduction(models.Model): _inherit = 'mrp.production' - @api.one + @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 list of ids""" + @returns True""" # reserve all that is available (standard behaviour): res = super(MrpProduction, self).action_assign() # try to create procurements: - for move in self.move_lines: - if (move.state == 'confirmed' and move.location_id in - move.product_id.mrp_mts_mto_location_ids): - 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: - qty_to_procure = (move.remaining_qty - - move.reserved_availability) - proc_dict = self._prepare_mto_procurement( - move, qty_to_procure) - self.env['procurement.order'].create(proc_dict) + 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): + 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' @@ -44,7 +81,7 @@ class MrpProduction(models.Model): 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)) - return { + vals = { 'name': move.name + ':' + str(move.id), 'origin': origin, 'company_id': move.company_id and move.company_id.id or False, @@ -53,9 +90,26 @@ class MrpProduction(models.Model): 'product_qty': qty, 'product_uom': move.product_uom.id, 'location_id': move.location_id.id, - 'move_dest_id': move.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 index 11099600d..a6e1142bf 100644 --- a/mrp_mto_with_stock/models/product_template.py +++ b/mrp_mto_with_stock/models/product_template.py @@ -2,7 +2,7 @@ # Copyright 2017 Eficent Business and IT Consulting Services S.L. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from openerp import fields, models +from odoo import fields, models class ProductTemplate(models.Model): 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/test_mrp_mto_with_stock.py b/mrp_mto_with_stock/tests/test_mrp_mto_with_stock.py index 464195aa9..c9d844cde 100644 --- a/mrp_mto_with_stock/tests/test_mrp_mto_with_stock.py +++ b/mrp_mto_with_stock/tests/test_mrp_mto_with_stock.py @@ -2,11 +2,11 @@ # Copyright 2017 Eficent Business and IT Consulting Services S.L. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from openerp.tests.common import TransactionCase -from openerp import fields +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'] @@ -15,55 +15,27 @@ class TestMrpMtoWithStock(TransactionCase): 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.product_fp = self.env['product.product'].create({ - 'name': 'FP', - 'type': 'product', - 'uom_id': self.uom_unit.id, - 'route_ids': [(4, self.manufacture_route.id)] - }) - self.product_c1 = self.env['product.product'].create({ - 'name': 'C1', - 'type': 'product', - 'uom_id': self.uom_unit.id, - 'route_ids': [(4, self.manufacture_route.id)] - }) - self.product_c2 = self.env['product.product'].create({ - 'name': 'C2', - 'type': 'product', - 'uom_id': self.uom_unit.id, - }) - self._update_product_qty(self.product_c2, - self.stock_location_stock, 10) + 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.bom_fp = self.env['mrp.bom'].create({ - 'product_id': self.product_fp.id, - 'product_tmpl_id': self.product_fp.product_tmpl_id.id, - 'bom_line_ids': ([ - (0, 0, { - 'product_id': self.product_c1.id, - 'product_qty': 1, - 'product_uom': self.uom_unit.id - }), - (0, 0, { - 'product_id': self.product_c2.id, - 'product_qty': 1, - 'product_uom': self.uom_unit.id - }), - ]) - }) + self.main_bom = self.env.ref( + 'mrp_mto_with_stock.mrp_bom_manuf_1') - self.bom_c1 = self.env['mrp.bom'].create({ - 'product_id': self.product_c1.id, - 'product_tmpl_id': self.product_c1.product_tmpl_id.id, - 'bom_line_ids': ([(0, 0, { - 'product_id': self.product_c2.id, - 'product_qty': 1, - 'product_uom': self.uom_unit.id - })]) - }) - self.product_c1.mrp_mts_mto_location_ids = [ - (6, 0, [self.stock_location_stock.id])] + 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.""" @@ -75,65 +47,112 @@ class TestMrpMtoWithStock(TransactionCase): product_qty.change_product_qty() return product_qty - def create_procurement(self, name, product): - values = { - 'name': name, - 'date_planned': fields.Datetime.now(), - 'product_id': product.id, - 'product_qty': 4.0, - 'product_uom': product.uom_id.id, - 'warehouse_id': self.env.ref('stock.warehouse0').id, - 'location_id': self.stock_location_stock.id, - 'route_ids': [ - (4, self.env.ref('mrp.route_warehouse0_manufacture').id, 0)], - } - return self.env['procurement.order'].create(values) + 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 + """ - def test_manufacture(self): + self.warehouse.mrp_mto_mts_forecast_qty = True - procurement_fp = self.create_procurement('TEST/01', self.product_fp) - production_fp = procurement_fp.production_id - self.assertEqual(production_fp.state, 'confirmed') + self._update_product_qty(self.subproduct1, self.stock_location_stock, + 2) + self._update_product_qty(self.subproduct2, self.stock_location_stock, + 4) - production_fp.action_assign() - self.assertEqual(production_fp.state, 'confirmed') + self.production = self.production_model.create( + self._get_production_vals()) - procurement_c1 = self.env['procurement.order'].search( - [('product_id', '=', self.product_c1.id), - ('move_dest_id', 'in', production_fp.move_lines.ids)], limit=1) - self.assertEquals(len(procurement_c1), 1) + # Create MO and check it create sub assemblie MO. + self.production.action_assign() - procurement_c2 = self.env['procurement.order'].search( - [('product_id', '=', self.product_c2.id), - ('move_dest_id', 'in', production_fp.move_lines.ids)], limit=1) - self.assertEquals(len(procurement_c2), 0) + self.assertEqual(self.production.availability, 'partially_available') - procurement_c1.run() - production_c1 = procurement_c1.production_id - self.assertEqual(production_c1.state, 'confirmed') + self.assertEquals(self.subproduct1.virtual_available, 0) - production_c1.action_assign() - self.assertEqual(production_c1.state, 'ready') + procurement_subproduct1 = self.env['procurement.order'].search( + [('product_id', '=', self.subproduct1.id), + ('group_id', '=', self.production.procurement_group_id.id)]) - procurement_c2 = self.env['procurement.order'].search( - [('product_id', '=', self.product_c2.id), - ('move_dest_id', 'in', production_c1.move_lines.ids)], limit=1) - self.assertEquals(len(procurement_c2), 0) + self.assertEquals(len(procurement_subproduct1), 1) + self.assertEquals(procurement_subproduct1.product_qty, 3) - wizard = self.env['mrp.product.produce'].create({ - 'product_id': self.product_c1.id, - 'product_qty': 1, - }) - self.env['mrp.production'].action_produce( - production_c1.id, 1, 'consume_produce', wizard) - production_c1.refresh() - self.assertEqual(production_fp.state, 'confirmed') + production_sub1 = procurement_subproduct1.production_id + self.assertEqual(production_sub1.state, 'confirmed') + self.assertEqual(production_sub1.product_qty, 3) - wizard = self.env['mrp.product.produce'].create({ - 'product_id': self.product_c1.id, - 'product_qty': 3, - }) - self.env['mrp.production'].action_produce( - production_c1.id, 3, 'consume_produce', wizard) - production_c1.refresh() - self.assertEqual(production_fp.state, 'ready') + 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/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 + + + + + + + + +