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