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 @@
+
+
+
+
(open_packaging_qty != 0)
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nfu_sale_order_batch_packaging/wizard/__init__.py b/nfu_sale_order_batch_packaging/wizard/__init__.py
new file mode 100644
index 0000000..f468d6b
--- /dev/null
+++ b/nfu_sale_order_batch_packaging/wizard/__init__.py
@@ -0,0 +1 @@
+from . import sale_order_batch_packaging_reconcile
diff --git a/nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile.py b/nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile.py
new file mode 100644
index 0000000..5730cdd
--- /dev/null
+++ b/nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile.py
@@ -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)
diff --git a/nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile_views.xml b/nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile_views.xml
new file mode 100644
index 0000000..a86a65d
--- /dev/null
+++ b/nfu_sale_order_batch_packaging/wizard/sale_order_batch_packaging_reconcile_views.xml
@@ -0,0 +1,37 @@
+
+
+
+ sale.order.batch.packaging.reconcile.form
+ sale.order.batch.packaging.reconcile
+
+
+
+
+
diff --git a/nfu_wholesaler_exporter/views/sale_order_batch_views.xml b/nfu_wholesaler_exporter/views/sale_order_batch_views.xml
index 8c0308d..7190489 100644
--- a/nfu_wholesaler_exporter/views/sale_order_batch_views.xml
+++ b/nfu_wholesaler_exporter/views/sale_order_batch_views.xml
@@ -6,12 +6,20 @@
+
+