ICT bug padding + template

This commit is contained in:
2026-05-28 20:12:39 +02:00
parent c9028f1590
commit 2428978d90
6 changed files with 314 additions and 32 deletions

View File

@@ -1 +0,0 @@
,PC_LO/baron,PC_LO,28.05.2026 13:23,file:///C:/Users/baron/AppData/Roaming/LibreOffice/4;

View File

@@ -230,12 +230,13 @@ def register():
Pool.register(
account.PhysicalTradeIFRS,
module='purchase_trade', type_='model')
Pool.register(
configuration.AccountConfiguration,
configuration.AccountConfigurationDefaultAccount,
invoice.InvoicePaddingReport,
invoice.InvoicePaddingContext,
module='purchase_trade', type_='model')
Pool.register(
configuration.AccountConfiguration,
configuration.AccountConfigurationDefaultAccount,
invoice.InvoiceLineLotWeight,
invoice.InvoicePaddingReport,
invoice.InvoicePaddingContext,
module='purchase_trade', type_='model')
Pool.register(
invoice.Invoice,
invoice.InvoiceLine,

View File

@@ -444,6 +444,9 @@ class Fee(ModelSQL,ModelView):
return sum(quantities)
def sync_quantity_from_lots(self):
if self.mode == 'ppack' and self.auto_calculation:
self.assert_quantity_consistency()
return
quantity = self._get_effective_fee_lots_quantity()
if quantity is None:
self.assert_quantity_consistency()

View File

@@ -17,13 +17,106 @@ from trytond.modules.purchase.purchase import (
PurchaseReport as BasePurchaseReport)
class InvoiceLineLotWeight(ModelSQL, ModelView):
"Invoice Line Lot Weight"
__name__ = 'account.invoice.line.lot.weight'
invoice_line = fields.Many2One(
'account.invoice.line', "Invoice Line", required=True,
ondelete='CASCADE')
lot = fields.Many2One('lot.lot', "Lot", required=True, ondelete='CASCADE')
lot_qt_hist = fields.Many2One('lot.qt.hist', "Quantity History")
quantity_type = fields.Many2One('lot.qt.type', "Quantity Type")
net_quantity = fields.Numeric("Net Weight", digits=(1, 5), required=True)
gross_quantity = fields.Numeric(
"Gross Weight", digits=(1, 5), required=True)
unit = fields.Many2One('product.uom', "Unit")
lot_qt = fields.Integer("Packing Quantity")
lot_unit = fields.Many2One('product.uom', "Packing Unit")
@classmethod
def _get_lot_hist_entry(cls, lot):
state = getattr(lot, 'lot_state', None)
state_id = getattr(state, 'id', None)
hist = list(getattr(lot, 'lot_hist', []) or [])
if state_id is not None:
for entry in hist:
quantity_type = getattr(entry, 'quantity_type', None)
if getattr(quantity_type, 'id', None) == state_id:
return entry
if len(hist) == 1:
return hist[0]
return None
@classmethod
def _get_lot_weights(cls, lot):
hist = cls._get_lot_hist_entry(lot)
if hist:
net = Decimal(str(getattr(hist, 'quantity', 0) or 0))
gross = Decimal(str(
getattr(hist, 'gross_quantity', None)
if getattr(hist, 'gross_quantity', None) not in (None, '')
else net))
return hist, net, gross
if hasattr(lot, 'get_hist_quantity'):
net, gross = lot.get_hist_quantity()
net = Decimal(str(net or 0))
gross = Decimal(str(gross if gross not in (None, '') else net))
return None, net, gross
net = Decimal(str(getattr(lot, 'lot_quantity', 0) or 0))
gross = Decimal(str(
getattr(lot, 'lot_gross_quantity', None)
if getattr(lot, 'lot_gross_quantity', None) not in (None, '')
else net))
return None, net, gross
@classmethod
def create_for_invoice_line(cls, line, lot):
if not getattr(line, 'id', None) or not getattr(lot, 'id', None):
return
existing = cls.search([
('invoice_line', '=', line.id),
('lot', '=', lot.id),
])
if existing:
cls.delete(existing)
hist, net, gross = cls._get_lot_weights(lot)
snapshot = cls()
snapshot.invoice_line = line
snapshot.lot = lot
snapshot.lot_qt_hist = hist
snapshot.quantity_type = (
getattr(hist, 'quantity_type', None)
or getattr(lot, 'lot_state', None))
snapshot.net_quantity = net
snapshot.gross_quantity = gross
snapshot.unit = (
getattr(lot, 'lot_unit_line', None)
or getattr(lot, 'lot_unit', None)
or getattr(line, 'unit', None))
snapshot.lot_qt = getattr(lot, 'lot_qt', None)
snapshot.lot_unit = getattr(lot, 'lot_unit', None)
cls.save([snapshot])
class Invoice(metaclass=PoolMeta):
__name__ = 'account.invoice'
def do_lot_invoicing(self):
super().do_lot_invoicing()
self._create_lot_weight_snapshots()
self._create_sale_padding_moves()
def _create_lot_weight_snapshots(self):
Snapshot = Pool().get('account.invoice.line.lot.weight')
Lot = Pool().get('lot.lot')
for line in self._get_report_invoice_lines():
if (getattr(line, 'description', None) != 'Pro forma'
or Decimal(str(getattr(line, 'quantity', 0) or 0)) <= 0
or not getattr(line, 'lot', None)):
continue
Snapshot.create_for_invoice_line(line, Lot(line.lot.id))
@classmethod
def _post(cls, invoices):
pool = Pool()
@@ -292,6 +385,20 @@ class Invoice(metaclass=PoolMeta):
return None, None
def _get_report_invoice_line_weights(self, line):
snapshots = self._get_report_invoice_line_weight_snapshots(line)
if snapshots:
sign = self._get_report_line_sign(line)
net_total = sum(
Decimal(str(snapshot.net_quantity or 0))
for snapshot in snapshots)
gross_total = sum(
Decimal(str(snapshot.gross_quantity or 0))
for snapshot in snapshots)
unit = self._get_report_invoice_line_unit(line)
padding = self._get_report_invoice_line_padding(line, unit)
net_total += padding
gross_total += padding
return net_total * sign, gross_total * sign
lots = self._get_report_preferred_lots(line)
if lots and self._report_invoice_line_reuses_lot(line):
quantity = self._get_report_invoice_line_quantity_from_line(line)
@@ -311,6 +418,33 @@ class Invoice(metaclass=PoolMeta):
quantity = Decimal(str(getattr(line, 'quantity', 0) or 0))
return quantity, quantity
@staticmethod
def _get_report_invoice_line_weight_snapshots(line):
snapshots = getattr(line, 'lot_weight_snapshots', None)
if snapshots and isinstance(snapshots, (list, tuple)):
return list(snapshots)
line_id = getattr(line, 'id', None)
if not isinstance(line_id, int):
return []
Snapshot = Pool().get('account.invoice.line.lot.weight')
return Snapshot.search([('invoice_line', '=', line_id)])
@classmethod
def _get_report_invoice_line_padding(cls, line, to_unit):
if getattr(line, 'description', None) != 'Pro forma':
return Decimal(0)
invoice_type = getattr(line, 'invoice_type', None)
invoice = getattr(line, 'invoice', None)
if invoice_type != 'out' and getattr(invoice, 'type', None) != 'out':
return Decimal(0)
lot = getattr(line, 'lot', None)
padding = Decimal(str(
getattr(lot, 'sale_invoice_padding', 0) or 0))
if not padding:
return Decimal(0)
return cls._convert_report_quantity(
padding, getattr(line, 'unit', None), to_unit)
@staticmethod
def _get_report_line_lot_keys(line):
keys = []
@@ -416,6 +550,9 @@ class Invoice(metaclass=PoolMeta):
@staticmethod
def _get_report_invoice_line_unit(line):
snapshots = Invoice._get_report_invoice_line_weight_snapshots(line)
if snapshots and getattr(snapshots[0], 'unit', None):
return snapshots[0].unit
lots = Invoice._get_report_preferred_lots(line)
if lots and getattr(lots[0], 'lot_unit_line', None):
return lots[0].lot_unit_line
@@ -1556,6 +1693,10 @@ class InvoicePaddingContext(ModelView):
class InvoiceLine(metaclass=PoolMeta):
__name__ = 'account.invoice.line'
lot_weight_snapshots = fields.One2Many(
'account.invoice.line.lot.weight', 'invoice_line',
"Lot Weight Snapshots")
def _get_report_trade(self):
origin = getattr(self, 'origin', None)
if not origin:
@@ -1648,6 +1789,15 @@ class InvoiceLine(metaclass=PoolMeta):
def report_net(self):
if self.type == 'line':
invoice = getattr(self, 'invoice', None)
snapshots = Invoice._get_report_invoice_line_weight_snapshots(self)
if snapshots:
sign = Invoice._get_report_line_sign(self)
net = sum(
Decimal(str(snapshot.net_quantity or 0))
for snapshot in snapshots)
unit = Invoice._get_report_invoice_line_unit(self)
net += Invoice._get_report_invoice_line_padding(self, unit)
return net * sign
if invoice and invoice._report_invoice_line_reuses_lot(self):
return Invoice._get_report_invoice_line_quantity_from_line(self)
lot = getattr(self, 'lot', None)

View File

@@ -132,31 +132,37 @@ class Lot(metaclass=PoolMeta):
"more than one virtual lot exists",
[('virtual_lots', len(virtual_lots))]))
vlot = virtual_lots[0]
physical_quantity = sum(
cls._get_lot_current_quantity_for_consistency(lot, unit)
for lot in physical_lots)
virtual_quantity = cls._get_lot_current_quantity_for_consistency(
vlot, unit)
theorical_quantity = Decimal(str(theorical or 0))
expected_diff = (
cls._round_consistency_quantity(physical_quantity)
+ cls._round_consistency_quantity(virtual_quantity)
- cls._round_consistency_quantity(theorical_quantity))
if expected_diff:
raise UserError(
cls._format_consistency_error(
line,
"physical lots + virtual lot must equal theoretical "
"quantity",
[
('physical', physical_quantity),
('virtual', virtual_quantity),
('theoretical', theorical_quantity),
('diff', expected_diff),
]))
cls._assert_virtual_lot_open_quantity_consistency(vlot, line)
# Temporarily suspended: repesages successifs are no longer tied
# reliably to the original lot.qt forecast split. We keep the helper
# calls in place, but do not enforce the two historical invariants:
# 1) physical lots + virtual lot must equal quantity_theorical
# 2) sum(non-zero lot.qt) must cover the positive virtual lot quantity
#
# vlot = virtual_lots[0]
# physical_quantity = sum(
# cls._get_lot_current_quantity_for_consistency(lot, unit)
# for lot in physical_lots)
# virtual_quantity = cls._get_lot_current_quantity_for_consistency(
# vlot, unit)
# theorical_quantity = Decimal(str(theorical or 0))
# expected_diff = (
# cls._round_consistency_quantity(physical_quantity)
# + cls._round_consistency_quantity(virtual_quantity)
# - cls._round_consistency_quantity(theorical_quantity))
# if expected_diff:
# raise UserError(
# cls._format_consistency_error(
# line,
# "physical lots + virtual lot must equal theoretical "
# "quantity",
# [
# ('physical', physical_quantity),
# ('virtual', virtual_quantity),
# ('theoretical', theorical_quantity),
# ('diff', expected_diff),
# ]))
#
# cls._assert_virtual_lot_open_quantity_consistency(vlot, line)
@classmethod
def _assert_virtual_lot_open_quantity_consistency(cls, vlot, line=None):

View File

@@ -355,6 +355,29 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(fee.quantity, Decimal('40.00000'))
save.assert_called_once_with([fee])
def test_fee_ppack_auto_sync_keeps_auto_quantity(self):
'per packing auto fee keeps the on-change quantity on save'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 1
fee.mode = 'ppack'
fee.auto_calculation = True
fee.quantity = Decimal('3')
fee.unit = Mock()
lot = Mock(lot_type='physic', lot_qt=Decimal('990'))
fee_lots = Mock()
fee_lots.search.return_value = [Mock(lot=lot)]
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(Fee, 'save') as save:
PoolMock.return_value.get.return_value = fee_lots
fee.sync_quantity_from_lots()
self.assertEqual(fee.quantity, Decimal('3'))
save.assert_not_called()
def test_fee_pnl_regeneration_uses_shipment_lotqt_purchase_line(self):
'fee pnl regeneration follows shipment lot.qt back to purchase line'
Fee = Pool().get('fee.fee')
@@ -4206,6 +4229,106 @@ class PurchaseTradeTestCase(ModuleTestCase):
invoice.report_quantity_lines,
'950.00 LBS (950.00 LBS)')
def test_invoice_report_weights_prefer_invoice_line_snapshot(self):
'invoice report weights use the invoicing snapshot when available'
Invoice = Pool().get('account.invoice')
unit = Mock(rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit)
lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
snapshot = Mock(
net_quantity=Decimal('900'),
gross_quantity=Decimal('920'),
unit=unit,
)
line = Mock(
type='line',
quantity=Decimal('1000'),
lot=lot,
unit=Mock(rec_name='MT'),
lot_weight_snapshots=[snapshot],
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('900'))
self.assertEqual(invoice.report_gross, Decimal('920'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
self.assertEqual(
invoice.report_quantity_lines,
'900.00 LBS (900.00 LBS)')
def test_invoice_report_proforma_snapshot_adds_sale_padding(self):
'ICT provisional report displays snapshot weights plus sale padding'
Invoice = Pool().get('account.invoice')
unit = Mock(id=1, rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit, sale_invoice_padding=Decimal('10'))
snapshot = Mock(
net_quantity=Decimal('900'),
gross_quantity=Decimal('920'),
unit=unit,
)
line = Mock(
type='line',
description='Pro forma',
invoice_type='out',
quantity=Decimal('1010'),
lot=lot,
unit=unit,
lot_weight_snapshots=[snapshot],
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('910'))
self.assertEqual(invoice.report_gross, Decimal('930'))
self.assertEqual(
invoice.report_quantity_lines,
'910.00 LBS (910.00 LBS)')
def test_invoice_validation_creates_weight_snapshot_for_proforma_lines(self):
'invoice validation snapshots lot weights for pro forma invoice lines'
Invoice = Pool().get('account.invoice')
lot_ref = Mock(id=10)
lot = Mock(id=10)
proforma = Mock(
type='line',
description='Pro forma',
quantity=Decimal('1000'),
lot=lot_ref,
)
final = Mock(
type='line',
description='Final',
quantity=Decimal('1000'),
lot=lot_ref,
)
invoice = Invoice()
invoice.lines = [proforma, final]
snapshot_model = Mock()
lot_model = Mock(return_value=lot)
def get_model(name):
if name == 'account.invoice.line.lot.weight':
return snapshot_model
if name == 'lot.lot':
return lot_model
raise AssertionError(name)
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = get_model
invoice._create_lot_weight_snapshots()
snapshot_model.create_for_invoice_line.assert_called_once_with(
proforma, lot)
def test_invoice_report_lbs_converts_kilogram_to_lbs(self):
'invoice lbs helper converts kilogram quantities with the proper uom ratio'
Invoice = Pool().get('account.invoice')