diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 253ce325905..57b69f950e3 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 9a8a9cdb9b1..239a97725fd 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 582ff855898..4f6575df81c 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 24350dca7ca..3caa9d79825 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 3df53d37f6a..4c9b28d8df7 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 454924dba55..d406488180d 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)):