[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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user