diff --git a/addons/mrp_subcontracting/models/mrp_production.py b/addons/mrp_subcontracting/models/mrp_production.py index c311ffb271b3a..652ae81cd0616 100644 --- a/addons/mrp_subcontracting/models/mrp_production.py +++ b/addons/mrp_subcontracting/models/mrp_production.py @@ -58,7 +58,15 @@ def write(self, vals): unauthorized_fields = set(vals.keys()) - set(self._get_writeable_fields_portal_user()) if unauthorized_fields: raise AccessError(_("You cannot write on fields %s in mrp.production.", ', '.join(unauthorized_fields))) - return super().write(vals) + res = super().write(vals) + if 'lot_producing_id' in vals and vals.get('lot_producing_id'): + for production in self: + if production._should_auto_record_subcontract_components(): + try: + production.subcontracting_record_component() + except Exception: + pass + return res def action_merge(self): if any(production._get_subcontract_move() for production in self): @@ -204,3 +212,26 @@ def _subcontract_sanity_check(self): if sml.tracking != 'none' and not sml.lot_id: raise UserError(_('You must enter a serial number for each line of %s') % sml.product_id.display_name) return True + + def action_generate_serial(self): + res = super().action_generate_serial() + if self._should_auto_record_subcontract_components(): + try: + self.subcontracting_record_component() + except Exception: + pass + return res + + def _should_auto_record_subcontract_components(self): + self.ensure_one() + if ( + not self.lot_producing_id + or self.subcontracting_has_been_recorded + or not self._get_subcontract_move() + or self.state in ('done', 'cancel') + or self.product_id.tracking != 'serial' + ): + return False + if not self.move_raw_ids or not any(self.move_raw_ids.mapped('quantity_done')): + return False + return True diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index 458eb113a5b03..49b761a37995c 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -1880,3 +1880,70 @@ def test_bom_subcontracting_product_dynamic_attribute(self): }) report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom.id, searchVariant=False) self.assertTrue(report_values) + + def test_subcontracting_serial_mass_produce(self): + """ + Test that Mass Produce on a subcontracting MO with serial-tracked finished + products properly records subcontracted components and syncs serials to receipt. + """ + # Set finished product to serial tracking + self.finished.tracking = 'serial' + + # Get subcontract location + subcontract_location = self.subcontractor_partner1.property_stock_subcontractor + + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished + move.product_uom_qty = 2 + receipt = picking_form.save() + receipt.action_confirm() + + # Add components to subcontractor location + self.env['stock.quant']._update_available_quantity( + self.comp1, subcontract_location, 2 + ) + self.env['stock.quant']._update_available_quantity( + self.comp2, subcontract_location, 2 + ) + + # Get the created manufacturing order + production = receipt.move_ids.move_orig_ids.production_id + production.action_assign() + + # Use Mass Produce wizard to assign serial numbers + action = production.action_serial_mass_produce_wizard() + wizard = Form(self.env['stock.assign.serial'].with_context(**action['context'])) + wizard.next_serial_number = 'SN0001' + wizard.next_serial_count = 2 + action = wizard.save().generate_serial_numbers_production() + wizard = Form(self.env['stock.assign.serial'].browse(action['res_id'])) + wizard.save().apply() + + # Get all productions after split + productions = receipt.move_ids.move_orig_ids.production_id.sorted('id') + + # Verify: should have 2 productions (one per serial) + self.assertEqual(len(productions), 2) + + # Verify: subcontracting_has_been_recorded should be True for both + self.assertEqual( + productions.mapped('subcontracting_has_been_recorded'), [True, True], + "Mass Produce should have recorded subcontracted components for both MOs" + ) + + # Verify: serials are assigned to productions + self.assertEqual( + productions.mapped('lot_producing_id.name'), ['SN0001', 'SN0002'], + "Serial numbers should be assigned to split productions" + ) + + # Verify: serials are synced to receipt move lines + self.assertEqual( + sorted(receipt.move_line_ids.mapped('lot_id.name')), + ['SN0001', 'SN0002'], + "Serial numbers should be synced to receipt move lines" + ) diff --git a/addons/mrp_subcontracting/wizard/__init__.py b/addons/mrp_subcontracting/wizard/__init__.py index 0594ad02b2a1b..3b45e10da68d8 100644 --- a/addons/mrp_subcontracting/wizard/__init__.py +++ b/addons/mrp_subcontracting/wizard/__init__.py @@ -4,3 +4,4 @@ from . import stock_picking_return from . import mrp_consumption_warning from . import change_production_qty +from . import stock_assign_serial_numbers diff --git a/addons/mrp_subcontracting/wizard/stock_assign_serial_numbers.py b/addons/mrp_subcontracting/wizard/stock_assign_serial_numbers.py new file mode 100644 index 0000000000000..a739e09f8b95d --- /dev/null +++ b/addons/mrp_subcontracting/wizard/stock_assign_serial_numbers.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class StockAssignSerialNumbers(models.TransientModel): + _inherit = 'stock.assign.serial' + + def _assign_serial_numbers(self, cancel_remaining_quantity=False): + serial_numbers = set(self._get_serial_numbers()) + res = super()._assign_serial_numbers(cancel_remaining_quantity) + if not serial_numbers: + return res + # Record subcontracted components for each production with assigned serial + # When Mass Produce splits a subcontracting MO and assigns serials, + # subcontracting_record_component() is not called automatically, + # causing finished serials to not sync to receipt move lines + productions = ( + self.production_id.procurement_group_id.mrp_production_ids.filtered( + lambda mo: mo.state not in ('done', 'cancel') + and not mo.subcontracting_has_been_recorded + and mo._get_subcontract_move() + and mo.lot_producing_id + and mo.lot_producing_id.name in serial_numbers + ) + ) + for production in productions: + production.subcontracting_record_component() + return res