diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index b4c314bbb6d..b748a8a8a76 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -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 = [] diff --git a/addons/mrp_subcontracting/models/mrp_production.py b/addons/mrp_subcontracting/models/mrp_production.py index 5cbbfdff550..9d27f16eb8f 100644 --- a/addons/mrp_subcontracting/models/mrp_production.py +++ b/addons/mrp_subcontracting/models/mrp_production.py @@ -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 diff --git a/addons/mrp_subcontracting/models/stock_move.py b/addons/mrp_subcontracting/models/stock_move.py index 9413649aba2..3c9829542f0 100644 --- a/addons/mrp_subcontracting/models/stock_move.py +++ b/addons/mrp_subcontracting/models/stock_move.py @@ -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: diff --git a/addons/mrp_subcontracting/models/stock_picking.py b/addons/mrp_subcontracting/models/stock_picking.py index 98525dd1ecf..266f2f57841 100644 --- a/addons/mrp_subcontracting/models/stock_picking.py +++ b/addons/mrp_subcontracting/models/stock_picking.py @@ -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): diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index cac82ca5db6..d54474e6467 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -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