This commit is contained in:
Niels Göttsch 2026-05-27 12:46:43 +02:00
parent f913eac312
commit a9ade6a690
No known key found for this signature in database
GPG key ID: D36A5892D98A60AD
16 changed files with 695 additions and 1 deletions

View file

@ -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})

View file

@ -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",
} }

View 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>

View file

@ -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

View 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",
}

View file

@ -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:

View 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.",
)

View file

@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_batch_packaging_reconcile sale.order.batch.packaging.reconcile model_sale_order_batch_packaging_reconcile base.group_user 1 1 1 1
3 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
4 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

View file

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

View file

@ -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
)

View file

@ -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"

View file

@ -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>

View file

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

View file

@ -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)

View file

@ -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>

View file

@ -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>