From 174c3b7951ebe66c6a71de09cd7432251bcc75a8 Mon Sep 17 00:00:00 2001 From: Arnold Moyaux Date: Wed, 1 Sep 2021 14:00:28 +0000 Subject: [PATCH] [IMP] mrp: Mass assignation of SN/Lot in MRP Allow to produce several serial numbers at once. Only when BoM do not contain any component tracked by unique serial number and lot ones are from 1 lot. Also allow to copy/paste serial numbers. This applies to manufacturing orders. Task: 2444742 Part-of: odoo/odoo#71732 --- addons/mrp/__manifest__.py | 1 + addons/mrp/models/mrp_production.py | 136 ++++++++++++++++++ addons/mrp/tests/__init__.py | 1 + addons/mrp/tests/test_smp.py | 82 +++++++++++ addons/mrp/views/mrp_production_views.xml | 2 + addons/mrp/wizard/__init__.py | 1 + .../mrp/wizard/stock_assign_serial_numbers.py | 68 +++++++++ .../wizard/stock_assign_serial_numbers.xml | 63 ++++++++ .../views/purchase_views.xml | 1 - .../purchase_stock/views/purchase_views.xml | 6 +- addons/stock/models/stock_move.py | 30 +--- addons/stock/models/stock_production_lot.py | 40 ++++++ .../wizard/stock_assign_serial_numbers.py | 4 +- .../wizard/stock_assign_serial_views.xml | 1 + .../stock_dropshipping/models/res_company.py | 1 + 15 files changed, 406 insertions(+), 31 deletions(-) create mode 100644 addons/mrp/tests/test_smp.py create mode 100644 addons/mrp/wizard/stock_assign_serial_numbers.py create mode 100644 addons/mrp/wizard/stock_assign_serial_numbers.xml diff --git a/addons/mrp/__manifest__.py b/addons/mrp/__manifest__.py index 72ee3b72676..a9ca45b02e0 100644 --- a/addons/mrp/__manifest__.py +++ b/addons/mrp/__manifest__.py @@ -23,6 +23,7 @@ 'wizard/mrp_production_backorder.xml', 'wizard/mrp_consumption_warning_views.xml', 'wizard/mrp_immediate_production_views.xml', + 'wizard/stock_assign_serial_numbers.xml', 'views/mrp_views_menus.xml', 'views/stock_move_views.xml', 'views/mrp_workorder_views.xml', diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 9af7d0d405d..c9e4ca35346 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -265,6 +265,7 @@ class MrpProduction(models.Model): ('late', 'Late')], compute='_compute_components_availability') show_lot_ids = fields.Boolean('Display the serial number shortcut on the moves', compute='_compute_show_lot_ids') forecasted_issue = fields.Boolean(compute='_compute_forecasted_issue') + show_serial_mass_produce = fields.Boolean('Display the serial mass product wizard action moves', compute='_compute_show_serial_mass_produce') @api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids') def _compute_mrp_production_child_count(self): @@ -521,6 +522,17 @@ class MrpProduction(models.Model): for order in self: order.show_lot_ids = order.state != 'draft' and any(m.product_id.tracking == 'serial' for m in order.move_raw_ids) + @api.depends('state', 'move_raw_ids') + def _compute_show_serial_mass_produce(self): + self.show_serial_mass_produce = False + for order in self: + if order.state == 'confirmed' and order.product_id.tracking == 'serial' and \ + float_compare(order.product_qty, 1, precision_rounding=order.product_uom_id.rounding) > 0 and \ + float_compare(order.qty_producing, order.product_qty, precision_rounding=order.product_uom_id.rounding) < 0: + have_serial_components, dummy, dummy, dummy = order._check_serial_mass_produce_components() + if not have_serial_components: + order.show_serial_mass_produce = True + _sql_constraints = [ ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per Company!'), ('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'), @@ -1708,6 +1720,34 @@ class MrpProduction(models.Model): 'target': 'new', } + def action_serial_mass_produce_wizard(self): + self.ensure_one() + self._check_company() + if self.state != 'confirmed': + return + if self.product_id.tracking != 'serial': + return + dummy, dummy, missing_components, multiple_lot_components = self._check_serial_mass_produce_components() + message = "" + if missing_components: + message += _("Make sure enough quantities of these components are reserved to carry on production:\n") + message += "\n".join(component.name for component in missing_components) + if multiple_lot_components: + if message: + message += "\n" + message += _("Component Lots must be unique for mass production. Please review consumption for:\n") + message += "\n".join(component.name for component in multiple_lot_components) + if message: + raise UserError(message) + next_serial = self.env['stock.production.lot'].get_next_serial(self.company_id, self.product_id) + action = self.env["ir.actions.actions"]._for_xml_id("mrp.act_assign_serial_numbers_production") + action['context'] = { + 'default_production_id': self.id, + 'default_expected_qty': self.product_qty, + 'default_next_serial_number': next_serial, + } + return action + @api.model def _prepare_procurement_group_vals(self, values): return {'name': values['name']} @@ -1809,3 +1849,99 @@ class MrpProduction(models.Model): ) and float_is_zero(production.qty_producing, precision_digits=pd): immediate_productions |= production return immediate_productions + + def _check_serial_mass_produce_components(self): + have_serial_components = False + have_lot_components = False + missing_components = set() + multiple_lot_components = set() + for production in self: + have_serial_components |= any(move.product_id.tracking == 'serial' for move in production.move_raw_ids) + have_lot_components |= any(move.product_id.tracking == 'lot' for move in production.move_raw_ids) + missing_components.update([move.product_id for move in production.move_raw_ids if float_compare(move.reserved_availability, move.product_uom_qty, precision_rounding=move.product_uom.rounding) < 0]) + components = {} + for move in production.move_raw_ids: + if move.product_id.tracking != 'lot': + continue + lot_ids = move.mapped('move_line_ids.lot_id.id') + if not lot_ids: + continue + components.setdefault(move.product_id, set()).update(lot_ids) + multiple_lot_components.update([p for p, l in components.items() if len(l) != 1]) + return (have_serial_components, have_lot_components, missing_components, multiple_lot_components) + + def _generate_backorder_productions_multi(self, serial_numbers, cancel_remaining_quantities=False): + self.ensure_one() + if self.product_id.tracking != 'serial': + raise UserError(_('Expected product tracked by Serial Number.')) + if self.backorder_sequence == 0: + self.backorder_sequence = 1 + result = [] + production = self + for serial_number in serial_numbers: + production.qty_producing = 1 + if production.product_qty > 1 and (serial_number != serial_numbers[-1] or not cancel_remaining_quantities): + backorder = production.copy(default=production._get_backorder_mo_vals()) + else: + backorder = None + new_moves_origin = [] + new_moves_quantity_to_split = [] + new_moves_vals = [] + for move in production.move_raw_ids | production.move_finished_ids: + if not move.additional: + uom_qty_to_split = move.product_uom_qty - move.unit_factor * production.qty_producing + qty_to_split = move.product_uom._compute_quantity(uom_qty_to_split, move.product_id.uom_id, rounding_method='HALF-UP') + uom_qty_to_split = float_round(uom_qty_to_split, precision_rounding=move.product_uom.rounding, rounding_method='HALF-UP') + move_vals = move._split(qty_to_split) + if backorder: + if not move_vals: + continue + if move.raw_material_production_id: + move_vals[0]['raw_material_production_id'] = backorder.id + else: + move_vals[0]['production_id'] = backorder.id + new_moves_origin.append(move) + new_moves_vals.append(move_vals[0]) + new_moves_quantity_to_split.append(uom_qty_to_split) + elif move.raw_material_production_id: + move.move_line_ids.product_uom_qty = 0 + move.move_line_ids[0].product_uom_qty = move.product_uom_qty + if backorder: + new_moves = self.env['stock.move'].create(new_moves_vals) + for move, quantity_to_split, new_move in zip(new_moves_origin, new_moves_quantity_to_split, new_moves): + if move.raw_material_production_id: + move.move_line_ids[0].copy({ + 'move_id': new_move.id, + 'product_uom_qty': quantity_to_split, + 'lot_id': move.move_line_ids[0].lot_id.id if move.move_line_ids[0].lot_id else 0, + }) + move.with_context(bypass_reservation_update=True).move_line_ids.product_uom_qty = 0 + move.with_context(bypass_reservation_update=True).move_line_ids[0].product_uom_qty = move.product_uom_qty + for old_wo, wo in zip(production.workorder_ids, backorder.workorder_ids): + wo.qty_produced = max(old_wo.qty_produced - old_wo.qty_producing, 0) + wo.qty_producing = 1 + ratio = production.qty_producing / production.product_qty + for workorder in production.workorder_ids: + workorder.duration_expected = workorder.duration_expected * ratio + for workorder in backorder.workorder_ids: + workorder.duration_expected = workorder.duration_expected * (1 - ratio) + backorder.action_confirm() + production.name = self._get_name_backorder(production.name, production.backorder_sequence) + production.product_qty = 1 + production.lot_producing_id = self.env['stock.production.lot'].create({ + 'product_id': production.product_id.id, + 'company_id': production.company_id.id, + 'name': serial_number, + }) + qty_producing_uom = production.product_uom_id._compute_quantity(production.qty_producing, production.product_id.uom_id, rounding_method='HALF-UP') + if qty_producing_uom != 1: + production.qty_producing = production.product_id.uom_id._compute_quantity(1, production.product_uom_id, rounding_method='HALF-UP') + for move in (production.move_raw_ids | production.move_finished_ids.filtered(lambda m: m.product_id != production.product_id)): + if not move.product_uom: + continue + new_qty = float_round((production.qty_producing - production.qty_produced) * move.unit_factor, precision_rounding=move.product_uom.rounding) + move.move_line_ids.filtered(lambda ml: ml.state not in ('done', 'cancel')).qty_done = 0 + move.move_line_ids = move._set_quantity_done_prepare_vals(new_qty) + result.append((production.lot_producing_id.id, production.id)) + production = backorder + return result diff --git a/addons/mrp/tests/__init__.py b/addons/mrp/tests/__init__.py index 73cf6398cf0..94136892072 100644 --- a/addons/mrp/tests/__init__.py +++ b/addons/mrp/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_oee from . import test_traceability from . import test_multicompany from . import test_backorder +from . import test_smp diff --git a/addons/mrp/tests/test_smp.py b/addons/mrp/tests/test_smp.py new file mode 100644 index 00000000000..e027d4e79fc --- /dev/null +++ b/addons/mrp/tests/test_smp.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.mrp.tests.common import TestMrpCommon +from odoo.tests import Form +from odoo.exceptions import UserError + +class TestMrpSerialMassProduce(TestMrpCommon): + + def test_smp_serial(self): + """Create a MO for a product not tracked by serial number. + The smp wizard should not open. + """ + mo = self.generate_mo()[0] + self.assertEqual(mo.state, 'confirmed') + res = mo.action_serial_mass_produce_wizard() + self.assertFalse(res) + + def test_smp_no_serial_component(self): + """Create a MO for a product tracked by serial number with a component also tracked by serial number. + An error should be throwed. + """ + mo = self.generate_mo(tracking_final='serial', tracking_base_2='serial')[0] + with self.assertRaises(UserError): + mo.action_serial_mass_produce_wizard() + + def test_smp_produce_all(self): + """Create a MO for a product tracked by serial number. + Open the smp wizard, generate all serial numbers to produce all quantitites. + """ + mo = self.generate_mo(tracking_final='serial')[0] + count = mo.product_qty + # Make some stock and reserve + for product in mo.move_raw_ids.product_id: + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': product.id, + 'inventory_quantity': 100, + 'location_id': mo.location_src_id.id, + })._apply_inventory() + mo.action_assign() + # Open the wizard + action = mo.action_serial_mass_produce_wizard() + wizard = Form(self.env['stock.assign.serial'].with_context(**action['context'])) + # Let the wizard generate all serial numbers + wizard.next_serial_number = "sn#1" + wizard.next_serial_count = count + action = wizard.save().generate_serial_numbers_production() + # Reload the wizard to apply generated serial numbers + wizard = Form(self.env['stock.assign.serial'].browse(action['res_id'])) + wizard.save().apply() + # Initial MO should have a backorder-sequenced name and be in to_close state + self.assertTrue("-001" in mo.name) + self.assertEqual(mo.state, "to_close") + # Each generated serial number should have its own mo + self.assertEqual(len(mo.procurement_group_id.mrp_production_ids), count) + + def test_smp_produce_all_but_one(self): + """Create a MO for a product tracked by serial number. + Open the smp wizard, generate all but one serial numbers and create a back order. + """ + mo = self.generate_mo(tracking_final='serial')[0] + count = mo.product_qty + # Make some stock and reserve + for product in mo.move_raw_ids.product_id: + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': product.id, + 'inventory_quantity': 100, + 'location_id': mo.location_src_id.id, + })._apply_inventory() + mo.action_assign() + action = mo.action_serial_mass_produce_wizard() + wizard = Form(self.env['stock.assign.serial'].with_context(**action['context'])) + wizard.next_serial_number = "sn#1" + wizard.next_serial_count = count - 1 + action = wizard.save().generate_serial_numbers_production() + # Reload the wizard to create backorder (applying generated serial numbers) + wizard = Form(self.env['stock.assign.serial'].browse(action['res_id'])) + wizard.save().create_backorder() + # Last MO in sequence is the backorder + bo = mo.procurement_group_id.mrp_production_ids[-1] + self.assertEqual(bo.backorder_sequence, count) + self.assertEqual(bo.state, "confirmed") diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index b4a5f9bbe51..255785fc39f 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -101,6 +101,8 @@ + + + + + + + + + + + + + + + +

+ You have entered less serial numbers than the quantity to produce.
+ Create a backorder if you expect to process the remaining quantities later.
+ Do not create a backorder if you will not process the remaining products. +

+
+
+
+ +
+ + + + Assign Serial Numbers + ir.actions.act_window + stock.assign.serial + + form + {} + new + + + diff --git a/addons/purchase_requisition_stock_dropshipping/views/purchase_views.xml b/addons/purchase_requisition_stock_dropshipping/views/purchase_views.xml index 349686032d1..5c9ced976de 100644 --- a/addons/purchase_requisition_stock_dropshipping/views/purchase_views.xml +++ b/addons/purchase_requisition_stock_dropshipping/views/purchase_views.xml @@ -7,7 +7,6 @@ - diff --git a/addons/purchase_stock/views/purchase_views.xml b/addons/purchase_stock/views/purchase_views.xml index 0c3fed88f26..be0d86a0121 100644 --- a/addons/purchase_stock/views/purchase_views.xml +++ b/addons/purchase_stock/views/purchase_views.xml @@ -54,11 +54,13 @@ {'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))], 'readonly': [('product_type', 'in', ('consu', 'product'))]} - - + + + + diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index c326c821fe6..bd1f59a85ba 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -6,8 +6,6 @@ from collections import defaultdict from datetime import timedelta from itertools import groupby from operator import itemgetter -from re import findall as regex_findall -from re import split as regex_split from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -636,6 +634,9 @@ class StockMove(models.Model): else: view = self.env.ref('stock.view_stock_move_nosuggest_operations') + if self.product_id.tracking == "serial" and self.state == "assigned": + self.next_serial = self.env['stock.production.lot'].get_next_serial(self.company_id, self.product_id) + return { 'name': _('Detailed Operations'), 'type': 'ir.actions.act_window', @@ -743,30 +744,7 @@ class StockMove(models.Model): `next_serial`) and create a move line for each generated `lot_name`. """ self.ensure_one() - - if not next_serial_count: - next_serial_count = self.next_serial_count - # We look if the serial number contains at least one digit. - caught_initial_number = regex_findall("\d+", self.next_serial) - if not caught_initial_number: - raise UserError(_('The serial number must contain at least one digit.')) - # We base the serie on the last number find in the base serial number. - initial_number = caught_initial_number[-1] - padding = len(initial_number) - # We split the serial number to get the prefix and suffix. - splitted = regex_split(initial_number, self.next_serial) - # initial_number could appear several times in the SN, e.g. BAV023B00001S00001 - prefix = initial_number.join(splitted[:-1]) - suffix = splitted[-1] - initial_number = int(initial_number) - - lot_names = [] - for i in range(0, next_serial_count): - lot_names.append('%s%s%s' % ( - prefix, - str(initial_number + i).zfill(padding), - suffix - )) + lot_names = self.env['stock.production.lot'].generate_lot_names(self.next_serial, next_serial_count or self.next_serial_count) move_lines_commands = self._generate_serial_move_line_commands(lot_names) self.write({'move_line_ids': move_lines_commands}) return True diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index 9947885aa1e..010986c5c83 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from re import findall as regex_findall +from re import split as regex_split + from odoo.tools.misc import attrgetter from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError @@ -31,6 +34,43 @@ class ProductionLot(models.Model): delivery_count = fields.Integer('Delivery order count', compute='_compute_delivery_ids') last_delivery_partner_id = fields.Many2one('res.partner', compute='_compute_delivery_ids') + @api.model + def generate_lot_names(self, first_lot, count): + """Generate `lot_names` from a string.""" + # We look if the first lot contains at least one digit. + caught_initial_number = regex_findall(r"\d+", first_lot) + if not caught_initial_number: + raise UserError(_('The lot name must contain at least one digit.')) + # We base the serie on the last number found in the base lot. + initial_number = caught_initial_number[-1] + padding = len(initial_number) + # We split the lot name to get the prefix and suffix. + splitted = regex_split(initial_number, first_lot) + # initial_number could appear several times, e.g. BAV023B00001S00001 + prefix = initial_number.join(splitted[:-1]) + suffix = splitted[-1] + initial_number = int(initial_number) + + lot_names = [] + for i in range(0, count): + lot_names.append('%s%s%s' % ( + prefix, + str(initial_number + i).zfill(padding), + suffix + )) + return lot_names + + @api.model + def get_next_serial(self, company, product): + """Return the next serial number to be attributed to the product.""" + if product.tracking == "serial": + last_serial = self.env['stock.production.lot'].search( + [('company_id', '=', company.id), ('product_id', '=', product.id)], + limit=1, order='id DESC') + if last_serial: + return self.env['stock.production.lot'].generate_lot_names(last_serial.name, 2)[1] + return False + @api.constrains('name', 'product_id', 'company_id') def _check_unique_lot(self): domain = [('product_id', 'in', self.product_id.ids), diff --git a/addons/stock/wizard/stock_assign_serial_numbers.py b/addons/stock/wizard/stock_assign_serial_numbers.py index 1429f445835..197d2844bd2 100644 --- a/addons/stock/wizard/stock_assign_serial_numbers.py +++ b/addons/stock/wizard/stock_assign_serial_numbers.py @@ -16,8 +16,8 @@ class StockAssignSerialNumbers(models.TransientModel): return len(filtered_move_lines) product_id = fields.Many2one('product.product', 'Product', - related='move_id.product_id', required=True) - move_id = fields.Many2one('stock.move', required=True) + related='move_id.product_id') + move_id = fields.Many2one('stock.move') next_serial_number = fields.Char('First SN', required=True) next_serial_count = fields.Integer('Number of SN', default=_default_next_serial_count, required=True) diff --git a/addons/stock/wizard/stock_assign_serial_views.xml b/addons/stock/wizard/stock_assign_serial_views.xml index 9e41a11d025..3a5d0fab49e 100644 --- a/addons/stock/wizard/stock_assign_serial_views.xml +++ b/addons/stock/wizard/stock_assign_serial_views.xml @@ -24,6 +24,7 @@ Assign Serial Numbers ir.actions.act_window stock.assign.serial + form {} new diff --git a/addons/stock_dropshipping/models/res_company.py b/addons/stock_dropshipping/models/res_company.py index 8b069115c67..5e6e3e092d6 100644 --- a/addons/stock_dropshipping/models/res_company.py +++ b/addons/stock_dropshipping/models/res_company.py @@ -53,6 +53,7 @@ class ResCompany(models.Model): 'default_location_src_id': self.env.ref('stock.stock_location_suppliers').id, 'default_location_dest_id': self.env.ref('stock.stock_location_customers').id, 'sequence_code': 'DS', + 'use_existing_lots': False, }) if dropship_vals: self.env['stock.picking.type'].create(dropship_vals)