[FIX] mrp_subcontracting: set done qty in subcontracted pickings

Steps to reproduce:
- Manufacturing -> Configuration -> Settings -> Enable subcontracting
- Products -> Bill of Material
- Create a 'subcontracting' BoM for product A from supplier S
- Inventory -> Overview -> Receipts -> New planned transfer
- Set S as 'Receive from', A as product, 10 for demand and hit save
- Set 5 in the move's `quantity_done`
- Open the move details and try to record the qty, it's still possible
to record all 10 finished quantity
- If set to full quantity and saved, the move quantity will now be 15.

Issue:
In the case of a subcontracted move, only modifying the move's
`quantity_done` doesn't do much, as recording the components will add
the new `qty_producing` to the existing move's `quantity_done`, leading
to incorrect amounts if both are used.

The proposed solution here is to emulate what's done in the form when
updating the `quantity_done` of a subcontracted move. This is true also
for the generation of lot/serial numbers for the finished products, as
it would be done from quickly validating a MO through the form view.

The checks for lot/serial of component and finished products were moved
in the picking `_action_done()` rather than on the wizard itself.
This allows to set consumption of tracked components without giving a
lot, but still requires them to be set at final validation. The lots can
be assigned through the 'Register components for subcontracted product'
button.

By doing this both methods could be used correctly.

Task-3383596

Part-of: odoo/odoo#142513
This commit is contained in:
clesgow
2023-11-22 23:58:12 +00:00
parent d56eaf4a06
commit 3a644aba75
5 changed files with 137 additions and 29 deletions
+1 -1
View File
@@ -1735,7 +1735,7 @@ class MrpProduction(models.Model):
if diff > 0 and not cancel_remaining_qty:
amounts[production].append(production.product_qty - total_amount)
has_backorder_to_ignore[production] = True
elif diff < 0 or production.state in ['done', 'cancel']:
elif not self.env.context.get('allow_more') and (diff < 0 or production.state in ['done', 'cancel']):
raise UserError(_("Unable to split with more than the quantity to produce."))
backorder_vals_list = []
@@ -63,11 +63,7 @@ class MrpProduction(models.Model):
raise UserError(_("This MO isn't related to a subcontracted move"))
if float_is_zero(self.qty_producing, precision_rounding=self.product_uom_id.rounding):
return {'type': 'ir.actions.act_window_close'}
if self.product_tracking != 'none' and not self.lot_producing_id:
raise UserError(_('You must enter a serial number for %s', self.product_id.name))
for sml in self.move_raw_ids.move_line_ids:
if sml.tracking != 'none' and not sml.lot_id:
raise UserError(_('You must enter a serial number for each line of %s', sml.product_id.display_name))
if self.move_raw_ids and not any(self.move_raw_ids.mapped('quantity')):
raise UserError(_("You must indicate a non-zero amount consumed for at least one of your components"))
consumption_issues = self._get_consumption_issues()
@@ -155,10 +151,6 @@ class MrpProduction(models.Model):
return False
if not mo.subcontracting_has_been_recorded:
return False
if not all(line.lot_id for line in mo.move_raw_ids.filtered(lambda sm: sm.has_tracking != 'none').move_line_ids):
return False
if mo.product_tracking != 'none' and not mo.lot_producing_id:
return False
return True
return self.filtered(filter_in)
@@ -183,3 +175,12 @@ class MrpProduction(models.Model):
def _get_writeable_fields_portal_user(self):
return ['move_line_raw_ids', 'lot_producing_id', 'subcontracting_has_been_recorded', 'qty_producing', 'product_qty']
def _subcontract_sanity_check(self):
for production in self:
if production.product_tracking != 'none' and not self.lot_producing_id:
raise UserError(_('You must enter a serial number for %s', production.product_id.name))
for sml in production.move_raw_ids.move_line_ids:
if sml.tracking != 'none' and not sml.lot_id:
raise UserError(_('You must enter a serial number for each line of %s', sml.product_id.display_name))
return True
+86 -18
View File
@@ -5,7 +5,7 @@ from collections import defaultdict
from odoo import fields, models, api, _
from odoo.exceptions import UserError, AccessError
from odoo.tools.float_utils import float_compare, float_is_zero
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
class StockMove(models.Model):
@@ -62,6 +62,65 @@ class StockMove(models.Model):
move.show_details_visible = True
return res
def _set_quantity_done(self, qty):
to_set_moves = self
for move in self:
if move.is_subcontract and move._subcontracting_possible_record():
# If 'done' quantity is changed through the move, record components as if done through the wizard.
move._auto_record_components(qty)
to_set_moves -= move
if to_set_moves:
super(StockMove, to_set_moves)._set_quantity_done(qty)
def _set_quantity(self):
to_set_moves = self
for move in self:
if move.is_subcontract and move._subcontracting_possible_record():
move_line_quantities = sum(move.move_line_ids.filtered(lambda ml: ml.picked).mapped('quantity'))
delta_qty = move.quantity - move_line_quantities
if float_compare(delta_qty, 0, precision_rounding=move.product_uom.rounding) > 0:
move._auto_record_components(delta_qty)
to_set_moves -= move
elif float_compare(delta_qty, 0, precision_rounding=move.product_uom.rounding) < 0:
move._reduce_subcontract_order_qty(abs(delta_qty))
if to_set_moves:
super(StockMove, to_set_moves)._set_quantity()
def _auto_record_components(self, qty):
self.ensure_one()
subcontracted_productions = self._get_subcontract_production()
production = subcontracted_productions.filtered(lambda p: not p._has_been_recorded())[-1:]
if not production:
# If new quantity is over the already recorded quantity and we have no open production, then create a new one for the missing quantity.
production = subcontracted_productions[-1:]
production = production.sudo().with_context(allow_more=True)._split_productions({production: [production.qty_producing, qty]})[-1:]
qty = self.product_uom._compute_quantity(qty, production.product_uom_id)
if production.product_tracking == 'serial':
qty = float_round(qty, precision_digits=0, rounding_method='UP') # Makes no sense to have partial quantities for serial number
if float_compare(qty, production.product_qty, precision_rounding=production.product_uom_id.rounding) < 0:
remaining_qty = production.product_qty - qty
productions = production.sudo()._split_productions({production: ([1] * int(qty)) + [remaining_qty]})[:-1]
else:
productions = production.sudo().with_context(allow_more=True)._split_productions({production: ([1] * int(qty))})
for production in productions:
production.qty_producing = 1
if not production.lot_producing_id:
production.action_generate_serial()
production.with_context(cancel_backorder=False).subcontracting_record_component()
else:
production.qty_producing = qty
if float_compare(production.qty_producing, production.product_qty, precision_rounding=production.product_uom_id.rounding) > 0:
self.env['change.production.qty'].with_context(skip_activity=True).create({
'mo_id': production.id,
'product_qty': qty
}).change_prod_qty()
if production.product_tracking == 'lot' and not production.lot_producing_id:
production.action_generate_serial()
production._set_qty_producing()
production.with_context(cancel_backorder=False).subcontracting_record_component()
def copy(self, default=None):
self.ensure_one()
if not self.is_subcontract or 'location_id' in default:
@@ -77,7 +136,10 @@ class StockMove(models.Model):
"""
self._check_access_if_subcontractor(values)
if 'product_uom_qty' in values and self.env.context.get('cancel_backorder') is not False:
self.filtered(lambda m: m.is_subcontract and m.state not in ['draft', 'cancel', 'done'])._update_subcontract_order_qty(values['product_uom_qty'])
self.filtered(
lambda m: m.is_subcontract and m.state not in ['draft', 'cancel', 'done']
and float_compare(m.product_uom_qty, values['product_uom_qty'], precision_rounding=m.product_uom.rounding) != 0
)._update_subcontract_order_qty(values['product_uom_qty'])
res = super().write(values)
if 'date' in values:
for move in self:
@@ -115,7 +177,7 @@ class StockMove(models.Model):
def action_show_subcontract_details(self):
""" Display moves raw for subcontracted product self. """
moves = self._get_subcontract_production().move_raw_ids
moves = self._get_subcontract_production().move_raw_ids.filtered(lambda m: m.state != 'cancel')
tree_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_move_tree_view')
form_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_move_form_view')
ctx = dict(self._context, search_default_by_product=True)
@@ -201,6 +263,9 @@ class StockMove(models.Model):
def _subcontrating_can_be_record(self):
return self._get_subcontract_production().filtered(lambda p: not p._has_been_recorded() and p.consumption != 'strict')
def _subcontracting_possible_record(self):
return self._get_subcontract_production().filtered(lambda p: p._has_tracked_component() or p.consumption != 'strict')
def _get_subcontract_production(self):
return self.filtered(lambda m: m.is_subcontract).move_orig_ids.production_id
@@ -227,21 +292,24 @@ class StockMove(models.Model):
def _update_subcontract_order_qty(self, new_quantity):
for move in self:
move_quantity = move.product_uom_qty
quantity_to_remove = move_quantity - new_quantity
if float_is_zero(quantity_to_remove, precision_rounding=move.product_uom.rounding):
continue
productions = move.move_orig_ids.production_id.filtered(lambda p: p.state not in ('done', 'cancel'))[::-1]
# Cancel productions until reach new_quantity
for production in productions:
if quantity_to_remove >= production.product_qty:
quantity_to_remove -= production.product_qty
production.with_context(skip_activity=True).action_cancel()
else:
self.env['change.production.qty'].with_context(skip_activity=True).create({
'mo_id': production.id,
'product_qty': production.product_uom_qty - quantity_to_remove
}).change_prod_qty()
quantity_to_remove = move.product_uom_qty - new_quantity
if not float_is_zero(quantity_to_remove, precision_rounding=move.product_uom.rounding):
move._reduce_subcontract_order_qty(quantity_to_remove)
def _reduce_subcontract_order_qty(self, quantity_to_remove):
self.ensure_one()
productions = self.move_orig_ids.production_id.filtered(lambda p: p.state not in ('done', 'cancel'))[::-1]
# Cancel productions until reach new_quantity
for production in productions:
if quantity_to_remove >= production.product_qty:
quantity_to_remove -= production.product_qty
production.with_context(skip_activity=True).action_cancel()
else:
self.env['change.production.qty'].with_context(skip_activity=True).create({
'mo_id': production.id,
'product_qty': production.product_uom_qty - quantity_to_remove
}).change_prod_qty()
break
def _check_access_if_subcontractor(self, vals):
if self.env.user.has_group('base.group_portal') and not self.env.su:
@@ -39,7 +39,9 @@ class StockPicking(models.Model):
# -------------------------------------------------------------------------
def _action_done(self):
res = super(StockPicking, self)._action_done()
for move in self.move_ids.filtered(lambda move: move.is_subcontract):
for move in self.move_ids:
if not move.is_subcontract:
continue
# Auto set qty_producing/lot_producing_id of MO wasn't recorded
# manually (if the flexible + record_component or has tracked component)
productions = move._get_subcontract_production()
@@ -76,6 +78,7 @@ class StockPicking(models.Model):
for picking in self:
productions_to_done = picking._get_subcontract_production()._subcontracting_filter_to_done()
productions_to_done._subcontract_sanity_check()
if not productions_to_done:
continue
productions_to_done = productions_to_done.sudo()
@@ -89,6 +92,7 @@ class StockPicking(models.Model):
production_moves = productions_to_done.move_raw_ids | productions_to_done.move_finished_ids
production_moves.write({'date': minimum_date - timedelta(seconds=1)})
production_moves.move_line_ids.write({'date': minimum_date - timedelta(seconds=1)})
return res
def action_record_components(self):
@@ -194,6 +194,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
move.quantity = 1
move.picked = True
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
# Nothing should be tracked
self.assertEqual(picking_receipt.display_action_record_components, 'hide')
@@ -461,6 +462,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
move.quantity = 5
move.picked = True
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
picking_delivery = self.env['stock.move'].search([
('product_id', 'in', (self.comp1 | self.comp2).ids)
@@ -511,6 +513,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
move.product_uom_qty = 1
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
self.assertEqual(picking_receipt.display_action_record_components, 'facultative')
action = picking_receipt.action_record_components()
mo = self.env['mrp.production'].browse(action['res_id'])
@@ -765,6 +768,35 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
self.assertEqual(subcontract.date_start, picking_receipt.scheduled_date)
self.assertEqual(subcontract.date_finished, picking_receipt.scheduled_date)
def test_subcontracting_set_quantity_done(self):
""" Tests to set a quantity done directly on a subcontracted move without using the subcontracting wizard.
Checks that it does the same as it would do with the wizard.
"""
self.bom.consumption = 'flexible'
quantities = [10, 15, 12, 14]
with Form(self.env['stock.picking']) as picking_form:
picking_form.picking_type_id = self.env.ref('stock.picking_type_in')
picking_form.partner_id = self.subcontractor_partner1
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.finished
move.product_uom_qty = quantities[0]
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
move = picking_receipt.move_ids_without_package
for qty in quantities[1:]:
move.quantity = qty
subcontracted = move._get_subcontract_production().filtered(lambda p: p.state != 'cancel')
self.assertEqual(sum(subcontracted.mapped('product_qty')), qty)
picking_receipt.button_validate()
self.assertEqual(move.product_uom_qty, quantities[0])
self.assertEqual(move.quantity, quantities[-1])
subcontracted = move._get_subcontract_production().filtered(lambda p: p.state == 'done')
self.assertEqual(sum(subcontracted.mapped('qty_produced')), quantities[-1])
@tagged('post_install', '-at_install')
class TestSubcontractingTracking(TransactionCase):
@@ -1262,6 +1294,9 @@ class TestSubcontractingPortal(TransactionCase):
ml.lot_id = serial1
mo = mo_form.save()
mo.subcontracting_record_component()
# Continue record of components with new MO (backorder was when recording first MO)
action = picking_receipt.with_user(self.portal_user).with_context({'is_subcontracting_portal': 1}).move_ids.action_show_details()
mo = self.env['mrp.production'].with_user(self.portal_user).browse(action['res_id'])
mo_form = Form(mo.with_context(action['context']), view=action['view_id'])
# Registering components for the second manufactured product with over-consumption, which leads to a warning
mo_form.qty_producing = 1