From 1f4fb64a197729b709bba8524b6bc59892a2f099 Mon Sep 17 00:00:00 2001 From: "Ahmed Khalaf (ahkh)" <ahkh@odoo.com> Date: Fri, 10 Jun 2022 14:05:47 +0000 Subject: [PATCH] [IMP] mrp: MO component qty changes reflected in pickings When changing quantities of raw moves after MO confirm, procurement is run to fulfill updated need. Since procurement creates new moves, some fixes were also done to allow moves to be merged with older ones, including: 1) `date_planned_start` of merged MO included microsecond which blocked merging new moves created by procurement with orig moves. Removed microseconds. 2) `_set_date_deadline` did not allow a simple date update to moves with orig or dest moves. Added context to hard set all moves in self to specific deadline. 3) `_update_candidate_moves_list` when merging moves, did not return candidates from pickings that were merged to same MO. Usually just mattered when decreasing quantity. Added explicit check for sibling pickings in MO closes odoo/odoo#93712 Task: 2859547 Signed-off-by: William Henrotin (whe) <whe@odoo.com> --- addons/mrp/models/mrp_production.py | 23 +--- addons/mrp/models/mrp_workorder.py | 2 +- addons/mrp/models/stock_move.py | 39 +++++- addons/mrp/tests/test_procurement.py | 129 ++++++++++++++++++ addons/mrp/wizard/mrp_production_backorder.py | 2 +- addons/stock/models/stock_move.py | 12 +- 6 files changed, 178 insertions(+), 29 deletions(-) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 253ce325905f..57b69f950e35 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -32,7 +32,7 @@ class MrpProduction(models.Model): def _get_default_date_planned_start(self): if self.env.context.get('default_date_deadline'): return fields.Datetime.to_datetime(self.env.context.get('default_date_deadline')) - return datetime.datetime.now() + return fields.Datetime.now() @api.model def _get_default_is_locked(self): @@ -1074,27 +1074,13 @@ class MrpProduction(models.Model): def _update_raw_moves(self, factor): self.ensure_one() update_info = [] - moves_to_assign = self.env['stock.move'] - procurements = [] for move in self.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel')): old_qty = move.product_uom_qty new_qty = float_round(old_qty * factor, precision_rounding=move.product_uom.rounding, rounding_method='UP') if new_qty > 0: + # procurement and assigning is now run in write move.write({'product_uom_qty': new_qty}) - if move._should_bypass_reservation() \ - or move.picking_type_id.reservation_method == 'at_confirm' \ - or (move.reservation_date and move.reservation_date <= fields.Date.today()): - moves_to_assign |= move - if move.procure_method == 'make_to_order': - procurement_qty = new_qty - old_qty - values = move._prepare_procurement_values() - procurements.append(self.env['procurement.group'].Procurement( - move.product_id, procurement_qty, move.product_uom, - move.location_id, move.name, move.origin, move.company_id, values)) update_info.append((move, old_qty, new_qty)) - moves_to_assign._action_assign() - if procurements: - self.env['procurement.group'].run(procurements) return update_info @api.ondelete(at_uninstall=False) @@ -1614,7 +1600,7 @@ class MrpProduction(models.Model): move_to_backorder_moves[move] = self.env['stock.move'] unit_factor = move.product_uom_qty / initial_qty_by_production[production] initial_move_vals = move.copy_data(move._get_backorder_move_vals())[0] - move.with_context(do_not_unreserve=True).product_uom_qty = production.product_qty * unit_factor + move.with_context(do_not_unreserve=True, no_procurement=True).product_uom_qty = production.product_qty * unit_factor for backorder in production_to_backorders[production]: move_vals = dict( @@ -2055,6 +2041,7 @@ class MrpProduction(models.Model): for move in production.move_raw_ids: for field, vals in origs[move.bom_line_id.id].items(): move[field] = vals + for move in production.move_finished_ids: move.move_dest_ids = [Command.set(dests[move.byproduct_id.id])] @@ -2069,6 +2056,8 @@ class MrpProduction(models.Model): production.state = 'confirmed' self.with_context(skip_activity=True)._action_cancel() + # set the new deadline of origin moves (stock to pre prod) + production.move_raw_ids.move_orig_ids.with_context(date_deadline_propagate_ids=set(production.move_raw_ids.ids)).write({'date_deadline': production.date_planned_start}) for p in self: p._message_log(body=_('This production has been merge in %s', production.display_name)) diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 9a8a9cdb9b17..239a97725fda 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -619,7 +619,7 @@ class MrpWorkorder(models.Model): }) if self.state == 'progress': return True - start_date = datetime.now() + start_date = fields.Datetime.now() vals = { 'state': 'progress', 'date_start': start_date, diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 582ff8558985..4f6575df81cf 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -304,8 +304,35 @@ class StockMove(models.Model): # so possibly unlink lines move_line_vals = vals.pop('move_line_ids') super().write({'move_line_ids': move_line_vals}) + if 'product_uom_qty' in vals and self.raw_material_production_id.state == 'confirmed' and not self.env.context.get('no_procurement', False): + # when updating consumed qty need to update related pickings + # context no_procurement means we don't want the qty update to modify stock i.e create new pickings + # ex. when spliting MO to backorders we don't want to move qty from pre prod to stock in 2/3 step config + self._run_procurement(vals['product_uom_qty'], self.product_uom_qty) return super().write(vals) + def _run_procurement(self, new_qty, old_qty): + procurements = [] + to_assign = self.env['stock.move'] + self._adjust_procure_method() + for move in self: + if new_qty > 0: + if move._should_bypass_reservation() \ + or move.picking_type_id.reservation_method == 'at_confirm' \ + or (move.reservation_date and move.reservation_date <= fields.Date.today()): + to_assign |= move + + if move.procure_method == 'make_to_order': + procurement_qty = new_qty - old_qty + values = move._prepare_procurement_values() + procurements.append(self.env['procurement.group'].Procurement( + move.product_id, procurement_qty, move.product_uom, + move.location_id, move.name, move.origin, move.company_id, values)) + + to_assign._action_assign() + if procurements: + self.env['procurement.group'].run(procurements) + def _action_assign(self, force_qty=False): res = super(StockMove, self)._action_assign(force_qty=force_qty) for move in self.filtered(lambda x: x.production_id or x.raw_material_production_id): @@ -531,12 +558,16 @@ class StockMove(models.Model): else: self.quantity_done = new_qty - def _update_candidate_moves_list(self, candidate_moves_list): - super()._update_candidate_moves_list(candidate_moves_list) + def _update_candidate_moves_list(self, candidate_moves_set): + super()._update_candidate_moves_list(candidate_moves_set) for production in self.mapped('raw_material_production_id'): - candidate_moves_list.append(production.move_raw_ids) + candidate_moves_set.add(production.move_raw_ids) for production in self.mapped('production_id'): - candidate_moves_list.append(production.move_finished_ids) + candidate_moves_set.add(production.move_finished_ids) + # this will include sibling pickings as a result of merging MOs + for picking in self.move_dest_ids.raw_material_production_id.picking_ids: + candidate_moves_set.add(picking.move_ids) + def _multi_line_quantity_done_set(self, quantity_done): if self.raw_material_production_id: diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index 24350dca7ca9..3caa9d798257 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -751,3 +751,132 @@ class TestProcurement(TestMrpCommon): {'product_qty': 1, 'bom_id': bom01.id, 'picking_type_id': manu_operation01.id, 'location_dest_id': stock_location01.id}, {'product_qty': 2, 'bom_id': bom02.id, 'picking_type_id': manu_operation02.id, 'location_dest_id': stock_location02.id}, ]) + + def test_update_mo_component_qty(self): + """ After Confirming MO, updating component qty should run procurement + to update orig move qty + """ + warehouse = self.env['stock.warehouse'].search([], limit=1) + # 2 steps Manufacture + warehouse.write({'manufacture_steps': 'pbm'}) + mo, *_ = self.generate_mo(qty_final=2, qty_base_1=1, qty_base_2=2) + self.assertEqual(mo.state, 'confirmed', 'MO should be confirmed at this point') + self.assertEqual(mo.product_qty, 2, 'MO qty to produce should be 2') + self.assertEqual(mo.move_raw_ids.mapped('product_uom_qty'), [4, 2], 'Comp2 qty should be 4 and comp1 should be 2') + self.assertEqual(mo.picking_ids.move_ids.mapped('product_uom_qty'), [4, 2], 'Comp moves should have same qty as MO') + # decrease comp2 qty, should reflect in picking + mo.move_raw_ids[0].product_uom_qty = 2 + self.assertEqual(mo.picking_ids.move_ids[0].product_uom_qty, 2, 'Comp2 move should have same qty as MO') + + # add a third component, should reflect in picking + comp3 = self.env['product.product'].create({ + 'name': 'Comp3', + 'type': 'product' + }) + mo.write({ + 'move_raw_ids': [(0, 0, { + 'product_id': comp3.id, + 'product_uom_qty': 3 + })] + }) + self.assertEqual(len(mo.picking_ids.move_ids), 3, 'Picking should have 3 moves') + self.assertEqual(mo.picking_ids.move_ids[2].product_uom_qty, 3, 'Comp3 move should have same qty as MO') + # change its qty + mo.move_raw_ids[2].product_uom_qty = 4 + self.assertEqual(mo.picking_ids.move_ids[2].product_uom_qty, 4, 'Comp3 move should have same qty as MO') + + # increase qty to produce + wiz = self.env['change.production.qty'].create({ + 'mo_id': mo.id, + 'product_qty': 4 + }) + wiz.change_prod_qty() + self.assertEqual(mo.product_qty, 4, 'MO qty to produce should be 4') + # each move qty should be doubled + self.assertEqual(mo.picking_ids.move_ids.mapped('product_uom_qty'), [4, 4, 8], 'Comps move should have same qty as MO') + + def test_update_merged_mo_component_qty(self): + """ After Confirming two MOs merge then and change their component qtys, + Procurements should run and any new moves should be merged with old ones + """ + warehouse = self.env['stock.warehouse'].search([], limit=1) + # 2 steps Manufacture + warehouse.write({'manufacture_steps': 'pbm'}) + + super_product = self.env['product.product'].create({ + 'name': 'Super Product', + 'type': 'product', + }) + comp1 = self.env['product.product'].create({ + 'name': 'Comp1', + 'type': 'product', + }) + comp2 = self.env['product.product'].create({ + 'name': 'Comp2', + 'type': 'product', + }) + bom = self.env['mrp.bom'].create({ + 'product_id': super_product.id, + 'product_tmpl_id': super_product.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'consumption': 'flexible', + 'bom_line_ids': [ + (0, 0, {'product_id': comp1.id, 'product_qty': 1}), + (0, 0, {'product_id': comp2.id, 'product_qty': 2}) + ] + }) + # MO 1 + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = super_product + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo_1 = mo_form.save() + mo_1.action_confirm() + + # MO 2 + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = super_product + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo_2 = mo_form.save() + mo_2.action_confirm() + + res_mo_id = (mo_1 | mo_2).action_merge()['res_id'] + mo = self.env['mrp.production'].browse(res_mo_id) + self.assertEqual(mo.product_qty, 2, 'Qty to produce should be 2') + self.assertEqual(mo.move_raw_ids.mapped('product_uom_qty'), [2, 4], 'Comp1 qty should be 2 and comp2 should be 4') + self.assertEqual(mo.picking_ids[0].move_ids.mapped('product_uom_qty'), [1, 2], 'Comp moves should have same qty as old MO') + # increase Comp1 qty by 1 in MO + mo.move_raw_ids[0].product_uom_qty = 3 + + # any required qty is added to first picking by procurement + self.assertEqual(mo.picking_ids[0].move_ids[0].product_uom_qty, 2, 'Comp1 qty increase should reflect in picking') + + # add new comp3 + comp3 = self.env['product.product'].create({ + 'name': 'Comp3', + 'type': 'product' + }) + mo.write({ + 'move_raw_ids': [(0, 0, { + 'product_id': comp3.id, + 'product_uom_qty': 2, + })] + }) + self.assertEqual(len(mo.picking_ids[0].move_ids), 3, 'Picking should have 3 moves') + self.assertEqual(mo.picking_ids[0].move_ids[2].product_uom_qty, 2, 'Comp3 move should have same qty as MO') + + # increase qty to produce + wiz = self.env['change.production.qty'].create({ + 'mo_id': mo.id, + 'product_qty': 4 + }) + wiz.change_prod_qty() + self.assertEqual(mo.product_qty, 4, 'MO qty to produce should be 4') + # extra quantities are all added to first picking moves + # comp1 (2 + 3 extra) = 5 + # comp2 (2 + 4 extra) = 6 + # comp3 (2 + 2 extra) = 4 + self.assertEqual(mo.picking_ids[0].move_ids.mapped('product_uom_qty'), [5, 6, 4], 'Comp qty do not match expected') diff --git a/addons/mrp/wizard/mrp_production_backorder.py b/addons/mrp/wizard/mrp_production_backorder.py index 3df53d37f6a4..4c9b28d8df7f 100644 --- a/addons/mrp/wizard/mrp_production_backorder.py +++ b/addons/mrp/wizard/mrp_production_backorder.py @@ -31,7 +31,7 @@ class MrpProductionBackorder(models.TransientModel): wizard.show_backorder_lines = len(wizard.mrp_production_backorder_line_ids) > 1 def action_close_mo(self): - return self.mrp_production_ids.with_context(skip_backorder=True).button_mark_done() + return self.mrp_production_ids.with_context(skip_backorder=True, no_procurement=True).button_mark_done() def action_backorder(self): ctx = dict(self.env.context) diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 454924dba556..d406488180d3 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -900,9 +900,9 @@ Please change the quantity done or the rounding precision of your unit of measur """Cleanup hook used when merging moves""" self.write({'propagate_cancel': False}) - def _update_candidate_moves_list(self, candidate_moves_list): + def _update_candidate_moves_list(self, candidate_moves_set): for picking in self.mapped('picking_id'): - candidate_moves_list.append(picking.move_ids) + candidate_moves_set.add(picking.move_ids) def _merge_moves(self, merge_into=False): """ This method will, for each move in `self`, go up in their linked picking and try to @@ -912,11 +912,11 @@ Please change the quantity done or the rounding precision of your unit of measur """ distinct_fields = self._prepare_merge_moves_distinct_fields() - candidate_moves_list = [] + candidate_moves_set = set() if not merge_into: - self._update_candidate_moves_list(candidate_moves_list) + self._update_candidate_moves_list(candidate_moves_set) else: - candidate_moves_list.append(merge_into | self) + candidate_moves_set.add(merge_into | self) # Move removed after merge moves_to_unlink = self.env['stock.move'] @@ -933,7 +933,7 @@ Please change the quantity done or the rounding precision of your unit of measur excluded_fields = self._prepare_merge_negative_moves_excluded_distinct_fields() neg_key = itemgetter(*[field for field in distinct_fields if field not in excluded_fields]) - for candidate_moves in candidate_moves_list: + for candidate_moves in candidate_moves_set: # First step find move to merge. candidate_moves = candidate_moves.filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) - neg_qty_moves for __, g in groupby(candidate_moves, key=itemgetter(*distinct_fields)): -- GitLab