From e6255e09b8000aece1fce37a29d5b2c82b4b8d22 Mon Sep 17 00:00:00 2001 From: "Tiffany Chang (tic)" Date: Thu, 20 Aug 2020 10:53:38 +0000 Subject: [PATCH] [IMP] mrp(_subcontracting), stock: MO reserve and replenish automatically This commit does 3 things: 1. When 'action_confirm' is triggered in a manufacturing order, any 'move_raw_ids' (components) that are unable to be fully reserved when `action_assign` is triggered will be checked for an automatic reordering rule (RR). If a rule exists it will be auto-triggered. This includes subcontractor manufacturing orders. Relevant test updated to match this. 2. When a MO is completed, auto check if there are corresponding confirmed stock moves that can be populated by the completed products and trigger their 'action_assign' to reserve the completed products. 3. If a new move line is added to an already confirmed MO, then do same auto-RR check/trigger. (note will occur for pickings without extra code since `stock_picking.action_confirm` is triggered again in this case.) This reuses similar logic as odoo/odoo#52433 from task 2244230. Originally this was done in the `action_assign`, but has been moved to `action_confirm` so as to imitate the now archived MTO process with RR. Overlapping logic has been moved into stock_move for easier reusability. Note that this now means editing existing moves' `product_uom_qty` (i.e. Demand) will not have a flow to auto-trigger their RR. Subcontracting test updated to match + new mrp test added for this new feature. closes odoo/odoo#57334 Task: 2322496 X-original-commit: 2688cd1bae489b1dec8862f4de727ac20514844c Signed-off-by: Arnold Moyaux --- addons/mrp/models/mrp_production.py | 8 + addons/mrp/tests/test_procurement.py | 158 ++++++++++++++++++ .../tests/test_subcontracting.py | 14 +- addons/stock/models/stock_move.py | 25 +++ addons/stock/models/stock_picking.py | 23 +-- addons/stock/tests/test_proc_rule.py | 39 ++++- 6 files changed, 234 insertions(+), 33 deletions(-) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index c3c74a836a8..6d647ccd881 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -976,6 +976,8 @@ class MrpProduction(models.Model): if moves_to_confirm: moves_to_confirm._action_confirm() + # run scheduler for moves forecasted to not have enough in stock + moves_to_confirm._trigger_scheduler() self.workorder_ids.filtered(lambda w: w.state not in ['done', 'cancel'])._action_confirm() @@ -1074,6 +1076,8 @@ class MrpProduction(models.Model): production.move_raw_ids._adjust_procure_method() (production.move_raw_ids | production.move_finished_ids)._action_confirm() production.workorder_ids._action_confirm() + # run scheduler for moves forecasted to not have enough in stock + production.move_raw_ids._trigger_scheduler() return True def action_assign(self): @@ -1435,6 +1439,10 @@ class MrpProduction(models.Model): productions_to_backorder._post_inventory(cancel_backorder=False) backorders = productions_to_backorder._generate_backorder_productions() + # if completed products make other confirmed/partially_available moves available, assign them + done_move_finished_ids = (productions_to_backorder.move_finished_ids | productions_not_to_backorder.move_finished_ids).filtered(lambda m: m.state == 'done') + done_move_finished_ids._trigger_assign() + # Moves without quantity done are not posted => set them as done instead of canceling. In # case the user edits the MO later on and sets some consumed quantity on those, we do not # want the move lines to be canceled. diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index ea62c577477..6901920c91d 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -346,3 +346,161 @@ class TestProcurement(TestMrpCommon): move_dest._action_assign() self.assertEqual(move_dest.reserved_availability, 10.0) + + def test_auto_assign(self): + """ When auto reordering rule exists, check for when: + 1. There is not enough of a manufactured product to assign (reserve for) a picking => auto-create 1st MO + 2. There is not enough of a manufactured component to assign the created MO => auto-create 2nd MO + 3. Add an extra manufactured component (not in stock) to 1st MO => auto-create 3rd MO + 4. When 2nd MO is completed => auto-assign to 1st MO + 5. When 1st MO is completed => auto-assign to picking """ + + self.warehouse = self.env.ref('stock.warehouse0') + route_manufacture = self.warehouse.manufacture_pull_id.route_id + + product_1 = self.env['product.product'].create({ + 'name': 'Cake', + 'type': 'product', + 'route_ids': [(6, 0, [route_manufacture.id])] + }) + product_2 = self.env['product.product'].create({ + 'name': 'Cake Mix', + 'type': 'product', + 'route_ids': [(6, 0, [route_manufacture.id])] + }) + product_3 = self.env['product.product'].create({ + 'name': 'Flour', + 'type': 'consu', + }) + + self.env['mrp.bom'].create({ + 'product_id': product_1.id, + 'product_tmpl_id': product_1.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1, + 'consumption': 'flexible', + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_2.id, 'product_qty': 1}), + ]}) + + self.env['mrp.bom'].create({ + 'product_id': product_2.id, + 'product_tmpl_id': product_2.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_3.id, 'product_qty': 1}), + ]}) + + # extra manufactured component added to 1st MO after it is already confirmed + product_4 = self.env['product.product'].create({ + 'name': 'Flavor Enchancer', + 'type': 'product', + 'route_ids': [(6, 0, [route_manufacture.id])] + }) + product_5 = self.env['product.product'].create({ + 'name': 'MSG', + 'type': 'consu', + }) + + self.env['mrp.bom'].create({ + 'product_id': product_4.id, + 'product_tmpl_id': product_4.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_5.id, 'product_qty': 1}), + ]}) + + # setup auto orderpoints (reordering rules) + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'Cake RR', + 'location_id': self.warehouse.lot_stock_id.id, + 'product_id': product_1.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'Cake Mix RR', + 'location_id': self.warehouse.lot_stock_id.id, + 'product_id': product_2.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'Flavor Enchancer RR', + 'location_id': self.warehouse.lot_stock_id.id, + 'product_id': product_4.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + + # create picking output to trigger creating MO for reordering product_1 + pick_output = self.env['stock.picking'].create({ + 'name': 'Cake Delivery Order', + 'picking_type_id': self.ref('stock.picking_type_out'), + 'location_id': self.warehouse.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'move_lines': [(0, 0, { + 'name': '/', + 'product_id': product_1.id, + 'product_uom': product_1.uom_id.id, + 'product_uom_qty': 10.00, + 'procure_method': 'make_to_stock', + })], + }) + pick_output.action_confirm() # should trigger orderpoint to create and confirm 1st MO + pick_output.action_assign() + + mo = self.env['mrp.production'].search([ + ('product_id', '=', product_1.id), + ('state', '=', 'confirmed') + ]) + + self.assertEqual(len(mo), 1, "Manufacture order was not automatically created") + mo.action_assign() + self.assertEqual(mo.move_raw_ids.reserved_availability, 0, "No components should be reserved yet") + self.assertEqual(mo.product_qty, 15, "Quantity to produce should be picking demand + reordering rule max qty") + + # 2nd MO for product_2 should have been created and confirmed when 1st MO for product_1 was confirmed + mo2 = self.env['mrp.production'].search([ + ('product_id', '=', product_2.id), + ('state', '=', 'confirmed') + ]) + + self.assertEqual(len(mo2), 1, 'Second manufacture order was not created') + self.assertEqual(mo2.product_qty, 20, "Quantity to produce should be MO's 'to consume' qty + reordering rule max qty") + mo2_form = Form(mo2) + mo2_form.qty_producing = 20 + mo2 = mo2_form.save() + mo2.button_mark_done() + + self.assertEqual(mo.move_raw_ids.reserved_availability, 15, "Components should have been auto-reserved") + + # add new component to 1st MO + mo_form = Form(mo) + with mo_form.move_raw_ids.new() as line: + line.product_id = product_4 + line.product_uom_qty = 1 + mo_form.save() # should trigger orderpoint to create and confirm 3rd MO + + mo3 = self.env['mrp.production'].search([ + ('product_id', '=', product_4.id), + ('state', '=', 'confirmed') + ]) + + self.assertEqual(len(mo3), 1, 'Third manufacture order for added component was not created') + self.assertEqual(mo3.product_qty, 6, "Quantity to produce should be 1 + reordering rule max qty") + + mo_form = Form(mo) + mo.move_raw_ids.quantity_done = 15 + mo_form.qty_producing = 15 + mo = mo_form.save() + mo.button_mark_done() + + self.assertEqual(pick_output.move_ids_without_package.reserved_availability, 10, "Completed products should have been auto-reserved in picking") diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index 4c58db4659b..88e21407ff2 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -224,9 +224,8 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): def test_flow_4(self): """ Tick "Manufacture" and "MTO" on the components and trigger the creation of the subcontracting manufacturing order through a receipt - picking. Checks that the delivery to the subcontractor is not created - at the receipt creation. Then run the scheduler and check that - the delivery and MO exist. + picking. Checks that the delivery and MO for its components are + automatically created. """ # Tick "manufacture" and MTO on self.comp2 mto_route = self.env.ref('stock.route_warehouse0_mto') @@ -264,15 +263,6 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): picking_delivery = self.env['stock.picking'].search([('origin', 'ilike', '%' + picking_receipt.name + '%')]) self.assertFalse(picking_delivery) - move = self.env['stock.move'].search([ - ('product_id', '=', self.comp2.id), - ('location_id', '=', warehouse.lot_stock_id.id), - ('location_dest_id', '=', self.env.company.subcontracting_location_id.id) - ]) - self.assertFalse(move) - - self.env['procurement.group'].run_scheduler(company_id=self.env.company.id) - move = self.env['stock.move'].search([ ('product_id', '=', self.comp2.id), ('location_id', '=', warehouse.lot_stock_id.id), diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index ae809c5acf0..98633a4f0c0 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -13,6 +13,7 @@ from dateutil import relativedelta from odoo import SUPERUSER_ID, _, api, fields, models from odoo.exceptions import UserError +from odoo.osv import expression from odoo.tools.float_utils import float_compare, float_is_zero, float_repr, float_round from odoo.tools.misc import format_date @@ -1730,3 +1731,27 @@ class StockMove(models.Model): def _show_details_in_draft(self): self.ensure_one() return self.state != 'draft' or (self.picking_id.immediate_transfer and self.state == 'draft') + + def _trigger_scheduler(self): + """ Check for auto-triggered orderpoints and trigger them. """ + orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint']) + for move in self: + orderpoint = self.env['stock.warehouse.orderpoint'].search([ + ('product_id', '=', move.product_id.id), + ('trigger', '=', 'auto'), + ('location_id', 'parent_of', move.location_id.id), + ('company_id', '=', move.company_id.id) + ], limit=1) + if orderpoint: + orderpoints_by_company[orderpoint.company_id] |= orderpoint + for company, orderpoints in orderpoints_by_company.items(): + orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False) + + def _trigger_assign(self): + """ Check for and trigger action_assign for confirmed/partially_available moves related to done moves. """ + domains = [] + for move in self: + domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)]) + static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')] + moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)])) + moves_to_reserve._action_assign() diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 0f495ec0156..cac496480fd 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -694,6 +694,9 @@ class Picking(models.Model): self.mapped('move_lines')\ .filtered(lambda move: move.state == 'draft')\ ._action_confirm() + + # run scheduler for moves forecasted to not have enough in stock + self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))._trigger_scheduler() return True def action_assign(self): @@ -713,19 +716,6 @@ class Picking(models.Model): moves._action_assign() package_level_done.write({'is_done': True}) - # run scheduler for moves are not fully reserved - orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint']) - for move in moves: - orderpoint = self.env['stock.warehouse.orderpoint'].search([ - ('product_id', '=', move.product_id.id), - ('trigger', '=', 'auto'), - ('location_id', 'parent_of', move.location_id.id), - ], limit=1) - if orderpoint: - orderpoints_by_company[move.company_id] |= orderpoint - for company, orderpoints in orderpoints_by_company.items(): - orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False) - return True def action_cancel(self): @@ -777,12 +767,7 @@ class Picking(models.Model): # if incoming moves make other confirmed/partially_available moves available, assign them done_incoming_moves = self.filtered(lambda p: p.picking_type_id.code == 'incoming').move_lines.filtered(lambda m: m.state == 'done') - domains = [] - for move in done_incoming_moves: - domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)]) - static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')] - moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)])) - moves_to_reserve._action_assign() + done_incoming_moves._trigger_assign() self._send_confirmation_email() return True diff --git a/addons/stock/tests/test_proc_rule.py b/addons/stock/tests/test_proc_rule.py index f9885a5a992..8e4b2999aac 100644 --- a/addons/stock/tests/test_proc_rule.py +++ b/addons/stock/tests/test_proc_rule.py @@ -149,14 +149,20 @@ class TestProcRule(TransactionCase): self.assertEqual(receipt_move.product_uom_qty, 17.0) def test_reordering_rule_2(self): - """Test when not enough product to assign picking, automatically run - reordering rule. + """Test when there is not enough product to assign a picking => automatically run + reordering rule (RR). Add extra product to already confirmed picking => automatically + run another RR """ self.productA = self.env['product.product'].create({ 'name': 'Desk Combination', 'type': 'product', }) + self.productB = self.env['product.product'].create({ + 'name': 'Desk Decoration', + 'type': 'product', + }) + warehouse = self.env['stock.warehouse'].search([], limit=1) orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) orderpoint_form.product_id = self.productA @@ -165,6 +171,14 @@ class TestProcRule(TransactionCase): orderpoint_form.product_max_qty = 5.0 orderpoint = orderpoint_form.save() + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'ProductB RR', + 'location_id': warehouse.lot_stock_id.id, + 'product_id': self.productB.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + self.env['stock.rule'].create({ 'name': 'Rule Supplier', 'route_id': warehouse.reception_route_id.id, @@ -197,10 +211,31 @@ class TestProcRule(TransactionCase): ('product_id', '=', self.productA.id), ('location_id', '=', self.env.ref('stock.stock_location_suppliers').id) ]) + self.assertTrue(receipt_move) self.assertEqual(receipt_move.date.date(), date.today()) self.assertEqual(receipt_move.product_uom_qty, 17.0) + delivery_picking.write({'move_lines': [(0, 0, { + 'name': 'Extra Move', + 'product_id': self.productB.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 5.0, + 'location_id': warehouse.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'picking_id': delivery_picking.id, + 'additional': True + })]}) + + receipt_move2 = self.env['stock.move'].search([ + ('product_id', '=', self.productB.id), + ('location_id', '=', self.env.ref('stock.stock_location_suppliers').id) + ]) + + self.assertTrue(receipt_move2) + self.assertEqual(receipt_move2.date.date(), date.today()) + self.assertEqual(receipt_move2.product_uom_qty, 10.0) + class TestProcRuleLoad(TransactionCase): def setUp(cls):