diff --git a/modules/account_invoice/.~lock.invoice_melya.fodt# b/modules/account_invoice/.~lock.invoice_melya.fodt# deleted file mode 100644 index 48c5307..0000000 --- a/modules/account_invoice/.~lock.invoice_melya.fodt# +++ /dev/null @@ -1 +0,0 @@ -,PC_LO/baron,PC_LO,28.05.2026 13:23,file:///C:/Users/baron/AppData/Roaming/LibreOffice/4; \ No newline at end of file diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index 8384a52..995673a 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -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, diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 8706284..4c137c5 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -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() diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index 85ade7d..26995e8 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -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) diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 06ae139..393bede 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -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): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index c80fd6c..7b50eac 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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')