From 683c02b099ac616cba747e5bba1cebb126e5f8ed Mon Sep 17 00:00:00 2001 From: Adrien Widart Date: Wed, 29 Dec 2021 14:19:31 +0000 Subject: [PATCH] [FIX] repair,mrp,stock: count returned SN products Suppose a tracked-by-usn and consumed product returns in the stock thanks to a repair order. Using again this component in a new manufacturing order will raise an error To reproduce the issue: 1. In Settings, enable "Storage Locations" 2. Create two products P_finished, P_compo - Storable - P_comp tracked by USN 3. Update the quantity of P_compo: - WH/Stock: 1 x Lot01 4. Create a manufacturing order MO: - Product: P_finished - Components: - 1 x P_compo 5. Confirm, Check availability and Mark MO as Done - (Lot01 should be consumed) 6. Create a repair order RO: - Product: P_finished - Parts: - Type: Remove - Product: P_compo - Lot: Lot01 - Destination Location: WH/Stock 7. Confirm RO, Start RO, End RO - (There should be one Lot01 available in stock) 8. Repeat 4-5 Error: When checking the availability on the MO, Lot01 is correctly reserved. However, when marking the second MO as done, a User Error is displayed: "The serial number Lot01 used for component P_compo has already been consumed" although this lot should be available When checking the uniqueness of the lot, nothing includes the products back in stock thanks to the repair orders. OPW-2701668 closes odoo/odoo#82544 X-original-commit: 3d9355f90fa1dd9436f1745c515c89947ed04de0 Signed-off-by: Tiffany Chang Signed-off-by: Adrien Widart --- addons/mrp/models/mrp_production.py | 22 +++----- addons/mrp/models/product.py | 11 ++++ addons/mrp_repair/tests/test_tracability.py | 58 +++++++++++++++++++++ addons/repair/models/__init__.py | 1 + addons/repair/models/product.py | 18 +++++++ addons/stock/models/product.py | 4 ++ 6 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 addons/repair/models/product.py diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index d477d393837..719c8521864 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -1949,36 +1949,28 @@ class MrpProduction(models.Model): for move_line in move.move_line_ids: if float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding): continue - domain = [ - ('lot_id', '=', move_line.lot_id.id), - ('qty_done', '=', 1), - ('state', '=', 'done') - ] message = _('The serial number %(number)s used for component %(component)s has already been consumed', number=move_line.lot_id.name, component=move_line.product_id.name) co_prod_move_lines = self.move_raw_ids.move_line_ids - domain_unbuild = domain + [ - ('production_id', '=', False), - ('location_id.usage', '=', 'production') - ] # Check presence of same sn in previous productions - duplicates = self.env['stock.move.line'].search_count(domain + [ - ('location_dest_id.usage', '=', 'production') + duplicates = self.env['stock.move.line'].search_count([ + ('lot_id', '=', move_line.lot_id.id), + ('qty_done', '=', 1), + ('state', '=', 'done'), + ('location_dest_id.usage', '=', 'production'), ]) if duplicates: # Maybe some move lines have been compensated by unbuild - duplicates_unbuild = self.env['stock.move.line'].search_count(domain_unbuild + [ - ('move_id.unbuild_id', '!=', False) - ]) + duplicates_returned = move.product_id._count_returned_sn_products(move_line.lot_id) removed = self.env['stock.move.line'].search_count([ ('lot_id', '=', move_line.lot_id.id), ('state', '=', 'done'), ('location_dest_id.scrap_location', '=', True) ]) # Either removed or unbuild - if not ((duplicates_unbuild or removed) and duplicates - duplicates_unbuild - removed == 0): + if not ((duplicates_returned or removed) and duplicates - duplicates_returned - removed == 0): raise UserError(message) # Check presence of same sn in current production duplicates = co_prod_move_lines.filtered(lambda ml: ml.qty_done and ml.lot_id == move_line.lot_id) - move_line diff --git a/addons/mrp/models/product.py b/addons/mrp/models/product.py index eb1ea0ad4e6..e4751689c07 100644 --- a/addons/mrp/models/product.py +++ b/addons/mrp/models/product.py @@ -261,3 +261,14 @@ class ProductProduct(models.Model): if not any(ptav in self.product_template_attribute_value_ids for ptav in iter_ptav): return False return True + + def _count_returned_sn_products(self, sn_lot): + res = self.env['stock.move.line'].search_count([ + ('lot_id', '=', sn_lot.id), + ('qty_done', '=', 1), + ('state', '=', 'done'), + ('production_id', '=', False), + ('location_id.usage', '=', 'production'), + ('move_id.unbuild_id', '!=', False), + ]) + return super()._count_returned_sn_products(sn_lot) + res diff --git a/addons/mrp_repair/tests/test_tracability.py b/addons/mrp_repair/tests/test_tracability.py index 112e4f44e44..abf96ef96b0 100644 --- a/addons/mrp_repair/tests/test_tracability.py +++ b/addons/mrp_repair/tests/test_tracability.py @@ -72,3 +72,61 @@ class TestRepairTraceability(TestMrpCommon): }) # We are not forbidden to use that serial number, so nothing raised here mo2.button_mark_done() + + def test_mo_with_used_sn_component(self): + """ + Suppose a tracked-by-usn component has been used to produce a product. Then, using a repair order, + this component is removed from the product and returned as available stock. The user should be able to + use the component in a new MO + """ + def produce_one(product, component): + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product + with mo_form.move_raw_ids.new() as raw_line: + raw_line.product_id = component + raw_line.product_uom_qty = 1 + mo = mo_form.save() + mo.action_confirm() + mo.action_assign() + action = mo.button_mark_done() + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + wizard.process() + return mo + + stock_location = self.env.ref('stock.stock_location_stock') + + finished, component = self.env['product.product'].create([{ + 'name': 'Finished Product', + 'type': 'product', + }, { + 'name': 'SN Componentt', + 'type': 'product', + 'tracking': 'serial', + }]) + + sn_lot = self.env['stock.lot'].create({ + 'product_id': component.id, + 'name': 'USN01', + 'company_id': self.env.company.id, + }) + self.env['stock.quant']._update_available_quantity(component, stock_location, 1, lot_id=sn_lot) + + mo = produce_one(finished, component) + self.assertEqual(mo.state, 'done') + self.assertEqual(mo.move_raw_ids.lot_ids, sn_lot) + + ro_form = Form(self.env['repair.order']) + ro_form.product_id = finished + with ro_form.operations.new() as ro_line: + ro_line.type = 'remove' + ro_line.product_id = component + ro_line.lot_id = sn_lot + ro_line.location_dest_id = stock_location + ro = ro_form.save() + ro.action_validate() + ro.action_repair_start() + ro.action_repair_end() + + mo = produce_one(finished, component) + self.assertEqual(mo.state, 'done') + self.assertEqual(mo.move_raw_ids.lot_ids, sn_lot) diff --git a/addons/repair/models/__init__.py b/addons/repair/models/__init__.py index b082b6a40bb..4bebbca6293 100644 --- a/addons/repair/models/__init__.py +++ b/addons/repair/models/__init__.py @@ -5,3 +5,4 @@ from . import repair from . import stock_traceability from . import stock_lot from . import account_move +from . import product diff --git a/addons/repair/models/product.py b/addons/repair/models/product.py new file mode 100644 index 00000000000..3dcdf492202 --- /dev/null +++ b/addons/repair/models/product.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Product(models.Model): + _inherit = "product.product" + + def _count_returned_sn_products(self, sn_lot): + res = self.env['repair.line'].search_count([ + ('type', '=', 'remove'), + ('product_uom_qty', '=', 1), + ('lot_id', '=', sn_lot.id), + ('state', '=', 'done'), + ('location_dest_id.usage', '=', 'internal'), + ]) + return super()._count_returned_sn_products(sn_lot) + res diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 64c4305ab6b..ff5668e38c8 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -596,6 +596,10 @@ class Product(models.Model): linked_product_ids = [group['product_id'][0] for group in lines] return super(Product, self - self.browse(linked_product_ids))._filter_to_unlink() + @api.model + def _count_returned_sn_products(self, sn_lot): + return 0 + class ProductTemplate(models.Model): _inherit = 'product.template'