[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
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,3 +13,4 @@ from . import test_oee
|
||||
from . import test_traceability
|
||||
from . import test_multicompany
|
||||
from . import test_backorder
|
||||
from . import test_smp
|
||||
|
||||
@@ -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")
|
||||
@@ -101,6 +101,8 @@
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,progress,done"/>
|
||||
<button name="action_toggle_is_locked" attrs="{'invisible': ['|', ('show_lock', '=', False), ('is_locked', '=', False)]}" string="Unlock" groups="mrp.group_mrp_manager" type="object" help="Unlock the manufacturing order to adjust what has been consumed or produced." data-hotkey="l"/>
|
||||
<button name="action_toggle_is_locked" attrs="{'invisible': ['|', ('show_lock', '=', False), ('is_locked', '=', True)]}" string="Lock" groups="mrp.group_mrp_manager" type="object" help="Lock the manufacturing order to prevent changes to what has been consumed or produced." data-hotkey="l"/>
|
||||
<field name="show_serial_mass_produce" invisible="1"/>
|
||||
<button name="action_serial_mass_produce_wizard" attrs="{'invisible': [('show_serial_mass_produce', '=', False)]}" string="Mass Produce" type="object"/>
|
||||
<button name="action_cancel" type="object" string="Cancel" data-hotkey="z"
|
||||
attrs="{'invisible': ['|', '|', ('id', '=', False), ('state', 'in', ('done', 'cancel')), ('confirm_cancel', '=', True)]}"/>
|
||||
<button name="action_cancel" type="object" string="Cancel" data-hotkey="z"
|
||||
|
||||
@@ -6,3 +6,4 @@ from . import stock_warn_insufficient_qty
|
||||
from . import mrp_production_backorder
|
||||
from . import mrp_consumption_warning
|
||||
from . import mrp_immediate_production
|
||||
from . import stock_assign_serial_numbers
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class StockAssignSerialNumbers(models.TransientModel):
|
||||
_inherit = 'stock.assign.serial'
|
||||
|
||||
production_id = fields.Many2one('mrp.production', 'Production')
|
||||
expected_qty = fields.Float('Expected Quantity', digits='Product Unit of Measure')
|
||||
serial_numbers = fields.Text('Produced Serial Numbers')
|
||||
produced_qty = fields.Float('Produced Quantity', digits='Product Unit of Measure')
|
||||
show_apply = fields.Boolean(help="Technical field to show the Apply button")
|
||||
show_backorders = fields.Boolean(help="Technical field to show the Create Backorder and No Backorder buttons")
|
||||
|
||||
def generate_serial_numbers_production(self):
|
||||
if self.next_serial_number and self.next_serial_count:
|
||||
generated_serial_numbers = "\n".join(self.env['stock.production.lot'].generate_lot_names(self.next_serial_number, self.next_serial_count))
|
||||
self.serial_numbers = "\n".join([self.serial_numbers, generated_serial_numbers]) if self.serial_numbers else generated_serial_numbers
|
||||
self._onchange_serial_numbers()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.act_assign_serial_numbers_production")
|
||||
action['res_id'] = self.id
|
||||
return action
|
||||
|
||||
def _get_serial_numbers(self):
|
||||
if self.serial_numbers:
|
||||
return list(filter(lambda serial_number: len(serial_number.strip()) > 0, self.serial_numbers.split('\n')))
|
||||
return []
|
||||
|
||||
@api.onchange('serial_numbers')
|
||||
def _onchange_serial_numbers(self):
|
||||
self.show_apply = False
|
||||
self.show_backorders = False
|
||||
serial_numbers = self._get_serial_numbers()
|
||||
duplicate_serial_numbers = [serial_number for serial_number, counter in Counter(serial_numbers).items() if counter > 1]
|
||||
if duplicate_serial_numbers:
|
||||
self.serial_numbers = ""
|
||||
self.produced_qty = 0
|
||||
raise UserError(_('Duplicate Serial Numbers (%s)') % ','.join(duplicate_serial_numbers))
|
||||
existing_serial_numbers = self.env['stock.production.lot'].search([
|
||||
('company_id', '=', self.production_id.company_id.id),
|
||||
('product_id', '=', self.production_id.product_id.id),
|
||||
('name', 'in', serial_numbers),
|
||||
])
|
||||
if existing_serial_numbers:
|
||||
self.serial_numbers = ""
|
||||
self.produced_qty = 0
|
||||
raise UserError(_('Existing Serial Numbers (%s)') % ','.join(existing_serial_numbers.mapped('display_name')))
|
||||
if len(serial_numbers) > self.expected_qty:
|
||||
self.serial_numbers = ""
|
||||
self.produced_qty = 0
|
||||
raise UserError(_('There are more Serial Numbers than the Quantity to Produce'))
|
||||
self.produced_qty = len(serial_numbers)
|
||||
self.show_apply = self.produced_qty == self.expected_qty
|
||||
self.show_backorders = self.produced_qty > 0 and self.produced_qty < self.expected_qty
|
||||
|
||||
def apply(self):
|
||||
self.production_id._generate_backorder_productions_multi(self._get_serial_numbers())
|
||||
|
||||
def create_backorder(self):
|
||||
self.production_id._generate_backorder_productions_multi(self._get_serial_numbers(), False)
|
||||
|
||||
def no_backorder(self):
|
||||
self.production_id._generate_backorder_productions_multi(self._get_serial_numbers(), True)
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_assign_serial_numbers_production" model="ir.ui.view">
|
||||
<field name="name">mrp_assign_serial_numbers</field>
|
||||
<field name="model">stock.assign.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Serial Mass Produce">
|
||||
<group>
|
||||
<field name="production_id" readonly="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="next_serial_number"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="next_serial_count"/>
|
||||
<div class="o_row">
|
||||
<span><field name="next_serial_count"/></span>
|
||||
<button name="generate_serial_numbers_production" type="object" class="btn btn-secondary" title="Generate Serial Numbers">
|
||||
<span>Generate</span>
|
||||
</button>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="serial_numbers" placeholder="copy paste a list and/or use Generate"/>
|
||||
</group>
|
||||
<field name="show_apply" invisible="1" />
|
||||
<field name="show_backorders" invisible="1" />
|
||||
<group>
|
||||
<group>
|
||||
<field name="produced_qty" readonly="True" force_save="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="expected_qty" readonly="True"/>
|
||||
</group>
|
||||
<p class="o_form_label oe_inline" attrs="{'invisible': [('show_backorders', '=', False)]}">
|
||||
You have entered less serial numbers than the quantity to produce.<br/>
|
||||
Create a backorder if you expect to process the remaining quantities later.<br/>
|
||||
Do not create a backorder if you will not process the remaining products.
|
||||
</p>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="apply" string="Apply" type="object" class="btn-primary" attrs="{'invisible': [('show_apply', '=', False)]}"/>
|
||||
<button name="create_backorder" string="Create Backorder" type="object" class="btn-primary" attrs="{'invisible': [('show_backorders', '=', False)]}"/>
|
||||
<button name="no_backorder" string="No Backorder" type="object" class="btn-primary" attrs="{'invisible': [('show_backorders', '=', False)]}"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="act_assign_serial_numbers_production" model="ir.actions.act_window">
|
||||
<field name="name">Assign Serial Numbers</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">stock.assign.serial</field>
|
||||
<field name="view_id" ref="view_assign_serial_numbers_production"/>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="context">{}</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -7,7 +7,6 @@
|
||||
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='origin']" position="after">
|
||||
<field name="picking_type_id"/>
|
||||
<field name="group_id" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='order_line']/tree//field[@name='price_subtotal']" position="after">
|
||||
|
||||
@@ -54,11 +54,13 @@
|
||||
<attribute name="attrs">{'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))], 'readonly': [('product_type', 'in', ('consu', 'product'))]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='purchase_delivery_invoice']/group/group" position="inside">
|
||||
<field name="picking_type_id" domain="[('code','=','incoming'), '|', ('warehouse_id', '=', False), ('warehouse_id.company_id', '=', company_id)]" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/>
|
||||
<field name="dest_address_id" groups="stock.group_stock_multi_locations" attrs="{'invisible': [('default_location_dest_id_usage', '!=', 'customer')], 'required': [('default_location_dest_id_usage', '=', 'customer')]}"/>
|
||||
<field name="default_location_dest_id_usage" invisible="1"/>
|
||||
<field name="incoterm_id"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='origin']" position="after">
|
||||
<field name="picking_type_id" domain="[('code','=','incoming'), '|', ('warehouse_id', '=', False), ('warehouse_id.company_id', '=', company_id)]" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/>
|
||||
<field name="dest_address_id" groups="stock.group_stock_multi_locations" attrs="{'invisible': [('default_location_dest_id_usage', '!=', 'customer')], 'required': [('default_location_dest_id_usage', '=', 'customer')]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<field name="name">Assign Serial Numbers</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">stock.assign.serial</field>
|
||||
<field name="view_id" ref="view_assign_serial_numbers"/>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="context">{}</field>
|
||||
<field name="target">new</field>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user