From d87b366d41ca41b4370a61ba773a70cdb4c0dd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20G=C3=B6ttsch?= Date: Wed, 27 May 2026 12:46:43 +0200 Subject: [PATCH] [WIP] --- nfu_sale_order_batch_packaging/__init__.py | 17 + .../__manifest__.py | 4 + .../data/uom_data.xml | 15 + .../models/__init__.py | 2 + .../models/sale_order_batch.py | 64 ++++ .../models/sale_order_batch_product.py | 59 +++ .../models/uom_uom.py | 11 + .../security/ir.model.access.csv | 4 + .../tests/__init__.py | 1 + .../tests/test_batch_packaging_reconcile.py | 355 ++++++++++++++++++ .../views/sale_order_batch_product_views.xml | 9 + .../views/sale_order_batch_views.xml | 36 ++ .../wizard/__init__.py | 1 + .../sale_order_batch_packaging_reconcile.py | 71 ++++ ..._order_batch_packaging_reconcile_views.xml | 37 ++ .../views/sale_order_batch_views.xml | 10 +- 16 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 nfu_sale_order_batch_packaging/data/uom_data.xml create mode 100644 nfu_sale_order_batch_packaging/models/sale_order_batch.py create mode 100644 nfu_sale_order_batch_packaging/models/uom_uom.py create mode 100644 nfu_sale_order_batch_packaging/security/ir.model.access.csv create mode 100644 nfu_sale_order_batch_packaging/tests/__init__.py create mode 100644 nfu_sale_order_batch_packaging/tests/test_batch_packaging_reconcile.py create mode 100644 nfu_sale_order_batch_packaging/wizard/__init__.py create mode 100644 nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile.py create mode 100644 nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile_views.xml diff --git a/nfu_sale_order_batch_packaging/__init__.py b/nfu_sale_order_batch_packaging/__init__.py index 0650744..9e16cc0 100644 --- a/nfu_sale_order_batch_packaging/__init__.py +++ b/nfu_sale_order_batch_packaging/__init__.py @@ -1 +1,18 @@ 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}) diff --git a/nfu_sale_order_batch_packaging/__manifest__.py b/nfu_sale_order_batch_packaging/__manifest__.py index f8cd38b..e728f96 100644 --- a/nfu_sale_order_batch_packaging/__manifest__.py +++ b/nfu_sale_order_batch_packaging/__manifest__.py @@ -7,10 +7,14 @@ "version": "16.0.1.3.7", "depends": ["sale", "sale_order_batch"], "data": [ + "security/ir.model.access.csv", "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_batch_views.xml", "views/sale_order_batch_product_views.xml", ], "license": "LGPL-3", + "post_init_hook": "post_init_hook", } diff --git a/nfu_sale_order_batch_packaging/data/uom_data.xml b/nfu_sale_order_batch_packaging/data/uom_data.xml new file mode 100644 index 0000000..7dc0480 --- /dev/null +++ b/nfu_sale_order_batch_packaging/data/uom_data.xml @@ -0,0 +1,15 @@ + + + + True + + + + Stück + + smaller + 0.01 + 1.0 + True + + diff --git a/nfu_sale_order_batch_packaging/models/__init__.py b/nfu_sale_order_batch_packaging/models/__init__.py index b0c450c..46fe0f9 100644 --- a/nfu_sale_order_batch_packaging/models/__init__.py +++ b/nfu_sale_order_batch_packaging/models/__init__.py @@ -1,4 +1,6 @@ from . import product_product from . import product_template +from . import sale_order_batch from . import sale_order_batch_product from . import sale_order_line +from . import uom_uom diff --git a/nfu_sale_order_batch_packaging/models/sale_order_batch.py b/nfu_sale_order_batch_packaging/models/sale_order_batch.py new file mode 100644 index 0000000..56c1ab4 --- /dev/null +++ b/nfu_sale_order_batch_packaging/models/sale_order_batch.py @@ -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", + } diff --git a/nfu_sale_order_batch_packaging/models/sale_order_batch_product.py b/nfu_sale_order_batch_packaging/models/sale_order_batch_product.py index 343c46e..e7cabbd 100644 --- a/nfu_sale_order_batch_packaging/models/sale_order_batch_product.py +++ b/nfu_sale_order_batch_packaging/models/sale_order_batch_product.py @@ -1,4 +1,5 @@ from odoo import api, fields, models +from odoo.tools import float_is_zero, float_round PACKAGING_STATES = [ @@ -80,6 +81,64 @@ class SaleOrderBatchProduct(models.Model): else: 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 def create(self, vals_list): for vals in vals_list: diff --git a/nfu_sale_order_batch_packaging/models/uom_uom.py b/nfu_sale_order_batch_packaging/models/uom_uom.py new file mode 100644 index 0000000..bf9d5d5 --- /dev/null +++ b/nfu_sale_order_batch_packaging/models/uom_uom.py @@ -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.", + ) diff --git a/nfu_sale_order_batch_packaging/security/ir.model.access.csv b/nfu_sale_order_batch_packaging/security/ir.model.access.csv new file mode 100644 index 0000000..3f4b2f2 --- /dev/null +++ b/nfu_sale_order_batch_packaging/security/ir.model.access.csv @@ -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 diff --git a/nfu_sale_order_batch_packaging/tests/__init__.py b/nfu_sale_order_batch_packaging/tests/__init__.py new file mode 100644 index 0000000..4bde748 --- /dev/null +++ b/nfu_sale_order_batch_packaging/tests/__init__.py @@ -0,0 +1 @@ +from . import test_batch_packaging_reconcile diff --git a/nfu_sale_order_batch_packaging/tests/test_batch_packaging_reconcile.py b/nfu_sale_order_batch_packaging/tests/test_batch_packaging_reconcile.py new file mode 100644 index 0000000..2b5d253 --- /dev/null +++ b/nfu_sale_order_batch_packaging/tests/test_batch_packaging_reconcile.py @@ -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 + ) diff --git a/nfu_sale_order_batch_packaging/views/sale_order_batch_product_views.xml b/nfu_sale_order_batch_packaging/views/sale_order_batch_product_views.xml index 3f01f0f..d65b6e2 100644 --- a/nfu_sale_order_batch_packaging/views/sale_order_batch_product_views.xml +++ b/nfu_sale_order_batch_packaging/views/sale_order_batch_product_views.xml @@ -15,6 +15,15 @@ + + +