[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
This commit is contained in:
Pieter Claeys (clpi)
2023-05-25 16:28:55 +02:00
parent a235d3e968
commit a080337d7e
11 changed files with 70 additions and 35 deletions
+2 -2
View File
@@ -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)
+22 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+6
View File
@@ -8,6 +8,9 @@
<field name="owner_id" position="after">
<field name="workorder_id" invisible="1"/>
<field name="production_id" invisible="1"/>
<field name="product_is_kit" invisible="1"/>
<field name="product_template" invisible="1"/>
<field name="bom_id" attrs="{'invisible': [('product_is_kit', '=', False)], 'required': [('product_is_kit', '=', True)]}"/>
</field>
</field>
</record>
@@ -19,6 +22,9 @@
<field name="owner_id" position="after">
<field name="workorder_id" domain="[('production_id', '=', product_id)]" attrs="{'invisible': [('workorder_id', '=', False)]}"/>
<field name="production_id" domain="[('company_id', '=', company_id)]" attrs="{'invisible': [('production_id', '=', False)]}"/>
<field name="product_is_kit" invisible="1"/>
<field name="product_template" invisible="1"/>
<field name="bom_id" attrs="{'invisible': [('product_is_kit', '=', False)], 'required': [('product_is_kit', '=', True)]}"/>
</field>
</field>
</record>
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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')
+27 -19
View File
@@ -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 {
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -38,12 +38,12 @@
<field name="picking_id" invisible="1"/>
<button class="oe_stat_button" name="action_get_stock_move_lines"
type="object"
attrs="{'invisible':[('move_id','=',False)]}" icon="fa-exchange">
attrs="{'invisible':[('move_ids','=',False)]}" icon="fa-exchange">
<div class="o_stat_info">
<span class="o_stat_text">Product Moves</span>
</div>
</button>
<field name="move_id" invisible="1"/>
<field name="move_ids" invisible="1"/>
</div>
<div class="oe_title">
<h1><field name="name" nolabel="1"/></h1>