From a080337d7e8bf515018d9cfb7996461adf717224 Mon Sep 17 00:00:00 2001 From: "Pieter Claeys (clpi)" Date: Fri, 3 Mar 2023 13:18:47 +0000 Subject: [PATCH] [IMP] stock,mrp: allow scrapping of kits When creating a scrap order on a product which has at least one kit BoM, an option is added to create a scrap order for this kit. The user can select from the kit BoMs of this product and the scrap order will add stock moves for all the components of the selected kit instead of for the product itself. Task: 2479234 (nr 9) Community PR: https://github.com/odoo/odoo/pull/114315 Enterprise PR: https://github.com/odoo/enterprise/pull/37764 Part-of: odoo/odoo#114315 --- addons/mrp/models/stock_move.py | 4 +- addons/mrp/models/stock_scrap.py | 23 +++++++++++- addons/mrp/tests/test_stock.py | 2 +- addons/mrp/views/stock_scrap_views.xml | 6 +++ addons/stock/models/stock_move.py | 4 +- addons/stock/models/stock_picking.py | 2 +- addons/stock/models/stock_scrap.py | 46 +++++++++++++---------- addons/stock/report/stock_traceability.py | 6 +-- addons/stock/tests/test_move.py | 6 +-- addons/stock/tests/test_stock_flow.py | 2 +- addons/stock/views/stock_scrap_views.xml | 4 +- 11 files changed, 70 insertions(+), 35 deletions(-) diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 21ce6234d3e..49bcbe0799b 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -351,7 +351,7 @@ class StockMove(models.Model): moves_ids_to_unlink = OrderedSet() phantom_moves_vals_list = [] for move in self: - if not move.picking_type_id or (move.production_id and move.production_id.product_id == move.product_id): + if (not move.picking_type_id and not self.env.context.get('is_scrap')) or (move.production_id and move.production_id.product_id == move.product_id): moves_ids_to_return.add(move.id) continue bom = self.env['mrp.bom'].sudo()._bom_find(move.product_id, company_id=move.company_id.id, bom_type='phantom')[move.product_id] @@ -364,7 +364,7 @@ class StockMove(models.Model): factor = move.product_uom._compute_quantity(move.product_uom_qty, bom.product_uom_id) / bom.product_qty boms, lines = bom.sudo().explode(move.product_id, factor, picking_type=bom.picking_type_id) for bom_line, line_data in lines: - if move.picking_id.immediate_transfer or float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding): + if move.picking_id.immediate_transfer or float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding) or self.env.context.get('is_scrap'): phantom_moves_vals_list += move._generate_move_phantom(bom_line, 0, line_data['qty']) else: phantom_moves_vals_list += move._generate_move_phantom(bom_line, line_data['qty'], 0) diff --git a/addons/mrp/models/stock_scrap.py b/addons/mrp/models/stock_scrap.py index 20ea8329c6a..b8f4184c8de 100644 --- a/addons/mrp/models/stock_scrap.py +++ b/addons/mrp/models/stock_scrap.py @@ -3,7 +3,6 @@ from odoo import _, api, fields, models - class StockScrap(models.Model): _inherit = 'stock.scrap' @@ -14,6 +13,12 @@ class StockScrap(models.Model): 'mrp.workorder', 'Work Order', states={'done': [('readonly', True)]}, check_company=True) # Not to restrict or prefer quants, but informative + product_is_kit = fields.Boolean(related='product_id.is_kits') + product_template = fields.Many2one(related='product_id.product_tmpl_id') + bom_id = fields.Many2one( + 'mrp.bom', 'Kit', + domain="[('type', '=', 'phantom'), '|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_template)]", + states={'done': [('readonly', True)]}, check_company=True) @api.onchange('workorder_id') def _onchange_workorder_id(self): @@ -50,3 +55,19 @@ class StockScrap(models.Model): return {'warning': {'title': _('Warning'), 'message': message}} else: return super()._onchange_serial_number() + + @api.depends('move_ids', 'move_ids.move_line_ids.qty_done', 'product_id') + def _compute_scrap_qty(self): + self.scrap_qty = 1 + for scrap in self: + if not scrap.bom_id: + return super(StockScrap, scrap)._compute_scrap_qty() + if scrap.move_ids: + filters = { + 'incoming_moves': lambda m: True, + 'outgoing_moves': lambda m: False + } + scrap.scrap_qty = scrap.move_ids._compute_kit_quantities(scrap.product_id, scrap.scrap_qty, scrap.bom_id, filters) + + def _should_check_available_qty(self): + return super()._should_check_available_qty() or self.product_is_kit diff --git a/addons/mrp/tests/test_stock.py b/addons/mrp/tests/test_stock.py index f15ae8e219f..27f6a4c0c32 100644 --- a/addons/mrp/tests/test_stock.py +++ b/addons/mrp/tests/test_stock.py @@ -146,7 +146,7 @@ class TestWarehouseMrp(common.TestMrpCommon): # Scrap Product Wood with lot. scrap_id = self.env['stock.scrap'].with_context(active_model='mrp.production', active_id=production_3.id).create({'product_id': self.product_2.id, 'scrap_qty': 1.0, 'product_uom_id': self.product_2.uom_id.id, 'location_id': location_id, 'lot_id': lot_product_2.id, 'production_id': production_3.id}) scrap_id.do_scrap() - scrap_move = scrap_id.move_id + scrap_move = scrap_id.move_ids[0] self.assertTrue(scrap_move.raw_material_production_id) self.assertTrue(scrap_move.scrapped) diff --git a/addons/mrp/views/stock_scrap_views.xml b/addons/mrp/views/stock_scrap_views.xml index 11fcd7bd077..e1b51b20ab8 100644 --- a/addons/mrp/views/stock_scrap_views.xml +++ b/addons/mrp/views/stock_scrap_views.xml @@ -8,6 +8,9 @@ + + + @@ -19,6 +22,9 @@ + + + diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 703f6bd965e..f99ef69c8cc 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -127,7 +127,7 @@ class StockMove(models.Model): "this second option should be chosen.") scrapped = fields.Boolean( 'Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True) - scrap_ids = fields.One2many('stock.scrap', 'move_id') + scrap_id = fields.Many2one('stock.scrap', 'Scrap operation', readonly=True, check_company=True) group_id = fields.Many2one('procurement.group', 'Procurement Group', default=_default_group_id, index=True) rule_id = fields.Many2one( 'stock.rule', 'Stock Rule', ondelete='restrict', help='The stock rule that created this stock move', @@ -1881,7 +1881,7 @@ Please change the quantity done or the rounding precision of your unit of measur # We don't want to create back order for scrap moves # Replace by a kwarg in master if self.env.context.get('is_scrap'): - return moves_todo + return moves if picking and not cancel_backorder: backorder = picking._create_backorder() diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 8c33239c9f6..967ab43e396 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -947,7 +947,7 @@ class Picking(models.Model): move_ids_without_package |= move else: move_ids_without_package |= move - return move_ids_without_package.filtered(lambda move: not move.scrap_ids) + return move_ids_without_package.filtered(lambda move: not move.scrap_id) def _check_move_lines_map_quant_package(self, package): return package._check_move_lines_map_quant(self.move_line_ids.filtered(lambda ml: ml.package_id == package), 'reserved_qty') diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index c2719d63f14..1034cfe6db2 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -34,7 +34,7 @@ class StockScrap(models.Model): 'stock.quant.package', 'Package', states={'done': [('readonly', True)]}, check_company=True) owner_id = fields.Many2one('res.partner', 'Owner', states={'done': [('readonly', True)]}, check_company=True) - move_id = fields.Many2one('stock.move', 'Scrap Move', readonly=True, check_company=True, copy=False) + move_ids = fields.One2many('stock.move', 'scrap_id') picking_id = fields.Many2one('stock.picking', 'Picking', states={'done': [('readonly', True)]}, check_company=True) location_id = fields.Many2one( 'stock.location', 'Source Location', @@ -46,7 +46,7 @@ class StockScrap(models.Model): domain="[('scrap_location', '=', True), ('company_id', 'in', [company_id, False])]", check_company=True, readonly=False) scrap_qty = fields.Float( 'Quantity', required=True, states={'done': [('readonly', True)]}, digits='Product Unit of Measure', - compute='_compute_scrap_qty', precompute=True, readonly=False, store=True) + compute='_compute_scrap_qty', default=0.0, readonly=False, store=True) state = fields.Selection([ ('draft', 'Draft'), ('done', 'Done')], @@ -83,12 +83,12 @@ class StockScrap(models.Model): for scrap in self: scrap.scrap_location_id = locations_per_company[scrap.company_id.id] - @api.depends('move_id', 'move_id.move_line_ids.qty_done', 'product_id') + @api.depends('move_ids', 'move_ids.move_line_ids.qty_done', 'product_id') def _compute_scrap_qty(self): self.scrap_qty = 1 for scrap in self: - if scrap.move_id: - scrap.scrap_qty = scrap.move_id.quantity_done + if scrap.move_ids: + scrap.scrap_qty = scrap.move_ids[0].quantity_done @api.onchange('lot_id') def _onchange_serial_number(self): @@ -120,6 +120,7 @@ class StockScrap(models.Model): 'product_uom_qty': self.scrap_qty, 'location_id': self.location_id.id, 'scrapped': True, + 'scrap_id': self.id, 'location_dest_id': self.scrap_location_id.id, 'move_line_ids': [(0, 0, {'product_id': self.product_id.id, 'product_uom_id': self.product_uom_id.id, @@ -140,7 +141,7 @@ class StockScrap(models.Model): move = self.env['stock.move'].create(scrap._prepare_move_values()) # master: replace context by cancel_backorder move.with_context(is_scrap=True)._action_done() - scrap.write({'move_id': move.id, 'state': 'done'}) + scrap.write({'state': 'done'}) scrap.date_done = fields.Datetime.now() return True @@ -151,25 +152,32 @@ class StockScrap(models.Model): def action_get_stock_move_lines(self): action = self.env['ir.actions.act_window']._for_xml_id('stock.stock_move_line_action') - action['domain'] = [('move_id', '=', self.move_id.id)] + action['domain'] = [('move_id', 'in', self.move_ids.ids)] return action + def _should_check_available_qty(self): + return self.product_id.type == 'product' + + def check_available_qty(self): + if not self._should_check_available_qty(): + return True + + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + available_qty = self.with_context( + location=self.location_id.id, + lot_id=self.lot_id.id, + package_id=self.package_id.id, + owner_id=self.owner_id.id + ).product_id.qty_available + scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id) + return float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0 + def action_validate(self): self.ensure_one() if float_is_zero(self.scrap_qty, precision_rounding=self.product_uom_id.rounding): raise UserError(_('You can only enter positive quantities.')) - if self.product_id.type != 'product': - return self.do_scrap() - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - available_qty = sum(self.env['stock.quant']._gather(self.product_id, - self.location_id, - self.lot_id, - self.package_id, - self.owner_id, - strict=True).mapped('quantity')) - scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id) - if float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0: + if self.check_available_qty(): return self.do_scrap() else: ctx = dict(self.env.context) @@ -177,7 +185,7 @@ class StockScrap(models.Model): 'default_product_id': self.product_id.id, 'default_location_id': self.location_id.id, 'default_scrap_id': self.id, - 'default_quantity': scrap_qty, + 'default_quantity': self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id), 'default_product_uom_name': self.product_id.uom_name }) return { diff --git a/addons/stock/report/stock_traceability.py b/addons/stock/report/stock_traceability.py index eb1aba84c8b..15d99f77076 100644 --- a/addons/stock/report/stock_traceability.py +++ b/addons/stock/report/stock_traceability.py @@ -94,10 +94,10 @@ class MrpStockReport(models.TransientModel): res_model = 'stock.move' res_id = move_line.move_id.id ref = 'Inventory Adjustment' - elif move_line.move_id.scrapped and move_line.move_id.scrap_ids: + elif move_line.move_id.scrapped and move_line.move_id.scrap_id: res_model = 'stock.scrap' - res_id = move_line.move_id.scrap_ids[0].id - ref = move_line.move_id.scrap_ids[0].name + res_id = move_line.move_id.scrap_id.id + ref = move_line.move_id.scrap_id.name return res_model, res_id, ref @api.model diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index 1184dfb7e17..72983743f1d 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -4727,7 +4727,7 @@ class StockMove(TransactionCase): scrap = scrap_form.save() scrap.do_scrap() self.assertEqual(scrap.state, 'done') - move = scrap.move_id + move = scrap.move_ids[0] self.assertEqual(move.state, 'done') self.assertEqual(move.quantity_done, 1) self.assertEqual(move.scrapped, True) @@ -4746,7 +4746,7 @@ class StockMove(TransactionCase): scrap.do_scrap() self.assertTrue(scrap.name.startswith('SP/'), 'Sequence should be Changed after do_scrap') self.assertEqual(scrap.state, 'done') - move = scrap.move_id + move = scrap.move_ids[0] self.assertEqual(move.state, 'done') self.assertEqual(move.quantity_done, 1) self.assertEqual(move.scrapped, True) @@ -4818,7 +4818,7 @@ class StockMove(TransactionCase): self.assertEqual(len(picking.move_ids), 2) scrapped_move = picking.move_ids.filtered(lambda m: m.state == 'done') self.assertTrue(scrapped_move, 'No scrapped move created.') - self.assertEqual(scrapped_move.scrap_ids.ids, [scrap.id], 'Wrong scrap linked to the move.') + self.assertEqual(scrapped_move.scrap_id.id, scrap.id, 'Wrong scrap linked to the move.') self.assertEqual(scrap.scrap_qty, 5, 'Scrap quantity has been modified and is not correct anymore.') scrapped_move.quantity_done = 8 diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py index 56f9a5793cc..c466c4725f6 100644 --- a/addons/stock/tests/test_stock_flow.py +++ b/addons/stock/tests/test_stock_flow.py @@ -2381,7 +2381,7 @@ class TestStockFlow(TestStockCommon): self.assertEqual(picking.state, 'cancel') self.assertEqual(move.state, 'cancel') - self.assertEqual(scrap.move_id.state, 'done') + self.assertEqual(scrap.move_ids[0].state, 'done') def test_receive_tracked_product(self): self.productA.tracking = 'serial' diff --git a/addons/stock/views/stock_scrap_views.xml b/addons/stock/views/stock_scrap_views.xml index 97f862422c5..e90fde8b570 100644 --- a/addons/stock/views/stock_scrap_views.xml +++ b/addons/stock/views/stock_scrap_views.xml @@ -38,12 +38,12 @@ - +