[WIP]
This commit is contained in:
parent
f913eac312
commit
d87b366d41
16 changed files with 695 additions and 1 deletions
|
|
@ -1 +1,18 @@
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import wizard
|
||||||
|
from odoo import api, SUPERUSER_ID
|
||||||
|
|
||||||
|
|
||||||
|
def post_init_hook(cr, registry):
|
||||||
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||||
|
stueck = env.ref(
|
||||||
|
"nfu_sale_order_batch_packaging.product_uom_stueck",
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not stueck:
|
||||||
|
return
|
||||||
|
imd = env["ir.model.data"].search(
|
||||||
|
[("module", "=", "_import_"), ("name", "=", "uom.product_uom_piece")]
|
||||||
|
)
|
||||||
|
if imd:
|
||||||
|
imd.write({"res_id": stueck.id})
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,14 @@
|
||||||
"version": "16.0.1.3.7",
|
"version": "16.0.1.3.7",
|
||||||
"depends": ["sale", "sale_order_batch"],
|
"depends": ["sale", "sale_order_batch"],
|
||||||
"data": [
|
"data": [
|
||||||
|
"security/ir.model.access.csv",
|
||||||
"data/ir_config_parameter.xml",
|
"data/ir_config_parameter.xml",
|
||||||
|
"data/uom_data.xml",
|
||||||
|
"wizard/sale_order_batch_packaging_reconcile_views.xml",
|
||||||
"views/sale_order_views.xml",
|
"views/sale_order_views.xml",
|
||||||
"views/sale_order_batch_views.xml",
|
"views/sale_order_batch_views.xml",
|
||||||
"views/sale_order_batch_product_views.xml",
|
"views/sale_order_batch_product_views.xml",
|
||||||
],
|
],
|
||||||
"license": "LGPL-3",
|
"license": "LGPL-3",
|
||||||
|
"post_init_hook": "post_init_hook",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
nfu_sale_order_batch_packaging/data/uom_data.xml
Normal file
15
nfu_sale_order_batch_packaging/data/uom_data.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="uom.product_uom_unit" model="uom.uom">
|
||||||
|
<field name="reconcile_whole_units">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_uom_stueck" model="uom.uom">
|
||||||
|
<field name="name">Stück</field>
|
||||||
|
<field name="category_id" ref="uom.product_uom_categ_unit"/>
|
||||||
|
<field name="uom_type">smaller</field>
|
||||||
|
<field name="rounding">0.01</field>
|
||||||
|
<field name="ratio">1.0</field>
|
||||||
|
<field name="reconcile_whole_units">True</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from . import product_product
|
from . import product_product
|
||||||
from . import product_template
|
from . import product_template
|
||||||
|
from . import sale_order_batch
|
||||||
from . import sale_order_batch_product
|
from . import sale_order_batch_product
|
||||||
from . import sale_order_line
|
from . import sale_order_line
|
||||||
|
from . import uom_uom
|
||||||
|
|
|
||||||
64
nfu_sale_order_batch_packaging/models/sale_order_batch.py
Normal file
64
nfu_sale_order_batch_packaging/models/sale_order_batch.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderBatch(models.Model):
|
||||||
|
_inherit = "sale.order.batch"
|
||||||
|
|
||||||
|
has_qty_adjusted = fields.Boolean(
|
||||||
|
compute="_compute_has_qty_adjusted",
|
||||||
|
)
|
||||||
|
has_fillable_packages = fields.Boolean(
|
||||||
|
compute="_compute_has_fillable_packages",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
"sale_order_line_ids.product_uom_qty",
|
||||||
|
"sale_order_line_ids.product_uom_ordered_qty",
|
||||||
|
)
|
||||||
|
def _compute_has_qty_adjusted(self):
|
||||||
|
for batch in self:
|
||||||
|
batch.has_qty_adjusted = any(
|
||||||
|
line.product_uom_qty != line.product_uom_ordered_qty
|
||||||
|
for line in batch.sale_order_line_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends("product_ids.open_packaging_qty", "product_ids.open_packaging_max_qty")
|
||||||
|
def _compute_has_fillable_packages(self):
|
||||||
|
for batch in self:
|
||||||
|
batch.has_fillable_packages = any(
|
||||||
|
p.open_packaging_qty > 0 and p.open_packaging_max_qty == 0.0
|
||||||
|
for p in batch.product_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_restore_ordered_qty(self):
|
||||||
|
self.ensure_one()
|
||||||
|
lines = self.sale_order_line_ids.filtered(
|
||||||
|
lambda l: l.product_uom_qty != l.product_uom_ordered_qty
|
||||||
|
)
|
||||||
|
for line in lines:
|
||||||
|
line.write({"product_uom_qty": line.product_uom_ordered_qty})
|
||||||
|
|
||||||
|
def action_fill_packages(self):
|
||||||
|
self.ensure_one()
|
||||||
|
eligible = self.product_ids.filtered(
|
||||||
|
lambda p: p.open_packaging_qty > 0 and p.open_packaging_max_qty == 0.0
|
||||||
|
)
|
||||||
|
ineligible = self.product_ids.filtered(
|
||||||
|
lambda p: p.open_packaging_qty > 0 and p.open_packaging_max_qty != 0.0
|
||||||
|
)
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].create(
|
||||||
|
{
|
||||||
|
"batch_id": self.id,
|
||||||
|
"line_ids": [(0, 0, {"batch_product_id": p.id}) for p in eligible],
|
||||||
|
"zero_line_ids": [
|
||||||
|
(0, 0, {"batch_product_id": p.id}) for p in ineligible
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "sale.order.batch.packaging.reconcile",
|
||||||
|
"res_id": wizard.id,
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "new",
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from odoo import api, fields, models
|
from odoo import api, fields, models
|
||||||
|
from odoo.tools import float_is_zero, float_round
|
||||||
|
|
||||||
|
|
||||||
PACKAGING_STATES = [
|
PACKAGING_STATES = [
|
||||||
|
|
@ -80,6 +81,64 @@ class SaleOrderBatchProduct(models.Model):
|
||||||
else:
|
else:
|
||||||
product.open_packaging_state = "last_open"
|
product.open_packaging_state = "last_open"
|
||||||
|
|
||||||
|
has_qty_adjusted = fields.Boolean(
|
||||||
|
compute="_compute_has_qty_adjusted",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
"sale_order_line_ids.product_uom_qty",
|
||||||
|
"sale_order_line_ids.product_uom_ordered_qty",
|
||||||
|
)
|
||||||
|
def _compute_has_qty_adjusted(self):
|
||||||
|
for product in self:
|
||||||
|
product.has_qty_adjusted = any(
|
||||||
|
line.product_uom_qty != line.product_uom_ordered_qty
|
||||||
|
for line in product.sale_order_line_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_restore_ordered_qty(self):
|
||||||
|
self.ensure_one()
|
||||||
|
lines = self.sale_order_line_ids.filtered(
|
||||||
|
lambda l: l.product_uom_qty != l.product_uom_ordered_qty
|
||||||
|
)
|
||||||
|
for line in lines:
|
||||||
|
line.write({"product_uom_qty": line.product_uom_ordered_qty})
|
||||||
|
|
||||||
|
def _reconcile_packaging(self, rounding):
|
||||||
|
self.ensure_one()
|
||||||
|
target = self.open_packaging_qty
|
||||||
|
eligible = sorted(
|
||||||
|
[
|
||||||
|
(line, line.product_uom_max_qty - line.product_uom_qty)
|
||||||
|
for line in self.sale_order_line_ids
|
||||||
|
if line.product_uom_max_qty > line.product_uom_qty
|
||||||
|
],
|
||||||
|
key=lambda x: x[1],
|
||||||
|
)
|
||||||
|
n = len(eligible)
|
||||||
|
distributed = 0.0
|
||||||
|
last_line = None
|
||||||
|
for line, room in eligible:
|
||||||
|
remaining = target - distributed
|
||||||
|
fair_share = float_round(remaining / n, precision_rounding=rounding)
|
||||||
|
addition = min(room, fair_share)
|
||||||
|
if addition > 0:
|
||||||
|
line.write({"product_uom_qty": line.product_uom_qty + addition})
|
||||||
|
distributed += addition
|
||||||
|
last_line = line
|
||||||
|
n -= 1
|
||||||
|
if float_is_zero(target - distributed, precision_rounding=rounding):
|
||||||
|
break
|
||||||
|
# Attach any sub-rounding remainder directly to the last eligible line
|
||||||
|
remainder = target - distributed
|
||||||
|
if remainder > 1e-9 and last_line:
|
||||||
|
last_line.write({"product_uom_qty": last_line.product_uom_qty + remainder})
|
||||||
|
|
||||||
|
def _zero_packaging(self):
|
||||||
|
self.ensure_one()
|
||||||
|
for line in self.sale_order_line_ids:
|
||||||
|
line.write({"product_uom_qty": 0.0})
|
||||||
|
|
||||||
@api.model_create_multi
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
for vals in vals_list:
|
for vals in vals_list:
|
||||||
|
|
|
||||||
11
nfu_sale_order_batch_packaging/models/uom_uom.py
Normal file
11
nfu_sale_order_batch_packaging/models/uom_uom.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class UomUom(models.Model):
|
||||||
|
_inherit = "uom.uom"
|
||||||
|
|
||||||
|
reconcile_whole_units = fields.Boolean(
|
||||||
|
string="Reconcile as Whole Units",
|
||||||
|
default=False,
|
||||||
|
help="When set, the packaging reconciliation wizard defaults to rounding=1 for this UOM.",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_batch_packaging_reconcile,sale.order.batch.packaging.reconcile,model_sale_order_batch_packaging_reconcile,base.group_user,1,1,1,1
|
||||||
|
access_batch_packaging_reconcile_line,sale.order.batch.packaging.reconcile.line,model_sale_order_batch_packaging_reconcile_line,base.group_user,1,1,1,1
|
||||||
|
access_batch_packaging_reconcile_zero_line,sale.order.batch.packaging.reconcile.zero.line,model_sale_order_batch_packaging_reconcile_zero_line,base.group_user,1,1,1,1
|
||||||
|
1
nfu_sale_order_batch_packaging/tests/__init__.py
Normal file
1
nfu_sale_order_batch_packaging/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_batch_packaging_reconcile
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
|
class TestBatchPackagingReconcile(TransactionCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.uom_kg = self.env.ref("uom.product_uom_kgm")
|
||||||
|
self.uom_unit = self.env.ref("uom.product_uom_unit")
|
||||||
|
self.partner = self.env["res.partner"].create({"name": "Test Partner"})
|
||||||
|
self.batch = self.env["sale.order.batch"].create(
|
||||||
|
{"name": "Test Batch", "state": "in_progress"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_product(self, uom, packaging_qty):
|
||||||
|
product = self.env["product.product"].create(
|
||||||
|
{
|
||||||
|
"name": f"Product {uom.name} {packaging_qty}",
|
||||||
|
"uom_id": uom.id,
|
||||||
|
"uom_po_id": uom.id,
|
||||||
|
"type": "consu",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.env["product.packaging"].create(
|
||||||
|
{
|
||||||
|
"name": "Pack",
|
||||||
|
"product_id": product.id,
|
||||||
|
"qty": packaging_qty,
|
||||||
|
"sales": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return product
|
||||||
|
|
||||||
|
def _add_lines(self, product, lines_data):
|
||||||
|
"""Create one sale order in self.batch with one line per (qty, max_qty) pair.
|
||||||
|
Returns the sale.order.batch.product that aggregates all lines."""
|
||||||
|
order = self.env["sale.order"].create(
|
||||||
|
{"partner_id": self.partner.id, "batch_id": self.batch.id}
|
||||||
|
)
|
||||||
|
for qty, max_qty in lines_data:
|
||||||
|
self.env["sale.order.line"].create(
|
||||||
|
{
|
||||||
|
"order_id": order.id,
|
||||||
|
"product_id": product.id,
|
||||||
|
"product_uom_qty": qty,
|
||||||
|
"product_uom_max_qty": max_qty,
|
||||||
|
"price_unit": 10.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.batch.product_ids.filtered(lambda p: p.product_id == product)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# _reconcile_packaging
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_even_split(self):
|
||||||
|
"""All lines with identical room each receive an equal share."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
# total = 24, open = 6; each line has exactly 3 kg of room
|
||||||
|
bp = self._add_lines(product, [(7.0, 10.0), (8.0, 11.0), (9.0, 12.0)])
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 6.0, places=2)
|
||||||
|
|
||||||
|
bp._reconcile_packaging(0.001)
|
||||||
|
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
# each line must have gained exactly 2 kg
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
line.product_uom_qty,
|
||||||
|
line.product_uom_max_qty - 1.0,
|
||||||
|
places=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_constrained_line_capped_at_max(self):
|
||||||
|
"""A line that cannot take its fair share is capped; remainder is spread evenly."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
# total = 23, open = 7
|
||||||
|
# rooms: 1 (capped), 10, 7 → sorted: 1, 7, 10
|
||||||
|
bp = self._add_lines(product, [(8.0, 9.0), (5.0, 15.0), (10.0, 17.0)])
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 7.0, places=2)
|
||||||
|
|
||||||
|
bp._reconcile_packaging(0.001)
|
||||||
|
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
capped = bp.sale_order_line_ids.filtered(lambda l: l.product_uom_max_qty == 9.0)
|
||||||
|
# capped line must be at its max
|
||||||
|
self.assertAlmostEqual(capped.product_uom_qty, 9.0, places=3)
|
||||||
|
# every line must stay within its max
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertLessEqual(line.product_uom_qty, line.product_uom_max_qty + 1e-6)
|
||||||
|
|
||||||
|
def test_whole_units_rounding_produces_integers(self):
|
||||||
|
"""rounding=1.0 yields only whole-number additions."""
|
||||||
|
product = self._make_product(self.uom_unit, 6.0)
|
||||||
|
# total = 7, open = 5; rooms: 3 and 5
|
||||||
|
bp = self._add_lines(product, [(3.0, 6.0), (4.0, 9.0)])
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 5.0, places=2)
|
||||||
|
orig = {l.id: l.product_uom_qty for l in bp.sale_order_line_ids}
|
||||||
|
|
||||||
|
bp._reconcile_packaging(1.0)
|
||||||
|
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
added = line.product_uom_qty - orig[line.id]
|
||||||
|
self.assertAlmostEqual(added, round(added), places=6)
|
||||||
|
|
||||||
|
def test_decimal_rounding_uses_tenth_steps(self):
|
||||||
|
"""rounding=0.1 distributes in 0.1-unit steps."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
# total = 7, open = 3; rooms: 7 and 6 → sorted: line2(6), line1(7)
|
||||||
|
bp = self._add_lines(product, [(3.0, 10.0), (4.0, 10.0)])
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 3.0, places=2)
|
||||||
|
orig = {l.id: l.product_uom_qty for l in bp.sale_order_line_ids}
|
||||||
|
|
||||||
|
bp._reconcile_packaging(0.1)
|
||||||
|
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
added = round(line.product_uom_qty - orig[line.id], 6)
|
||||||
|
# must be a multiple of 0.1 (within floating-point tolerance)
|
||||||
|
self.assertAlmostEqual(added % 0.1, 0.0, places=5)
|
||||||
|
|
||||||
|
def test_sub_rounding_remainder_is_attached(self):
|
||||||
|
"""A remainder smaller than the rounding step is added directly to the last line."""
|
||||||
|
product = self._make_product(self.uom_kg, 3.0)
|
||||||
|
# total = 2.7, open = 0.3; with rounding=1.0 every fair_share rounds to 0
|
||||||
|
bp = self._add_lines(product, [(0.9, 1.5), (0.9, 1.5), (0.9, 1.5)])
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.3, places=2)
|
||||||
|
|
||||||
|
bp._reconcile_packaging(1.0)
|
||||||
|
|
||||||
|
# The whole 0.3 remainder must have been placed on one line
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
# Every line must still respect its max qty
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertLessEqual(line.product_uom_qty, line.product_uom_max_qty + 1e-6)
|
||||||
|
|
||||||
|
def test_no_line_exceeds_max_qty(self):
|
||||||
|
"""After reconciliation no sale order line may exceed its max qty."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(product, [(6.0, 8.0), (7.0, 12.0), (4.0, 9.0)])
|
||||||
|
bp._reconcile_packaging(0.001)
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertLessEqual(line.product_uom_qty, line.product_uom_max_qty + 1e-6)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Wizard
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_wizard_includes_eligible_products_only(self):
|
||||||
|
"""Eligible products land in line_ids; ineligible ones land in zero_line_ids."""
|
||||||
|
eligible = self._make_product(self.uom_kg, 10.0)
|
||||||
|
ineligible = self._make_product(self.uom_kg, 10.0)
|
||||||
|
# eligible: total max (30) covers needed (23+7=30)
|
||||||
|
self._add_lines(eligible, [(23.0, 30.0)])
|
||||||
|
# ineligible: total max (24) < 23+7=30 → open_packaging_max_qty != 0
|
||||||
|
self._add_lines(ineligible, [(23.0, 24.0)])
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(wizard.line_ids), 1)
|
||||||
|
self.assertEqual(wizard.line_ids.product_id, eligible)
|
||||||
|
self.assertEqual(len(wizard.zero_line_ids), 1)
|
||||||
|
self.assertEqual(wizard.zero_line_ids.product_id, ineligible)
|
||||||
|
|
||||||
|
def test_wizard_default_whole_units_for_unit_uom(self):
|
||||||
|
"""UOMs with reconcile_whole_units=True default use_whole_units to True."""
|
||||||
|
product = self._make_product(self.uom_unit, 6.0)
|
||||||
|
self._add_lines(product, [(3.0, 9.0)])
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(wizard.line_ids.use_whole_units)
|
||||||
|
|
||||||
|
def test_wizard_default_whole_units_for_stueck_uom(self):
|
||||||
|
"""Stück UOM (reconcile_whole_units=True) defaults use_whole_units to True."""
|
||||||
|
uom_stueck = self.env.ref("nfu_sale_order_batch_packaging.product_uom_stueck")
|
||||||
|
product = self._make_product(uom_stueck, 6.0)
|
||||||
|
self._add_lines(product, [(3.0, 9.0)])
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(wizard.line_ids.use_whole_units)
|
||||||
|
|
||||||
|
def test_wizard_default_decimal_for_kg_uom(self):
|
||||||
|
"""UOMs without reconcile_whole_units default use_whole_units to False."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
self._add_lines(product, [(23.0, 30.0)])
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(wizard.line_ids.use_whole_units)
|
||||||
|
|
||||||
|
def test_wizard_reconcile_closes_open_packaging(self):
|
||||||
|
"""End-to-end: confirming the wizard brings open_packaging_qty to zero."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(product, [(23.0, 30.0)])
|
||||||
|
self.assertGreater(bp.open_packaging_qty, 0.0)
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
wizard.action_reconcile()
|
||||||
|
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Restore
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_restore_batch_resets_all_lines(self):
|
||||||
|
"""action_restore_ordered_qty on the batch sets every line back to ordered qty."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(product, [(23.0, 30.0)])
|
||||||
|
bp._reconcile_packaging(0.1)
|
||||||
|
# reconciliation must have increased some qty
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
l.product_uom_qty != l.product_uom_ordered_qty
|
||||||
|
for l in bp.sale_order_line_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.batch.action_restore_ordered_qty()
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
line.product_uom_qty, line.product_uom_ordered_qty, places=3
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_restore_batch_product_resets_its_lines_only(self):
|
||||||
|
"""action_restore_ordered_qty on a batch product only touches its own lines."""
|
||||||
|
product_a = self._make_product(self.uom_kg, 10.0)
|
||||||
|
product_b = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp_a = self._add_lines(product_a, [(23.0, 30.0)])
|
||||||
|
bp_b = self._add_lines(product_b, [(23.0, 30.0)])
|
||||||
|
bp_a._reconcile_packaging(0.1)
|
||||||
|
bp_b._reconcile_packaging(0.1)
|
||||||
|
|
||||||
|
bp_a.action_restore_ordered_qty()
|
||||||
|
|
||||||
|
for line in bp_a.sale_order_line_ids:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
line.product_uom_qty, line.product_uom_ordered_qty, places=3
|
||||||
|
)
|
||||||
|
# product_b lines must be unchanged
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
l.product_uom_qty != l.product_uom_ordered_qty
|
||||||
|
for l in bp_b.sale_order_line_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_has_qty_adjusted_false_before_reconcile(self):
|
||||||
|
"""has_qty_adjusted is False when no line has been modified."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
self._add_lines(product, [(10.0, 15.0)])
|
||||||
|
self.assertFalse(self.batch.has_qty_adjusted)
|
||||||
|
|
||||||
|
def test_has_qty_adjusted_true_after_reconcile(self):
|
||||||
|
"""has_qty_adjusted becomes True once a line quantity is increased."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(product, [(23.0, 30.0)])
|
||||||
|
bp._reconcile_packaging(0.1)
|
||||||
|
self.assertTrue(self.batch.has_qty_adjusted)
|
||||||
|
|
||||||
|
def test_has_qty_adjusted_false_after_restore(self):
|
||||||
|
"""has_qty_adjusted returns to False after restoring ordered quantities."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(product, [(23.0, 30.0)])
|
||||||
|
bp._reconcile_packaging(0.1)
|
||||||
|
self.batch.action_restore_ordered_qty()
|
||||||
|
self.assertFalse(self.batch.has_qty_adjusted)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Zero-out
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_zero_packaging_sets_lines_to_zero(self):
|
||||||
|
"""_zero_packaging sets all sale order line quantities to 0."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(product, [(23.0, 24.0)]) # ineligible (max too tight)
|
||||||
|
self.assertGreater(bp.open_packaging_qty, 0.0)
|
||||||
|
|
||||||
|
bp._zero_packaging()
|
||||||
|
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertAlmostEqual(line.product_uom_qty, 0.0, places=3)
|
||||||
|
|
||||||
|
def test_wizard_zeros_ineligible_by_default(self):
|
||||||
|
"""Confirming the wizard zeros ineligible lines (keep_qty=False default)."""
|
||||||
|
ineligible = self._make_product(self.uom_kg, 10.0)
|
||||||
|
self._add_lines(ineligible, [(23.0, 24.0)])
|
||||||
|
bp = self.batch.product_ids.filtered(lambda p: p.product_id == ineligible)
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
self.assertFalse(wizard.zero_line_ids.keep_qty)
|
||||||
|
|
||||||
|
wizard.action_reconcile()
|
||||||
|
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertAlmostEqual(line.product_uom_qty, 0.0, places=3)
|
||||||
|
|
||||||
|
def test_wizard_keeps_ineligible_when_keep_qty_true(self):
|
||||||
|
"""Lines with keep_qty=True are not zeroed out."""
|
||||||
|
ineligible = self._make_product(self.uom_kg, 10.0)
|
||||||
|
bp = self._add_lines(ineligible, [(23.0, 24.0)])
|
||||||
|
orig_qtys = {l.id: l.product_uom_qty for l in bp.sale_order_line_ids}
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
wizard.zero_line_ids.write({"keep_qty": True})
|
||||||
|
wizard.action_reconcile()
|
||||||
|
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
self.assertAlmostEqual(line.product_uom_qty, orig_qtys[line.id], places=3)
|
||||||
|
|
||||||
|
def test_wizard_respects_use_whole_units_override(self):
|
||||||
|
"""Toggling use_whole_units on a wizard line changes the applied rounding."""
|
||||||
|
product = self._make_product(self.uom_kg, 10.0)
|
||||||
|
# total = 7, open = 3; 3 lines with room 1 each won't cover 3 with rounding=1
|
||||||
|
# but with rounding=0.1 each gets 1.0 exactly — use 2 lines (rooms: 7 and 6)
|
||||||
|
bp = self._add_lines(product, [(3.0, 10.0), (4.0, 10.0)])
|
||||||
|
|
||||||
|
action = self.batch.action_fill_packages()
|
||||||
|
wizard = self.env["sale.order.batch.packaging.reconcile"].browse(
|
||||||
|
action["res_id"]
|
||||||
|
)
|
||||||
|
# Force whole-units even though UOM is kg
|
||||||
|
wizard.line_ids.write({"use_whole_units": True})
|
||||||
|
wizard.action_reconcile()
|
||||||
|
|
||||||
|
self.assertAlmostEqual(bp.open_packaging_qty, 0.0, places=2)
|
||||||
|
for line in bp.sale_order_line_ids:
|
||||||
|
# additions must be whole numbers when use_whole_units=True
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
line.product_uom_qty, round(line.product_uom_qty), places=6
|
||||||
|
)
|
||||||
|
|
@ -15,6 +15,15 @@
|
||||||
<field name="open_packaging_max_qty" decoration-danger="(open_packaging_max_qty != 0.0)"/>
|
<field name="open_packaging_max_qty" decoration-danger="(open_packaging_max_qty != 0.0)"/>
|
||||||
<field name="open_packaging_state"/>
|
<field name="open_packaging_state"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="has_qty_adjusted" invisible="1"/>
|
||||||
|
<button
|
||||||
|
name="action_restore_ordered_qty"
|
||||||
|
type="object"
|
||||||
|
string="Restore Ordered Qty"
|
||||||
|
attrs="{'invisible': [('has_qty_adjusted', '=', False)]}"
|
||||||
|
/>
|
||||||
|
</xpath>
|
||||||
<xpath
|
<xpath
|
||||||
expr="//field[@name='sale_order_line_ids']/tree/field[@name='product_uom_qty']"
|
expr="//field[@name='sale_order_line_ids']/tree/field[@name='product_uom_qty']"
|
||||||
position="before"
|
position="before"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,42 @@
|
||||||
<xpath expr="//field[@name='product_ids']/tree/field[@name='product_id']" position="attributes">
|
<xpath expr="//field[@name='product_ids']/tree/field[@name='product_id']" position="attributes">
|
||||||
<attribute name="decoration-warning">(open_packaging_qty != 0)</attribute>
|
<attribute name="decoration-warning">(open_packaging_qty != 0)</attribute>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
<!-- Confirm (in_progress): primary when nothing to fill, secondary otherwise -->
|
||||||
|
<xpath expr="//button[@id='action_confirm'][@class='btn-primary']" position="replace">
|
||||||
|
<button
|
||||||
|
name="action_confirm"
|
||||||
|
id="action_confirm"
|
||||||
|
data-hotkey="v"
|
||||||
|
string="Confirm"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
attrs="{'invisible': ['|', ('state', 'not in', ['in_progress']), ('has_fillable_packages', '=', True)]}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="action_confirm"
|
||||||
|
string="Confirm"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
attrs="{'invisible': ['|', ('state', 'not in', ['in_progress']), ('has_fillable_packages', '=', False)]}"
|
||||||
|
/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="has_qty_adjusted" invisible="1"/>
|
||||||
|
<field name="has_fillable_packages" invisible="1"/>
|
||||||
|
<button
|
||||||
|
name="action_fill_packages"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
string="Fill Packages"
|
||||||
|
attrs="{'invisible': ['|', ('state', 'not in', ['in_progress']), ('has_fillable_packages', '=', False)]}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="action_restore_ordered_qty"
|
||||||
|
type="object"
|
||||||
|
string="Restore Ordered Qty"
|
||||||
|
attrs="{'invisible': [('has_qty_adjusted', '=', False)]}"
|
||||||
|
/>
|
||||||
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
1
nfu_sale_order_batch_packaging/wizard/__init__.py
Normal file
1
nfu_sale_order_batch_packaging/wizard/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import sale_order_batch_packaging_reconcile
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderBatchPackagingReconcile(models.TransientModel):
|
||||||
|
_name = "sale.order.batch.packaging.reconcile"
|
||||||
|
_description = "Batch Packaging Auto-Reconcile Wizard"
|
||||||
|
|
||||||
|
batch_id = fields.Many2one("sale.order.batch", readonly=True)
|
||||||
|
line_ids = fields.One2many("sale.order.batch.packaging.reconcile.line", "wizard_id")
|
||||||
|
zero_line_ids = fields.One2many(
|
||||||
|
"sale.order.batch.packaging.reconcile.zero.line", "wizard_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_reconcile(self):
|
||||||
|
for line in self.line_ids:
|
||||||
|
rounding = 1.0 if line.use_whole_units else 0.1
|
||||||
|
line.batch_product_id._reconcile_packaging(rounding)
|
||||||
|
for zero_line in self.zero_line_ids.filtered(lambda l: not l.keep_qty):
|
||||||
|
zero_line.batch_product_id._zero_packaging()
|
||||||
|
return {"type": "ir.actions.act_window_close"}
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderBatchPackagingReconcileLine(models.TransientModel):
|
||||||
|
_name = "sale.order.batch.packaging.reconcile.line"
|
||||||
|
_description = "Batch Packaging Auto-Reconcile Wizard Line"
|
||||||
|
|
||||||
|
wizard_id = fields.Many2one(
|
||||||
|
"sale.order.batch.packaging.reconcile", ondelete="cascade"
|
||||||
|
)
|
||||||
|
batch_product_id = fields.Many2one("sale.order.batch.product", required=True)
|
||||||
|
product_id = fields.Many2one(
|
||||||
|
related="batch_product_id.product_id", string="Product"
|
||||||
|
)
|
||||||
|
open_packaging_qty = fields.Float(
|
||||||
|
related="batch_product_id.open_packaging_qty",
|
||||||
|
string="Open Packaging Qty",
|
||||||
|
digits=[12, 3],
|
||||||
|
)
|
||||||
|
use_whole_units = fields.Boolean(
|
||||||
|
string="Round to 1",
|
||||||
|
compute="_compute_use_whole_units",
|
||||||
|
precompute=True,
|
||||||
|
store=True,
|
||||||
|
readonly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends("batch_product_id.product_id.uom_id.reconcile_whole_units")
|
||||||
|
def _compute_use_whole_units(self):
|
||||||
|
for line in self:
|
||||||
|
line.use_whole_units = (
|
||||||
|
line.batch_product_id.product_id.uom_id.reconcile_whole_units
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderBatchPackagingReconcileZeroLine(models.TransientModel):
|
||||||
|
_name = "sale.order.batch.packaging.reconcile.zero.line"
|
||||||
|
_description = "Batch Packaging Auto-Reconcile Zero-Out Line"
|
||||||
|
|
||||||
|
wizard_id = fields.Many2one(
|
||||||
|
"sale.order.batch.packaging.reconcile", ondelete="cascade"
|
||||||
|
)
|
||||||
|
batch_product_id = fields.Many2one("sale.order.batch.product", required=True)
|
||||||
|
product_id = fields.Many2one(
|
||||||
|
related="batch_product_id.product_id", string="Product"
|
||||||
|
)
|
||||||
|
open_packaging_qty = fields.Float(
|
||||||
|
related="batch_product_id.open_packaging_qty",
|
||||||
|
string="Open Packaging Qty",
|
||||||
|
digits=[12, 3],
|
||||||
|
)
|
||||||
|
keep_qty = fields.Boolean(string="Keep", default=False)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_sale_order_batch_packaging_reconcile_form" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.batch.packaging.reconcile.form</field>
|
||||||
|
<field name="model">sale.order.batch.packaging.reconcile</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Fill Packages">
|
||||||
|
<sheet>
|
||||||
|
<notebook>
|
||||||
|
<page string="Fill Packages" name="fill_packages">
|
||||||
|
<field name="line_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="product_id" readonly="1"/>
|
||||||
|
<field name="open_packaging_qty" readonly="1"/>
|
||||||
|
<field name="use_whole_units"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Zero Out" name="zero_out">
|
||||||
|
<field name="zero_line_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="product_id" readonly="1"/>
|
||||||
|
<field name="open_packaging_qty" readonly="1"/>
|
||||||
|
<field name="keep_qty"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_reconcile" type="object" string="Reconcile" class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -6,12 +6,20 @@
|
||||||
<field name="inherit_id" ref="sale_order_batch.view_order_batch_form"/>
|
<field name="inherit_id" ref="sale_order_batch.view_order_batch_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="has_fillable_packages" invisible="1"/>
|
||||||
<button
|
<button
|
||||||
name="export_wholesaler_csv"
|
name="export_wholesaler_csv"
|
||||||
type="object"
|
type="object"
|
||||||
string="Export Wholesaler CSV"
|
string="Export Wholesaler CSV"
|
||||||
class="oe_highlight"
|
class="oe_highlight"
|
||||||
attrs="{'invisible': [('state', 'not in', ['in_progress','closed'])]}"
|
attrs="{'invisible': ['|', ('state', 'not in', ['in_progress', 'closed']), ('has_fillable_packages', '=', True)]}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="export_wholesaler_csv"
|
||||||
|
type="object"
|
||||||
|
string="Export Wholesaler CSV"
|
||||||
|
class="btn-secondary"
|
||||||
|
attrs="{'invisible': ['|', ('state', 'not in', ['in_progress', 'closed']), ('has_fillable_packages', '=', False)]}"
|
||||||
/>
|
/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue