19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:47 +01:00
parent accf5918df
commit 6e65e8c877
688 changed files with 225434 additions and 199401 deletions

View file

@ -5,6 +5,8 @@ from . import change_production_qty
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
from . import product_replenish
from . import mrp_production_split
from . import stock_label_type
from . import mrp_production_serial_numbers
from . import stock_replenishment_info

View file

@ -14,13 +14,13 @@ class ChangeProductionQty(models.TransientModel):
required=True, ondelete='cascade')
product_qty = fields.Float(
'Quantity To Produce',
digits='Product Unit of Measure', required=True)
digits='Product Unit', required=True)
@api.model
def default_get(self, fields):
res = super(ChangeProductionQty, self).default_get(fields)
if 'mo_id' in fields and not res.get('mo_id') and self._context.get('active_model') == 'mrp.production' and self._context.get('active_id'):
res['mo_id'] = self._context['active_id']
if 'mo_id' in fields and not res.get('mo_id') and self.env.context.get('active_model') == 'mrp.production' and self.env.context.get('active_id'):
res['mo_id'] = self.env.context['active_id']
if 'product_qty' in fields and not res.get('product_qty') and res.get('mo_id'):
res['product_qty'] = self.env['mrp.production'].browse(res['mo_id']).product_qty
return res
@ -42,33 +42,22 @@ class ChangeProductionQty(models.TransientModel):
if self._need_quantity_propagation(move, qty):
push_moves |= move.copy({'product_uom_qty': qty})
else:
self._update_product_qty(move, qty)
move.write({'product_uom_qty': move.product_uom_qty + qty})
if push_moves:
push_moves._action_confirm()._action_assign()
push_moves._action_confirm()
production.move_finished_ids._action_assign()
return modification
@api.model
def _need_quantity_propagation(self, move, qty):
return move.move_dest_ids and not float_is_zero(qty, precision_rounding=move.product_uom.rounding)
@api.model
def _update_product_qty(self, move, qty):
move.write({'product_uom_qty': move.product_uom_qty + qty})
return move.move_dest_ids and not move.product_uom.is_zero(qty)
def change_prod_qty(self):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
precision = self.env['decimal.precision'].precision_get('Product Unit')
for wizard in self:
production = wizard.mo_id
produced = sum(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id).mapped('quantity_done'))
if wizard.product_qty < produced:
format_qty = '%.{precision}f'.format(precision=precision)
raise UserError(_(
"You have already processed %(quantity)s. Please input a quantity higher than %(minimum)s ",
quantity=format_qty % produced,
minimum=format_qty % produced
))
old_production_qty = production.product_qty
new_production_qty = wizard.product_qty
@ -87,8 +76,7 @@ class ChangeProductionQty(models.TransientModel):
production._log_manufacture_exception(documents)
self._update_finished_moves(production, new_production_qty, old_production_qty)
production.write({'product_qty': new_production_qty})
if not float_is_zero(production.qty_producing, precision_rounding=production.product_uom_id.rounding) and\
not production.workorder_ids:
if not production.product_uom_id.is_zero(production.qty_producing) and not production.workorder_ids:
production.qty_producing = new_production_qty
production._set_qty_producing()

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Change Product Quantity -->
<record id="view_change_production_qty_wizard" model="ir.ui.view">
<field name="name">Change Quantity To Produce</field>
@ -9,13 +9,19 @@
<field name="arch" type="xml">
<form string="Change Product Qty">
<group>
<field name="product_qty"/>
<field name="mo_id" invisible="1"/>
<group>
<label for="product_qty"/>
<div>
<field name="product_qty" class="oe_inline"/>
</div>
</group>
</group>
<span class="text-muted">Modifying the quantity to produce will also modify the quantities of components to consume for this manufacturing order.</span>
<field name="mo_id" invisible="1"/>
<footer>
<button name="change_prod_qty" string="Approve" data-hotkey="q"
<button name="change_prod_qty" string="Set Quantity" data-hotkey="q"
colspan="1" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x" />
</footer>
</form>
</field>
@ -23,11 +29,10 @@
<record id="action_change_production_qty" model="ir.actions.act_window">
<field name="name">Change Quantity To Produce</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">change.production.qty</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>
</odoo>

View file

@ -3,7 +3,6 @@
from odoo import _, fields, models, api
from odoo.exceptions import UserError
from odoo.tools import float_compare, float_is_zero
class MrpConsumptionWarning(models.TransientModel):
@ -46,33 +45,31 @@ class MrpConsumptionWarning(models.TransientModel):
if line.product_id != move.product_id:
continue
qty_expected = line.product_uom_id._compute_quantity(line.product_expected_qty_uom, move.product_uom)
qty_compare_result = float_compare(qty_expected, move.quantity_done, precision_rounding=move.product_uom.rounding)
qty_compare_result = move.product_uom.compare(qty_expected, move.quantity)
if qty_compare_result != 0:
if (move.has_tracking in ('lot', 'serial')
and not production.use_auto_consume_components_lots
and qty_compare_result > 0):
problem_tracked_products |= line.product_id
break
move.quantity_done = qty_expected
move.quantity = qty_expected
# move should be set to picked to correctly consume the product
move.picked = True
# in case multiple lines with same product => set others to 0 since we have no way to know how to distribute the qty done
line.product_expected_qty_uom = 0
# move was deleted before confirming MO or force deleted somehow
if not float_is_zero(line.product_expected_qty_uom, precision_rounding=line.product_uom_id.rounding):
if line.product_id.tracking in ('lot', 'serial') and not line.mrp_production_id.use_auto_consume_components_lots:
problem_tracked_products |= line.product_id
continue
if not line.product_uom_id.is_zero(line.product_expected_qty_uom):
missing_move_vals.append({
'product_id': line.product_id.id,
'product_uom': line.product_uom_id.id,
'product_uom_qty': line.product_expected_qty_uom,
'quantity_done': line.product_expected_qty_uom,
'quantity': line.product_expected_qty_uom,
'raw_material_production_id': line.mrp_production_id.id,
'additional': True,
'picked': True,
})
if problem_tracked_products:
products_list = "".join(f"\n- {product_name}" for product_name in problem_tracked_products.mapped("name"))
raise UserError(
_("Values cannot be set and validated because a Lot/Serial Number needs to be specified for a tracked product that is having its consumed amount increased:\n- ") +
"\n- ".join(problem_tracked_products.mapped('name'))
_(
"Values cannot be set and validated because a Lot/Serial Number needs to be specified for a tracked product that is having its consumed amount increased:%(products)s",
products=products_list,
),
)
if missing_move_vals:
self.env['stock.move'].create(missing_move_vals)
@ -88,6 +85,7 @@ class MrpConsumptionWarning(models.TransientModel):
'target': 'main',
}
class MrpConsumptionWarningLine(models.TransientModel):
_name = 'mrp.consumption.warning.line'
_description = "Line of issue consumption"
@ -97,6 +95,6 @@ class MrpConsumptionWarningLine(models.TransientModel):
consumption = fields.Selection(related="mrp_production_id.consumption")
product_id = fields.Many2one('product.product', "Product", readonly=True, required=True)
product_uom_id = fields.Many2one('uom.uom', "Unit of Measure", related="product_id.uom_id", readonly=True)
product_uom_id = fields.Many2one('uom.uom', "Unit", related="product_id.uom_id", readonly=True)
product_consumed_qty_uom = fields.Float("Consumed", readonly=True)
product_expected_qty_uom = fields.Float("To Consume", readonly=True)

View file

@ -13,33 +13,33 @@
<field name="mrp_production_count" invisible="1"/>
<div class="m-2">
You consumed a different quantity than expected for the following products.
<b attrs="{'invisible': [('consumption', '=', 'strict')]}">
<b invisible="consumption == 'strict'">
Please confirm it has been done on purpose.
</b>
<b attrs="{'invisible': [('consumption', '!=', 'strict')]}">
Please review your component consumption or ask a manager to validate
<span attrs="{'invisible':[('mrp_production_count', '!=', 1)]}">this manufacturing order</span>
<span attrs="{'invisible':[('mrp_production_count', '=', 1)]}">these manufacturing orders</span>.
<b invisible="consumption != 'strict'">
Please review your component consumption or ask a manager to validate
<span invisible="mrp_production_count != 1">this manufacturing order</span>
<span invisible="mrp_production_count == 1">these manufacturing orders</span>.
</b>
</div>
<field name="mrp_consumption_warning_line_ids" nolabel="1">
<tree create="0" delete="0" editable="top">
<field name="mrp_production_id" attrs="{'column_invisible':[('parent.mrp_production_count', '=', 1)]}" force_save="1"/>
<field name="consumption" invisible="1" force_save="1"/>
<list create="0" delete="0" editable="top">
<field name="mrp_production_id" column_invisible="parent.mrp_production_count == 1" force_save="1"/>
<field name="consumption" column_invisible="True" force_save="1"/>
<field name="product_id" force_save="1"/>
<field name="product_uom_id" groups="uom.group_uom" force_save="1"/>
<field name="product_uom_id" groups="uom.group_uom" force_save="1" widget="many2one_uom"/>
<field name="product_expected_qty_uom" force_save="1"/>
<field name="product_consumed_qty_uom" force_save="1"/>
</tree>
</list>
</field>
<footer>
<button name="action_confirm" string="Force" data-hotkey="q"
groups="mrp.group_mrp_manager" attrs="{'invisible': [('consumption', '!=', 'strict')]}"
groups="mrp.group_mrp_manager" invisible="consumption != 'strict'"
colspan="1" type="object" class="btn-primary"/>
<button name="action_confirm" string="Confirm" attrs="{'invisible': [('consumption', '=', 'strict')]}" data-hotkey="q"
colspan="1" type="object" class="btn-primary"/>
<button name="action_set_qty" string="Set Quantities &amp; Validate" colspan="1" type="object" class="btn-secondary"/>
<button name="action_cancel" string="Discard" data-hotkey="w"
<button name="action_confirm" string="Confirm" invisible="consumption == 'strict'" data-hotkey="q"
colspan="1" type="object" class="btn-primary" barcode_trigger="NEXT"/>
<button name="action_set_qty" string="Set Quantities &amp; Validate" colspan="1" type="object" class="btn-secondary" invisible="'is_subcontracting_portal' in context"/>
<button name="action_cancel" string="Discard" data-hotkey="x"
colspan="1" type="object" class="btn-secondary"/>
</footer>
</form>
@ -48,11 +48,10 @@
<record id="action_mrp_consumption_warning" model="ir.actions.act_window">
<field name="name">Consumption Warning</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.consumption.warning</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>
</odoo>

View file

@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_compare, float_is_zero
class MrpImmediateProductionLine(models.TransientModel):
_name = 'mrp.immediate.production.line'
_description = 'Immediate Production Line'
immediate_production_id = fields.Many2one('mrp.immediate.production', 'Immediate Production', required=True)
production_id = fields.Many2one('mrp.production', 'Production', required=True)
to_immediate = fields.Boolean('To Process')
class MrpImmediateProduction(models.TransientModel):
_name = 'mrp.immediate.production'
_description = 'Immediate Production'
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if 'immediate_production_line_ids' in fields:
if self.env.context.get('default_mo_ids'):
res['mo_ids'] = self.env.context['default_mo_ids']
res['immediate_production_line_ids'] = [(0, 0, {'to_immediate': True, 'production_id': mo_id[1]}) for mo_id in res['mo_ids']]
return res
mo_ids = fields.Many2many('mrp.production', 'mrp_production_production_rel')
show_productions = fields.Boolean(compute='_compute_show_production')
immediate_production_line_ids = fields.One2many(
'mrp.immediate.production.line',
'immediate_production_id',
string="Immediate Production Lines")
@api.depends('immediate_production_line_ids')
def _compute_show_production(self):
for wizard in self:
wizard.show_productions = len(wizard.immediate_production_line_ids.production_id) > 1
def process(self):
productions_to_do = self.env['mrp.production']
productions_not_to_do = self.env['mrp.production']
for line in self.immediate_production_line_ids:
if line.to_immediate is True:
productions_to_do |= line.production_id
else:
productions_not_to_do |= line.production_id
for production in productions_to_do:
error_msg = ""
if production.product_tracking in ('lot', 'serial') and not production.lot_producing_id:
production.action_generate_serial()
if production.product_tracking == 'serial' and float_compare(production.qty_producing, 1, precision_rounding=production.product_uom_id.rounding) == 1:
production.qty_producing = 1
else:
production.qty_producing = production.product_qty - production.qty_produced
production._set_qty_producing()
for move in production.move_raw_ids:
if move.state in ('done', 'cancel') or not move.product_uom_qty:
continue
rounding = move.product_uom.rounding
if move.has_tracking in ('serial', 'lot') and float_is_zero(move.quantity_done, precision_rounding=rounding):
error_msg += "\n - %s" % move.product_id.display_name
if error_msg:
error_msg = _('You need to supply Lot/Serial Number for products:') + error_msg
raise UserError(error_msg)
productions_to_validate = self.env.context.get('button_mark_done_production_ids')
if productions_to_validate:
productions_to_validate = self.env['mrp.production'].browse(productions_to_validate)
productions_to_validate = productions_to_validate - productions_not_to_do
consumption_issues = productions_to_validate._get_consumption_issues()
if consumption_issues:
return productions_to_validate._action_generate_consumption_wizard(consumption_issues)
return productions_to_validate.with_context(skip_immediate=True).button_mark_done()
return True

View file

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_immediate_production" model="ir.ui.view">
<field name="name">mrp.immediate.production.view.form</field>
<field name="model">mrp.immediate.production</field>
<field name="arch" type="xml">
<form string="Immediate production?">
<group>
<p colspan="2">You have not recorded <i>produced</i> quantities yet, by clicking on <i>apply</i> Odoo will produce all the finished products and consume all components.</p>
</group>
<field name="show_productions" invisible="1"/>
<field name="immediate_production_line_ids" nolabel="1" attrs="{'invisible': [('show_productions', '=', False)]}">
<tree create="0" delete="0" editable="top">
<field name="production_id"/>
<field name="to_immediate" widget="boolean_toggle"/>
</tree>
</field>
<footer>
<button name="process" string="Apply" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -31,10 +31,13 @@ class MrpProductionBackorder(models.TransientModel):
wizard.show_backorder_lines = len(wizard.mrp_production_backorder_line_ids) > 1
def action_close_mo(self):
return self.mrp_production_ids.with_context(skip_backorder=True).button_mark_done()
ctx = dict(self.env.context)
always_backorder_mo_ids = ctx.pop('always_backorder_mo_ids', [])
return self.mrp_production_ids.with_context(ctx, skip_backorder=True, mo_ids_to_backorder=always_backorder_mo_ids).button_mark_done()
def action_backorder(self):
ctx = dict(self.env.context)
ctx.pop('default_mrp_production_ids', None)
mo_ids_to_backorder = self.mrp_production_backorder_line_ids.filtered(lambda l: l.to_backorder).mrp_production_id.ids
always_backorder_mo_ids = ctx.pop('always_backorder_mo_ids', [])
mo_ids_to_backorder = self.mrp_production_backorder_line_ids.filtered(lambda l: l.to_backorder).mrp_production_id.ids + always_backorder_mo_ids
return self.mrp_production_ids.with_context(ctx, skip_backorder=True, mo_ids_to_backorder=mo_ids_to_backorder).button_mark_done()

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- MO Backorder -->
<record id="view_mrp_production_backorder_form" model="ir.ui.view">
<field name="name">Create Backorder</field>
@ -14,31 +14,30 @@
</p>
</group>
<field name="show_backorder_lines" invisible="1"/>
<field name="mrp_production_backorder_line_ids" nolabel="1" attrs="{'invisible': [('show_backorder_lines', '=', False)]}">
<tree create="0" delete="0" editable="top">
<field name="mrp_production_backorder_line_ids" nolabel="1" invisible="not show_backorder_lines">
<list create="0" delete="0" editable="top">
<field name="mrp_production_id" force_save="1"/>
<field name="to_backorder" widget="boolean_toggle"/>
</tree>
</list>
</field>
<footer>
<button name="action_backorder" string="Create backorder" data-hotkey="q"
colspan="1" type="object" class="btn-primary" attrs="{'invisible': [('show_backorder_lines', '!=', False)]}"/>
colspan="1" type="object" class="btn-primary" invisible="show_backorder_lines"/>
<button name="action_backorder" string="Validate" data-hotkey="q"
colspan="1" type="object" class="btn-primary" attrs="{'invisible': [('show_backorder_lines', '=', False)]}"/>
<button name="action_close_mo" type="object" string="No Backorder" attrs="{'invisible': [('show_backorder_lines', '!=', False)]}" data-hotkey="x"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" />
colspan="1" type="object" class="btn-primary" invisible="not show_backorder_lines"/>
<button name="action_close_mo" type="object" string="No Backorder" invisible="show_backorder_lines" data-hotkey="w"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x" />
</footer>
</form>
</field>
</record>
<record id="action_mrp_production_backorder" model="ir.actions.act_window">
<field name="name">You produced less than initial demand</field>
<field name="type">ir.actions.act_window</field>
<field name="name">You produced less than the initial demand</field>
<field name="res_model">mrp.production.backorder</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>
</odoo>

View file

@ -0,0 +1,88 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.exceptions import UserError
class MrpProductionSerials(models.TransientModel):
_name = 'mrp.production.serials'
_description = 'Assign serial numbers to production order'
production_id = fields.Many2one('mrp.production', 'Production')
workorder_id = fields.Many2one('mrp.workorder', 'Workorder')
lot_name = fields.Char('First SN', compute="_compute_lot_name", store=True, readonly=False)
lot_quantity = fields.Integer('Number of SN', compute="_compute_lot_quantity", store=True, readonly=False)
serial_numbers = fields.Text('Produced Serial Numbers', compute="_compute_lot_name", store=True, readonly=False)
@api.depends('production_id')
def _compute_lot_name(self):
for wizard in self:
wizard.serial_numbers = '\n'.join(wizard.production_id.lot_producing_ids.mapped('name'))
if wizard.lot_name:
continue
wizard.lot_name = wizard.production_id.lot_producing_ids[:1].name
if not wizard.lot_name:
sequence = wizard.production_id.product_id.lot_sequence_id
wizard.lot_name = sequence.get_next_char(sequence.number_next_actual) if sequence \
else wizard.production_id.product_id.serial_prefix_format + wizard.production_id.product_id.next_serial
@api.depends('production_id')
def _compute_lot_quantity(self):
for wizard in self:
wizard.lot_quantity = wizard.production_id.product_qty
@api.onchange('serial_numbers')
def _onchange_serial_numbers(self):
lot_names = list(filter(lambda s: len(s.strip()) > 0, self.serial_numbers.split('\n'))) if self.serial_numbers else []
self.serial_numbers = '\n'.join(list(dict.fromkeys(lot_names))) # remove duplicate lot names
def action_generate_serial_numbers(self):
self.ensure_one()
if self.lot_name and self.lot_quantity:
lots = self.env['stock.lot'].generate_lot_names(self.lot_name, self.lot_quantity)
self.serial_numbers = '\n'.join([lot['lot_name'] for lot in lots])
self._onchange_serial_numbers()
action = self.env["ir.actions.actions"]._for_xml_id("mrp.action_assign_serial_numbers")
action['res_id'] = self.id
return action
def action_apply(self):
self.ensure_one()
if not self.serial_numbers:
raise UserError(self.env._("There is no serial numbers to apply."))
lots = list(filter(lambda serial_number: len(serial_number.strip()) > 0, self.serial_numbers.split('\n'))) if self.serial_numbers else []
existing_lots = self.env['stock.lot'].search([
'|', ('company_id', '=', False), ('company_id', '=', self.production_id.company_id.id),
('product_id', '=', self.production_id.product_id.id),
('name', 'in', lots),
])
existing_lot_names = existing_lots.mapped('name')
new_lots = []
sequence = self.production_id.product_id.lot_sequence_id
for lot_name in sorted(lots):
if lot_name in existing_lot_names:
continue
if sequence and lot_name == sequence.get_next_char(sequence.number_next_actual):
sequence.sudo().number_next_actual += 1
new_lots.append({
'name': lot_name,
'product_id': self.production_id.product_id.id
})
self.production_id.lot_producing_ids = existing_lots + self.env['stock.lot'].create(new_lots)
if self.production_id.qty_producing != len(self.production_id.lot_producing_ids):
self.production_id.qty_producing = len(self.production_id.lot_producing_ids)
(self.workorder_id or self.production_id).set_qty_producing()
print_actions = self.production_id._autoprint_mass_generated_lots()
if print_actions:
return {
'type': 'ir.actions.client',
'tag': 'do_multi_print',
'context': {},
'params': {
'reports': print_actions,
}
}

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_mrp_production_serials_form" model="ir.ui.view">
<field name="name">mrp_production_serials</field>
<field name="model">mrp.production.serials</field>
<field name="arch" type="xml">
<form string="Serial Numbers">
<field name="production_id" invisible="True"/>
<group>
<group>
<field name="lot_name"/>
</group>
<group>
<group>
<field name="lot_quantity" style="max-width: 75%;"/>
</group>
<group>
<button name="action_generate_serial_numbers"
type="object" string="Generate"
class="btn btn-secondary" title="Generate Serial Numbers"/>
</group>
</group>
</group>
<field name="serial_numbers" nolabel="1" placeholder="copy paste a list and/or use Generate"/>
<footer>
<button name="action_apply" string="Apply" type="object" class="btn-primary"/>
<button string="Discard" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_assign_serial_numbers" model="ir.actions.act_window">
<field name="name">Assign Serial Numbers</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.production.serials</field>
<field name="view_id" ref="view_mrp_production_serials_form"/>
<field name="view_mode">form</field>
<field name="context">{}</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, Command
from odoo.tools import float_round, float_compare
from odoo.tools import float_round
class MrpProductionSplitMulti(models.TransientModel):
@ -22,40 +22,45 @@ class MrpProductionSplit(models.TransientModel):
product_qty = fields.Float(related='production_id.product_qty')
product_uom_id = fields.Many2one(related='production_id.product_uom_id')
production_capacity = fields.Float(related='production_id.production_capacity')
counter = fields.Integer(
"Split #", default=0, compute="_compute_counter",
store=True, readonly=False)
production_detailed_vals_ids = fields.One2many(
'mrp.production.split.line', 'mrp_production_split_id',
'Split Details', compute="_compute_details", store=True, readonly=False)
valid_details = fields.Boolean("Valid", compute="_compute_valid_details")
max_batch_size = fields.Float("Max Batch Size", compute="_compute_max_batch_size", digits='Product Unit', readonly=False)
num_splits = fields.Integer("# Splits", compute="_compute_num_splits", readonly=True)
@api.depends('production_detailed_vals_ids')
def _compute_counter(self):
@api.depends('production_id')
def _compute_max_batch_size(self):
for wizard in self:
wizard.counter = len(wizard.production_detailed_vals_ids)
bom_id = wizard.production_id.bom_id
wizard.max_batch_size = bom_id.batch_size if bom_id.enable_batch_size else wizard.product_qty
@api.depends('counter')
@api.depends('max_batch_size')
def _compute_num_splits(self):
self.num_splits = 0
for wizard in self:
if wizard.product_uom_id.compare(wizard.max_batch_size, 0) > 0:
wizard.num_splits = float_round(
wizard.product_qty / wizard.max_batch_size,
precision_digits=0,
rounding_method='UP')
@api.depends('num_splits')
def _compute_details(self):
for wizard in self:
commands = [Command.clear()]
if wizard.counter < 1 or not wizard.production_id:
if wizard.num_splits <= 0 or not wizard.production_id:
wizard.production_detailed_vals_ids = commands
continue
quantity = float_round(wizard.product_qty / wizard.counter, precision_rounding=wizard.product_uom_id.rounding)
remaining_quantity = wizard.product_qty
for _ in range(wizard.counter - 1):
remaining_qty = wizard.product_qty
for _ in range(wizard.num_splits):
qty = min(wizard.max_batch_size, remaining_qty)
commands.append(Command.create({
'quantity': quantity,
'user_id': wizard.production_id.user_id,
'date': wizard.production_id.date_planned_start,
'quantity': qty,
'user_id': wizard.production_id.user_id.id,
'date': wizard.production_id.date_start,
}))
remaining_quantity = float_round(remaining_quantity - quantity, precision_rounding=wizard.product_uom_id.rounding)
commands.append(Command.create({
'quantity': remaining_quantity,
'user_id': wizard.production_id.user_id,
'date': wizard.production_id.date_planned_start,
}))
remaining_qty = wizard.product_uom_id.round(remaining_qty - qty)
wizard.production_detailed_vals_ids = commands
@api.depends('production_detailed_vals_ids')
@ -63,13 +68,13 @@ class MrpProductionSplit(models.TransientModel):
self.valid_details = False
for wizard in self:
if wizard.production_detailed_vals_ids:
wizard.valid_details = float_compare(wizard.product_qty, sum(wizard.production_detailed_vals_ids.mapped('quantity')), precision_rounding=wizard.product_uom_id.rounding) == 0
wizard.valid_details = wizard.product_uom_id.compare(wizard.product_qty, sum(wizard.production_detailed_vals_ids.mapped('quantity'))) == 0
def action_split(self):
productions = self.production_id._split_productions({self.production_id: [detail.quantity for detail in self.production_detailed_vals_ids]})
for production, detail in zip(productions, self.production_detailed_vals_ids):
production.user_id = detail.user_id
production.date_planned_start = detail.date
production.date_start = detail.date
if self.production_split_multi_id:
saved_production_split_multi_id = self.production_split_multi_id.id
self.production_split_multi_id.production_ids = [Command.unlink(self.id)]
@ -84,7 +89,7 @@ class MrpProductionSplit(models.TransientModel):
def action_return_to_list(self):
self.production_detailed_vals_ids = [Command.clear()]
self.counter = 0
self.max_batch_size = 0
action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi')
action['res_id'] = self.production_split_multi_id.id
return action
@ -96,8 +101,8 @@ class MrpProductionSplitLine(models.TransientModel):
mrp_production_split_id = fields.Many2one(
'mrp.production.split', 'Split Production', required=True, ondelete="cascade")
quantity = fields.Float('Quantity To Produce', digits='Product Unit of Measure', required=True)
quantity = fields.Float('Quantity To Produce', digits='Product Unit', required=True)
user_id = fields.Many2one(
'res.users', 'Responsible',
domain=lambda self: [('groups_id', 'in', self.env.ref('mrp.group_mrp_user').id)])
domain=lambda self: [('all_group_ids', 'in', self.env.ref('mrp.group_mrp_user').id)])
date = fields.Datetime('Schedule Date')

View file

@ -5,21 +5,20 @@
<record id="view_mrp_production_split_multi_form" model="ir.ui.view">
<field name="name">mrp.production.split.multi.form</field>
<field name="model">mrp.production.split.multi</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form string="Split Productions">
<field name="production_ids">
<tree create="0" editable="top">
<list create="0" editable="top">
<field name="production_id"/>
<field name="product_id"/>
<field name="product_qty"/>
<field name="production_capacity"/>
<field name="product_uom_id" groups="uom.group_uom"/>
<button name="action_prepare_split" type="object" icon="fa-scissors" width="0.1" title="Split Production"/>
</tree>
<field name="product_uom_id" groups="uom.group_uom" widget="many2one_uom"/>
<button name="action_prepare_split" type="object" icon="fa-scissors" title="Split Production"/>
</list>
</field>
<footer>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
@ -39,33 +38,34 @@
<label for="product_qty"/>
<div class="o_row">
<span><field name="product_qty"/></span>
<span><field name="product_uom_id" groups="uom.group_uom"/></span>
<span><field name="product_uom_id" groups="uom.group_uom" widget="many2one_uom"/></span>
</div>
</group>
<group>
<field name="counter"/>
</group>
<group>
<label for="production_capacity"/>
<div class="o_row">
<span><field name="production_capacity"/></span>
<span><field name="product_uom_id" groups="uom.group_uom"/></span>
<label for="max_batch_size"/>
<div class="d-flex">
<field name="max_batch_size" class="w-50"/>
<field name="product_uom_id" groups="uom.group_uom"/>
</div>
<field name="num_splits"/>
</group>
</group>
<field name="production_detailed_vals_ids" attrs="{'invisible': [('counter', '=', 0)]}">
<tree editable="top">
<field name="production_detailed_vals_ids" invisible="max_batch_size &lt;= 0">
<list editable="top">
<field name="date"/>
<field name="user_id"/>
<field name="quantity"/>
</tree>
</list>
</field>
<field name="production_split_multi_id" invisible="1"/>
<field name="valid_details" invisible="1"/>
<div class="alert alert-danger" role="alert" invisible="valid_details and max_batch_size &gt; 0">
The total should be equal to the quantity to produce.
</div>
<footer>
<button string="Split" class="btn-primary" type="object" name="action_split" data-hotkey="q" attrs="{'invisible': [('valid_details', '=', False)]}"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" attrs="{'invisible': [('production_split_multi_id', '!=', False)]}"/>
<button string="Discard" class="btn-secondary" type="object" name="action_return_to_list" data-hotkey="z" attrs="{'invisible': [('production_split_multi_id', '=', False)]}"/>
<button string="Split" class="btn-primary" type="object" name="action_split" data-hotkey="q" invisible="not valid_details"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x" invisible="production_split_multi_id"/>
<button string="Discard" class="btn-secondary" type="object" name="action_return_to_list" data-hotkey="x" invisible="not production_split_multi_id"/>
</footer>
</form>
</field>
@ -73,7 +73,6 @@
<record id="action_mrp_production_split_multi" model="ir.actions.act_window">
<field name="name">Split productions</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.production.split.multi</field>
<field name="view_mode">form</field>
<field name="target">new</field>
@ -81,11 +80,10 @@
<record id="action_mrp_production_split" model="ir.actions.act_window">
<field name="name">Split production</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.production.split</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>
</odoo>

View file

@ -13,29 +13,27 @@
<field name="company_id" invisible="1"/>
</group>
<footer>
<button name="button_block" string="Block" type="object" class="btn-danger text-uppercase" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
<button name="button_block" string="Block" type="object" class="btn-danger" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x" />
</footer>
</form>
</field>
</record>
<record id="act_mrp_block_workcenter" model="ir.actions.act_window">
<field name="name">Block Workcenter</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.workcenter.productivity</field>
<field name="view_mode">form</field>
<field name="context">{'default_workcenter_id': active_id}</field>
<field name="view_id" eval="mrp_workcenter_block_wizard_form"/>
<field name="view_id" ref="mrp_workcenter_block_wizard_form"/>
<field name="target">new</field>
</record>
<record id="act_mrp_block_workcenter_wo" model="ir.actions.act_window">
<field name="name">Block Workcenter</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.workcenter.productivity</field>
<field name="view_mode">form</field>
<field name="view_id" eval="mrp_workcenter_block_wizard_form"/>
<field name="view_id" ref="mrp_workcenter_block_wizard_form"/>
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.fields import Domain
class ProductReplenish(models.TransientModel):
_inherit = 'product.replenish'
@api.depends('product_id.bom_ids', 'product_id.bom_ids.product_uom_id')
def _compute_allowed_uom_ids(self):
super()._compute_allowed_uom_ids()
for rec in self:
rec.allowed_uom_ids |= rec.product_id.bom_ids.product_uom_id
@api.depends('route_id')
def _compute_date_planned(self):
super()._compute_date_planned()
for rec in self:
if 'manufacture' in rec.route_id.rule_ids.mapped('action'):
rec.date_planned = rec._get_date_planned(rec.route_id, product_tmpl_id=rec.product_tmpl_id)
def _get_record_to_notify(self, date):
order_line = self.env['mrp.production'].search([('write_date', '>=', date)], limit=1)
return order_line or super()._get_record_to_notify(date)
def _get_replenishment_order_notification_link(self, production):
if production._name == 'mrp.production':
return [{
'label': production.name,
'url': f'/odoo/action-mrp.action_mrp_production_form/{production.id}'
}]
return super()._get_replenishment_order_notification_link(production)
def _get_date_planned(self, route_id, **kwargs):
date = super()._get_date_planned(route_id, **kwargs)
if 'manufacture' not in route_id.rule_ids.mapped('action'):
return date
delay = 0
product_tmpl_id = kwargs.get('product_tmpl_id') or self.product_tmpl_id
if product_tmpl_id and product_tmpl_id.bom_ids:
delay += product_tmpl_id.bom_ids[0].produce_delay + product_tmpl_id.bom_ids[0].days_to_prepare_mo
return fields.Datetime.add(date, days=delay)
def _get_route_domain(self, product_tmpl_id):
domain = super()._get_route_domain(product_tmpl_id)
company = product_tmpl_id.company_id or self.env.company
manufacture_route = self.env['stock.rule'].search([('action', '=', 'manufacture'), ('company_id', '=', company.id)]).route_id
if manufacture_route and product_tmpl_id.bom_ids:
domain = Domain.OR([domain, Domain('id', 'in', manufacture_route.ids)])
return domain

View file

@ -1,93 +0,0 @@
# -*- 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
from odoo.tools.float_utils import float_compare
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() # Technical field to show the Apply button
show_backorders = fields.Boolean() # Technical field to show the Create Backorder and No Backorder buttons
multiple_lot_components_names = fields.Text() # Names of components with multiple lots, used to show warning
def generate_serial_numbers_production(self):
if self.next_serial_number and self.next_serial_count:
generated_serial_numbers = "\n".join(self.env['stock.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.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)
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
self.show_apply = float_compare(self.produced_qty, self.expected_qty, precision_digits=precision) == 0
self.show_backorders = self.produced_qty > 0 and self.produced_qty < self.expected_qty
def _assign_serial_numbers(self, cancel_remaining_quantity=False):
serial_numbers = self._get_serial_numbers()
productions = self.production_id._split_productions(
{self.production_id: [1] * len(serial_numbers)}, cancel_remaining_quantity, set_consumed_qty=True)
production_lots_vals = []
for serial_name in serial_numbers:
production_lots_vals.append({
'product_id': self.production_id.product_id.id,
'company_id': self.production_id.company_id.id,
'name': serial_name,
})
production_lots = self.env['stock.lot'].create(production_lots_vals)
for production, production_lot in zip(productions, production_lots):
production.lot_producing_id = production_lot.id
production.qty_producing = production.product_qty
for workorder in production.workorder_ids:
workorder.qty_produced = workorder.qty_producing
if productions and len(production_lots) < len(productions):
productions[-1].move_raw_ids.move_line_ids.write({'qty_done': 0})
productions[-1].state = "confirmed"
def apply(self):
self._assign_serial_numbers()
def create_backorder(self):
self._assign_serial_numbers(False)
def no_backorder(self):
self._assign_serial_numbers(True)

View file

@ -1,70 +0,0 @@
<?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="multiple_lot_components_names" invisible="1"/>
<group col="1">
<p class="o_form_label oe_inline text-danger" attrs="{'invisible': [('multiple_lot_components_names', '=', False)]}">
Note that components <field name="multiple_lot_components_names" readonly="True"/> have multiple lot reservations.<br/>
Do you want to confirm anyway ?
</p>
</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 col="1" 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>

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
class PickingLabelType(models.TransientModel):
_inherit = 'picking.label.type'
production_ids = fields.Many2many('mrp.production')
def process(self):
if not self.production_ids:
return super().process()
if self.label_type == 'products':
return self.production_ids.action_open_label_layout()
view = self.env.ref('stock.lot_label_layout_form_picking')
return {
'name': _('Choose Labels Layout'),
'type': 'ir.actions.act_window',
'res_model': 'lot.label.layout',
'views': [(view.id, 'form')],
'target': 'new',
'context': {
'default_move_line_ids': self.production_ids.move_finished_ids.move_line_ids.ids
},
}

View file

@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class StockReplenishmentInfo(models.TransientModel):
_inherit = 'stock.replenishment.info'
_description = 'Stock supplier replenishment information'
bom_id = fields.Many2one(related='orderpoint_id.bom_id')
bom_ids = fields.Many2many('mrp.bom', compute='_compute_bom_ids', store=True)
show_bom_tab = fields.Boolean(compute='_compute_show_bom_tab')
@api.depends('orderpoint_id')
def _compute_bom_ids(self):
for replenishment_info in self:
replenishment_info.bom_ids = replenishment_info.product_id.bom_ids
@api.depends('orderpoint_id')
def _compute_show_bom_tab(self):
for replenishment_info in self:
orderpoint = replenishment_info.orderpoint_id
replenishment_info.show_bom_tab = not orderpoint.route_id or (
orderpoint.route_id
and 'manufacture' in orderpoint.rule_ids.mapped('action')
)

View file

@ -0,0 +1,32 @@
<odoo>
<record id="mrp_bom_replenishment_tree_view" model="ir.ui.view">
<field name="name">mrp.bom.replenishment.list.view</field>
<field name="model">mrp.bom</field>
<field name="inherit_id" ref="mrp.mrp_bom_tree_view"/>
<field name="mode">primary</field>
<field name="priority">100</field>
<field name="arch" type="xml">
<field name="product_uom_id" position="after">
<field name="show_set_bom_button" column_invisible="True"/>
<button name="action_set_bom_on_orderpoint" type="object" string="Set as Bill of Materials" class="btn btn-link" invisible="not show_set_bom_button"/>
</field>
<field name="company_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
</field>
</record>
<record id="view_stock_replenishment_info_stock_mrp_inherit" model="ir.ui.view">
<field name="name">stock.replenishment.information.mrp.stock.inherit</field>
<field name="model">stock.replenishment.info</field>
<field name="inherit_id" ref="stock.view_stock_replenishment_info"/>
<field name="arch" type="xml">
<xpath expr="//page" position="before">
<page string="Bills of Materials" name="page_boms" invisible="not show_bom_tab">
<field name="bom_id" invisible="1"/>
<field name="bom_ids" readonly="1" context="{'list_view_ref': 'mrp.mrp_bom_replenishment_tree_view', 'stock_replenishment_info_id': id, 'orderpoint_id': orderpoint_id}"/>
</page>
</xpath>
</field>
</record>
</odoo>

View file

@ -6,7 +6,7 @@ from odoo import api, fields, models
class StockWarnInsufficientQtyUnbuild(models.TransientModel):
_name = 'stock.warn.insufficient.qty.unbuild'
_inherit = 'stock.warn.insufficient.qty'
_inherit = ['stock.warn.insufficient.qty']
_description = 'Warn Insufficient Unbuild Quantity'
unbuild_id = fields.Many2one('mrp.unbuild', 'Unbuild')