Merge PR #949 into 15.0

Signed-off-by simahawk
This commit is contained in:
OCA-git-bot
2023-03-01 14:37:07 +00:00
19 changed files with 642 additions and 0 deletions

View File

@@ -0,0 +1 @@
# TO BE GENERATED

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@@ -0,0 +1,21 @@
# Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "MRP Production Split",
"summary": "Split Manufacturing Orders into smaller ones",
"version": "15.0.1.0.0",
"author": "Camptocamp, Odoo Community Association (OCA)",
"maintainers": ["ivantodorovich"],
"website": "https://github.com/OCA/manufacture",
"license": "AGPL-3",
"category": "Manufacturing",
"depends": ["mrp"],
"data": [
"security/ir.model.access.csv",
"templates/messages.xml",
"views/mrp_production.xml",
"wizards/mrp_production_split_wizard.xml",
],
}

View File

@@ -0,0 +1 @@
from . import mrp_production

View File

@@ -0,0 +1,34 @@
# Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, models
from odoo.exceptions import UserError
class MrpProduction(models.Model):
_inherit = "mrp.production"
def copy_data(self, default=None):
# OVERRIDE copy the date_planned_start and date_planned_end when splitting
# productions, as they are not copied by default (copy=False).
[data] = super().copy_data(default=default)
data.setdefault("date_planned_start", self.date_planned_start)
data.setdefault("date_planned_finished", self.date_planned_finished)
return [data]
def action_split(self):
self.ensure_one()
self._check_company()
if self.state in ("draft", "done", "to_close", "cancel"):
raise UserError(
_(
"Cannot split a manufacturing order that is in '%s' state.",
self._fields["state"].convert_to_export(self.state, self),
)
)
action = self.env["ir.actions.actions"]._for_xml_id(
"mrp_production_split.action_mrp_production_split_wizard"
)
action["context"] = {"default_production_id": self.id}
return action

View File

@@ -0,0 +1,3 @@
* `Camptocamp <https://www.camptocamp.com>`_
* Iván Todorovich <ivan.todorovich@camptocamp.com>

View File

@@ -0,0 +1,12 @@
This module adds a "Split" button to Manufacturing Orders.
Manufacturing Orders can be split as long as they haven't been completed yet.
For products tracked by "Serial Number", it allows to choose the Quantity to extract
from the original MO, and it'll create one MO per single unit.
For other products, more options are available, that will let you do things like:
* Extract 10 units from a MO into 5 MOs of 2 units each.
* Extract 10 units from a MO into a single new MOs.
* Extract 10 units from a MO into multiple MOs of different quantities.

View File

@@ -0,0 +1,5 @@
#. Create a Manufacturing Order.
#. Confirm it.
#. Click on the "Split" button.
#. Choose the desired split options.
#. Confirm.

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mrp_production_split_wizard,access.mrp.production.split.wizard,model_mrp_production_split_wizard,mrp.group_mrp_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mrp_production_split_wizard access.mrp.production.split.wizard model_mrp_production_split_wizard mrp.group_mrp_user 1 1 1 1

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
@author Iván Todorovich <ivan.todorovich@camptocamp.com>
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<template id="message_order_split">
<p>
The following <t
t-esc="self.env['ir.model']._get(records._name).name.lower()"
/>(s)
have been created as a result of a <strong>split</strong> operation:
</p>
<ul>
<li t-foreach="records" t-as="o">
<a
href="#"
t-att-data-oe-model="o._name"
t-att-data-oe-id="o.id"
t-esc="o.display_name"
/>
</li>
</ul>
</template>
</odoo>

View File

@@ -0,0 +1 @@
from . import test_mrp_production_split

View File

@@ -0,0 +1,92 @@
# Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import Command
from odoo.tests import Form, TransactionCase
class CommonCase(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
# Create bom, product and components
cls.component = cls.env["product.product"].create(
{
"name": "Component",
"detailed_type": "product",
}
)
cls.product = cls.env["product.product"].create(
{
"name": "Product",
"detailed_type": "product",
"tracking": "lot",
}
)
cls.product_bom = cls.env["mrp.bom"].create(
{
"product_tmpl_id": cls.product.product_tmpl_id.id,
"product_qty": 1.0,
"product_uom_id": cls.product.uom_id.id,
"bom_line_ids": [
Command.create(
{
"product_id": cls.component.id,
"product_qty": 1.0,
"product_uom_id": cls.component.uom_id.id,
}
)
],
}
)
# Create some initial stocks
cls.location_stock = cls.env.ref("stock.stock_location_stock")
cls.env["stock.quant"].create(
{
"product_id": cls.component.id,
"product_uom_id": cls.component.uom_id.id,
"location_id": cls.location_stock.id,
"quantity": 10.00,
}
)
# Create the MO
cls.production = cls._create_mrp_production(
product=cls.product,
bom=cls.product_bom,
)
@classmethod
def _create_mrp_production(
cls, product=None, bom=None, quantity=5.0, confirm=False
):
if product is None: # pragma: no cover
product = cls.product
if bom is None: # pragma: no cover
bom = cls.product_bom
mo_form = Form(cls.env["mrp.production"])
mo_form.product_id = product
mo_form.bom_id = bom
mo_form.product_qty = quantity
mo_form.product_uom_id = product.uom_id
mo = mo_form.save()
if confirm: # pragma: no cover
mo.action_confirm()
return mo
def _mrp_production_set_quantity_done(self, order):
for line in order.move_raw_ids.move_line_ids:
line.qty_done = line.product_uom_qty
order.move_raw_ids._recompute_state()
order.qty_producing = order.product_qty
def _mrp_production_split(self, order, **vals):
action = order.action_split()
Wizard = self.env[action["res_model"]]
Wizard = Wizard.with_context(active_model=order._name, active_id=order.id)
Wizard = Wizard.with_context(**action["context"])
wizard = Wizard.create(vals)
res = wizard.apply()
records = self.env[res["res_model"]].search(res["domain"])
return records

View File

@@ -0,0 +1,178 @@
# Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import datetime, timedelta
from odoo.exceptions import UserError
from .common import CommonCase
class TestMrpProductionSplit(CommonCase):
def test_mrp_production_split_draft(self):
with self.assertRaisesRegex(UserError, r"Cannot split.*"):
self._mrp_production_split(self.production)
def test_mrp_production_split_done(self):
self.production.action_confirm()
self.production.action_generate_serial()
self._mrp_production_set_quantity_done(self.production)
self.production.button_mark_done()
with self.assertRaisesRegex(UserError, r"Cannot split.*"):
self._mrp_production_split(self.production)
def test_mrp_production_split_cancel(self):
self.production.action_cancel()
with self.assertRaisesRegex(UserError, r"Cannot split.*"):
self._mrp_production_split(self.production)
def test_mrp_production_split_lot_simple(self):
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(self.production, split_qty=2.0)
self.assertRecordValues(mos, [dict(product_qty=3.0), dict(product_qty=2.0)])
def test_mrp_production_split_lot_simple_copy_date_planned(self):
dt_start = datetime.now() + timedelta(days=5)
dt_finished = dt_start + timedelta(hours=1)
self.production.date_planned_start = dt_start
self.production.date_planned_finished = dt_finished
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(self.production, split_qty=2.0)
self.assertRecordValues(
mos,
[
dict(
product_qty=3.0,
date_planned_start=dt_start,
date_planned_finished=dt_finished,
),
dict(
product_qty=2.0,
date_planned_start=dt_start,
date_planned_finished=dt_finished,
),
],
)
def test_mrp_production_split_lot_simple_zero_qty(self):
self.production.action_confirm()
self.production.action_generate_serial()
with self.assertRaisesRegex(UserError, r"Nothing to split.*"):
self._mrp_production_split(self.production, split_qty=0.0)
def test_mrp_production_split_lot_simple_with_qty_producing_exceeded(self):
self.production.action_confirm()
self.production.action_generate_serial()
self.production.qty_producing = 3.0
with self.assertRaisesRegex(UserError, r"You can't split.*"):
self._mrp_production_split(self.production, split_qty=4.0)
def test_mrp_production_split_lot_equal(self):
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(
self.production,
split_mode="equal",
split_qty=4.0,
split_equal_qty=2.0,
)
self.assertRecordValues(
mos,
[
dict(product_qty=1.0),
dict(product_qty=2.0),
dict(product_qty=2.0),
],
)
def test_mrp_production_split_lot_custom(self):
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(
self.production,
split_mode="custom",
custom_quantities="1 2 1 1",
)
self.assertRecordValues(
mos,
[
dict(product_qty=1.0),
dict(product_qty=2.0),
dict(product_qty=1.0),
dict(product_qty=1.0),
],
)
def test_mrp_production_split_lot_custom_incomplete(self):
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(
self.production,
split_mode="custom",
custom_quantities="1 2",
)
self.assertRecordValues(
mos,
[
dict(product_qty=2.0),
dict(product_qty=1.0),
dict(product_qty=2.0),
],
)
def test_mrp_production_split_lot_custom_float(self):
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(
self.production,
split_mode="custom",
custom_quantities="1.0 2.0 1.0 1.0",
)
self.assertRecordValues(
mos,
[
dict(product_qty=1.0),
dict(product_qty=2.0),
dict(product_qty=1.0),
dict(product_qty=1.0),
],
)
def test_mrp_production_split_lot_custom_float_locale(self):
lang = self.env["res.lang"]._lang_get(self.env.user.lang)
lang.decimal_point = ","
lang.thousands_sep = ""
self.production.action_confirm()
self.production.action_generate_serial()
mos = self._mrp_production_split(
self.production,
split_mode="custom",
custom_quantities="1,0 2,0 1,0 1,0",
)
self.assertRecordValues(
mos,
[
dict(product_qty=1.0),
dict(product_qty=2.0),
dict(product_qty=1.0),
dict(product_qty=1.0),
],
)
def test_mrp_production_split_serial(self):
self.product.tracking = "serial"
self.production.action_confirm()
mos = self._mrp_production_split(self.production)
self.assertRecordValues(
mos,
[
dict(product_qty=1.0),
dict(product_qty=1.0),
dict(product_qty=1.0),
dict(product_qty=1.0),
dict(product_qty=1.0),
],
)

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
@author Iván Todorovich <ivan.todorovich@camptocamp.com>
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="mrp_production_form_view" model="ir.ui.view">
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view" />
<field name="arch" type="xml">
<button name="action_serial_mass_produce_wizard" position="before">
<button
name="action_split"
string="Split"
type="object"
attrs="{
'invisible': [
'|',
('state', 'in', ('draft', 'done', 'to_close', 'cancel')),
('product_qty', '&lt;=', 1),
],
}"
/>
</button>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import mrp_production_split_wizard

View File

@@ -0,0 +1,167 @@
# Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from typing import List, Union
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class MrpProductionSplitWizard(models.TransientModel):
_name = "mrp.production.split.wizard"
production_id = fields.Many2one(
"mrp.production",
"Production",
required=True,
ondelete="cascade",
)
split_mode = fields.Selection(
[
("simple", "Extract a quantity from the original MO"),
("equal", "Extract a quantity into several MOs with equal quantities"),
("custom", "Custom"),
],
required=True,
default="simple",
)
split_qty = fields.Float(
string="Quantity",
digits="Product Unit of Measure",
help="Total quantity to extract from the original MO.",
)
split_equal_qty = fields.Float(
string="Equal Quantity",
digits="Product Unit of Measure",
help="Used to split the MO into several MOs with equal quantities.",
default=1,
)
custom_quantities = fields.Char(
string="Split Quantities",
help="Space separated list of quantities to split:\n"
"e.g. '3 2 5' will result in 3 MOs with 3, 2 and 5 units respectively.\n"
"If the sum of the quantities is less than the original MO's quantity, the "
"remaining quantity will remain in the original MO.",
)
product_tracking = fields.Selection(related="production_id.product_id.tracking")
product_uom_id = fields.Many2one(related="production_id.product_uom_id")
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_model = self.env.context.get("active_model")
active_id = self.env.context.get("active_id")
# Auto-complete production_id from context
if "production_id" in fields_list and active_model == "mrp.production":
res["production_id"] = active_id
# Auto-complete split_mode from production_id
if "split_mode" in fields_list and res.get("production_id"):
production = self.env["mrp.production"].browse(res["production_id"])
if production.product_tracking == "serial":
res["split_mode"] = "equal"
# Auto-complete split_qty from production_id
if "split_qty" in fields_list and res.get("production_id"):
production = self.env["mrp.production"].browse(res["production_id"])
res["split_qty"] = production._get_quantity_to_backorder()
return res
@api.model
def _parse_float(self, value: Union[float, int, str]) -> float:
"""Parse a float number from a string, with the user's language settings."""
if isinstance(value, (float, int)): # pragma: no cover
return float(value)
lang = self.env["res.lang"]._lang_get(self.env.user.lang)
try:
return float(
value.replace(lang.thousands_sep, "")
.replace(lang.decimal_point, ".")
.strip()
)
except ValueError as e: # pragma: no cover
raise UserError(_("%s is not a number.", value)) from e
@api.model
def _parse_float_list(self, value: str) -> List[float]:
"""Parse a list of float numbers from a string."""
return [self._parse_float(v) for v in value.split()]
@api.onchange("custom_quantities")
def _onchange_custom_quantities_check(self):
"""Check that the custom quantities are valid."""
if self.custom_quantities: # pragma: no cover
try:
self._parse_float_list(self.custom_quantities)
except UserError:
return {
"warning": {
"title": _("Invalid quantities"),
"message": _("Please enter a space separated list of numbers."),
}
}
def _get_split_quantities(self) -> List[float]:
"""Return the quantities to split, according to the settings."""
production = self.production_id
rounding = production.product_uom_id.rounding
if self.split_mode == "simple":
if (
fields.Float.compare(
self.split_qty, production._get_quantity_to_backorder(), rounding
)
> 0
):
raise UserError(_("You can't split quantities already in production."))
if fields.Float.is_zero(self.split_qty, precision_rounding=rounding):
raise UserError(_("Nothing to split."))
return [production.product_qty - self.split_qty, self.split_qty]
elif self.split_mode == "equal":
split_total = min(production._get_quantity_to_backorder(), self.split_qty)
split_count = int(split_total // self.split_equal_qty)
split_rest = production.product_qty - split_total
split_rest += split_total % self.split_equal_qty
quantities = [self.split_equal_qty] * split_count
if not fields.Float.is_zero(split_rest, precision_rounding=rounding):
quantities = [split_rest] + quantities
return quantities
elif self.split_mode == "custom":
quantities = self._parse_float_list(self.custom_quantities)
split_total = sum(quantities)
split_rest = production.product_qty - split_total
if not fields.Float.is_zero(split_rest, precision_rounding=rounding):
quantities = [split_rest] + quantities
return quantities
else: # pragma: no cover
raise UserError(_("Invalid Split Mode: '%s'", self.split_mode))
def _apply(self):
self.ensure_one()
records = self.production_id.with_context(
copy_date_planned=True
)._split_productions(
amounts={self.production_id: self._get_split_quantities()},
cancel_remaning_qty=False,
set_consumed_qty=False,
)
new_records = records - self.production_id
for record in new_records:
record.message_post_with_view(
"mail.message_origin_link",
values=dict(self=record, origin=self.production_id),
message_log=True,
)
if new_records:
self.production_id.message_post_with_view(
"mrp_production_split.message_order_split",
values=dict(self=self.production_id, records=new_records),
message_log=True,
)
return records
def apply(self):
records = self._apply()
action = self.env["ir.actions.act_window"]._for_xml_id(
"mrp.mrp_production_action"
)
action["domain"] = [("id", "in", records.ids)]
return action

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2023 Camptocamp SA (https://www.camptocamp.com).
@author Iván Todorovich <ivan.todorovich@camptocamp.com>
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="view_mrp_production_split_wizard_form" model="ir.ui.view">
<field name="model">mrp.production.split.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="production_id" invisible="1" />
<field name="product_tracking" invisible="1" />
<field
name="split_mode"
widget="radio"
attrs="{'invisible': [('product_tracking', '=', 'serial')]}"
/>
<field
name="split_qty"
attrs="{'invisible': [('split_mode', 'not in', ('simple', 'equal'))]}"
/>
<field
name="split_equal_qty"
string="In orders of"
attrs="{'invisible': ['|', ('product_tracking', '=', 'serial'), ('split_mode', '!=', 'equal')]}"
/>
<field
name="custom_quantities"
attrs="{'invisible': [('split_mode', '!=', 'custom')]}"
/>
</group>
<footer>
<button
name="apply"
type="object"
data-hotkey="q"
string="Apply"
class="oe_highlight"
/>
<button special="cancel" data-hotkey="z" string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="action_mrp_production_split_wizard" model="ir.actions.act_window">
<field name="name">Split Manufacturing Order</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.production.split.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
../../../../mrp_production_split

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)