[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 <tic@odoo.com>
Signed-off-by: Adrien Widart <awt@odoo.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,3 +5,4 @@ from . import repair
|
||||
from . import stock_traceability
|
||||
from . import stock_lot
|
||||
from . import account_move
|
||||
from . import product
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user