From dd2acc2d090ebf5753da2134b9d26c4ba78c8e10 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Wed, 17 Jun 2026 09:19:21 +0200 Subject: [PATCH] CN/DN commission --- .../account_invoice/invoice_ict_final.fodt | 2 +- modules/purchase/purchase.py | 3 + modules/purchase_trade/fee.py | 72 +++++++++-- modules/purchase_trade/tests/test_module.py | 114 +++++++++++++++++- 4 files changed, 182 insertions(+), 9 deletions(-) diff --git a/modules/account_invoice/invoice_ict_final.fodt b/modules/account_invoice/invoice_ict_final.fodt index 04e849a..b207c7a 100644 --- a/modules/account_invoice/invoice_ict_final.fodt +++ b/modules/account_invoice/invoice_ict_final.fodt @@ -4104,7 +4104,7 @@ <invoice.currency.name> - <format_currency(invoice.total_amount, invoice.party.lang, invoice.currency)> + <format_number(invoice.total_amount, invoice.party.lang, digits=invoice.currency.digits)> diff --git a/modules/purchase/purchase.py b/modules/purchase/purchase.py index 39b356a..4a02efe 100755 --- a/modules/purchase/purchase.py +++ b/modules/purchase/purchase.py @@ -1915,8 +1915,11 @@ class Line(sequence_ordered(), ModelSQL, ModelView): fee = fee[0] invoice_line.fee = fee + fee.warn_percent_price_partial_lots() if fee.mode == 'lumpsum': invoice_line.quantity = 1 + elif fee.mode == 'pprice': + invoice_line.quantity = 1 elif fee.mode == 'ppack': invoice_line.quantity = fee.quantity else: diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 90f107a..7536eb9 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -383,6 +383,7 @@ class Fee(ModelSQL,ModelView): "create a debit note or credit note by reversing the " "previous fee invoice line and adding a new line with " "the current fee values. Do you want to continue?") + fee.warn_percent_price_partial_lots() fee.ensure_ordered_purchase() if fee.purchase: fl = FeeLots.search([('fee','=',fee.id)]) @@ -498,6 +499,10 @@ class Fee(ModelSQL,ModelView): FeeLots = Pool().get('fee.lots') fee_lots = FeeLots.search([('fee', '=', self.id)]) lots = [fee_lot.lot for fee_lot in fee_lots if fee_lot.lot] + return self._select_effective_lots(lots) + + @staticmethod + def _select_effective_lots(lots): physical_lots = [ lot for lot in lots if getattr(lot, 'lot_type', None) == 'physic' @@ -509,6 +514,42 @@ class Fee(ModelSQL,ModelView): if getattr(lot, 'lot_type', None) == 'virtual' ] + @staticmethod + def _record_key(record): + return getattr(record, 'id', None) or id(record) + + def _get_effective_line_lots(self): + line = self.line or self.sale_line + return self._select_effective_lots( + list(getattr(line, 'lots', []) or [])) + + def percent_price_lots_cover_line(self): + if self.mode != 'pprice': + return True + line_lots = self._get_effective_line_lots() + if not line_lots: + return True + fee_lots = self._get_effective_fee_lots() + fee_lot_keys = {self._record_key(lot) for lot in fee_lots} + return all( + self._record_key(lot) in fee_lot_keys + for lot in line_lots) + + def warn_percent_price_partial_lots(self): + if self.percent_price_lots_cover_line(): + return + Warning = Pool().get('res.user.warning') + warning_name = Warning.format( + "Partial % price fee lots", [self]) + if Warning.check(warning_name): + raise UserWarning( + warning_name, + "This % price fee is linked to only part of the line lots. " + "The commission cannot be safely computed from a partial " + "quantity because lot quantities may change through BL, WR " + "or LR weighing states. Please link all lots to the fee or " + "review the fee amount manually before continuing.") + def is_effective_fee_lot(self, lot): return bool(lot and lot in self._get_effective_fee_lots()) @@ -713,8 +754,16 @@ class Fee(ModelSQL,ModelView): return self._unsigned_amount(round((self.quantity if self.quantity else 0) * self.price * sign,2)) elif self.mode == 'pprice': if self.line: + if self.percent_price_lots_cover_line(): + return self._unsigned_amount(round( + self.price / 100 + * Decimal(self.line.amount or 0) * sign, 2)) return self._unsigned_amount(round(self.price / 100 * self.line.unit_price * (self.quantity if self.quantity else 0) * sign,2)) if self.sale_line: + if self.percent_price_lots_cover_line(): + return self._unsigned_amount(round( + self.price / 100 + * Decimal(self.sale_line.amount or 0) * sign, 2)) return self._unsigned_amount(round(self.price / 100 * self.sale_line.unit_price * (self.quantity if self.quantity else 0) * sign,2)) if self.shipment_in: StockMove = Pool().get('stock.move') @@ -761,13 +810,18 @@ class Fee(ModelSQL,ModelView): if self.mode == 'lumpsum': if self.amount != self.purchase.lines[0].unit_price: self.purchase.lines[0].unit_price = self.amount - elif self.mode == 'ppack': - if self.amount != self.purchase.lines[0].amount: - self.purchase.lines[0].unit_price = self.price - self.purchase.lines[0].quantity = self.quantity - else: - if self.get_price_per_qt() != self.purchase.lines[0].unit_price: - self.purchase.lines[0].unit_price = self.get_price_per_qt() + elif self.mode == 'ppack': + if self.amount != self.purchase.lines[0].amount: + self.purchase.lines[0].unit_price = self.price + self.purchase.lines[0].quantity = self.quantity + elif self.mode == 'pprice': + if self.amount != self.purchase.lines[0].unit_price: + self.purchase.lines[0].unit_price = self.amount + if self.purchase.lines[0].quantity != 1: + self.purchase.lines[0].quantity = 1 + else: + if self.get_price_per_qt() != self.purchase.lines[0].unit_price: + self.purchase.lines[0].unit_price = self.get_price_per_qt() if self.quantity != self.purchase.lines[0].quantity: self.purchase.lines[0].quantity = self.quantity if self.product != self.purchase.lines[0].product: @@ -912,6 +966,10 @@ class Fee(ModelSQL,ModelView): line.quantity = round(quantity, 5) line.unit = unit line.fee_ = self.id + if self.mode == 'pprice': + line.quantity = 1 + line.unit_price = round(Decimal(self.amount or 0), 4) + return if getattr(self, 'price', None) is not None: fee_price = self.get_price_per_qt() logger.info("GET_FEE_PRICE_PER_QT:%s", fee_price) diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index bfcde7a..4e1107b 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -11,7 +11,7 @@ from xml.etree import ElementTree from trytond.pool import Pool from trytond.pyson import Eval from trytond.tests.test_tryton import ModuleTestCase, with_transaction -from trytond.exceptions import UserError +from trytond.exceptions import UserError, UserWarning from trytond.transaction import Transaction from trytond.modules.purchase import purchase as base_purchase_module from trytond.modules.purchase_trade import valuation as valuation_module @@ -2690,6 +2690,118 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( line._get_service_fee_invoice_lines(invoice_line, lot), []) + def test_purchase_service_percent_price_fee_invoice_uses_amount_line(self): + '% price service fee invoice uses a single amount line' + Line = base_purchase_module.Line + line = Line() + line.purchase = Mock(id=20) + lot = Mock(id=30) + invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('50')) + fee = Mock( + state='not invoiced', + mode='pprice', + warn_percent_price_partial_lots=Mock()) + fee_model = Mock() + fee_model.search.return_value = [fee] + + with patch( + 'trytond.modules.purchase.purchase.Pool' + ) as PoolMock: + PoolMock.return_value.get.side_effect = lambda name: { + 'fee.fee': fee_model, + }[name] + + result = line._get_service_fee_invoice_lines(invoice_line, lot) + + self.assertEqual(result, [invoice_line]) + self.assertEqual(invoice_line.fee, fee) + self.assertEqual(invoice_line.quantity, 1) + self.assertEqual(invoice_line.unit_price, Decimal('50')) + fee.warn_percent_price_partial_lots.assert_called_once_with() + + def test_percent_price_fee_amount_uses_full_line_amount(self): + '% price fee ignores quantity when all line lots are linked' + Fee = Pool().get('fee.fee') + fee = Fee() + lot = SimpleNamespace(id=1, lot_type='physic') + fee.mode = 'pprice' + fee.price = Decimal('2') + fee.quantity = Decimal('999') + fee.sale_line = None + fee.shipment_in = None + fee.line = SimpleNamespace( + amount=Decimal('1000'), + unit_price=Decimal('10'), + lots=[lot]) + + with patch.object( + fee, '_get_effective_fee_lots', return_value=[lot]): + self.assertEqual(fee.get_amount(), Decimal('20.00')) + + def test_percent_price_fee_warns_when_line_lots_are_partial(self): + '% price fee warns when it does not cover all line lots' + Fee = Pool().get('fee.fee') + fee = Fee() + lot_1 = SimpleNamespace(id=1, lot_type='physic') + lot_2 = SimpleNamespace(id=2, lot_type='physic') + fee.mode = 'pprice' + fee.line = SimpleNamespace(lots=[lot_1, lot_2]) + fee.sale_line = None + warning = Mock() + warning.format.return_value = 'partial-pprice-fee' + warning.check.return_value = True + + with patch( + 'trytond.modules.purchase_trade.fee.Pool' + ) as PoolMock, patch.object( + fee, '_get_effective_fee_lots', return_value=[lot_1]): + PoolMock.return_value.get.side_effect = lambda name: { + 'res.user.warning': warning, + }[name] + + with self.assertRaises(UserWarning): + fee.warn_percent_price_partial_lots() + + warning.format.assert_called_once_with( + "Partial % price fee lots", [fee]) + warning.check.assert_called_once_with('partial-pprice-fee') + + def test_percent_price_fee_adjusts_purchase_to_amount_line(self): + '% price fee keeps generated purchase as one amount line' + Fee = Pool().get('fee.fee') + fee = Fee() + fee.type = 'ordered' + fee.mode = 'pprice' + fee.amount = Decimal('20') + fee.product = Mock() + fee.supplier = Mock() + fee.currency = Mock() + purchase_line = Mock( + unit_price=Decimal('10'), + quantity=Decimal('999'), + product=fee.product) + fee.purchase = Mock( + lines=[purchase_line], + party=fee.supplier, + currency=fee.currency) + purchase_model = Mock() + purchase_line_model = Mock() + + with patch( + 'trytond.modules.purchase_trade.fee.Pool' + ) as PoolMock: + PoolMock.return_value.get.side_effect = lambda name: { + 'purchase.purchase': purchase_model, + 'purchase.line': purchase_line_model, + }[name] + + fee.adjust_purchase_values() + + self.assertEqual(purchase_line.unit_price, Decimal('20')) + self.assertEqual(purchase_line.quantity, 1) + purchase_line_model.save.assert_called_once_with([purchase_line]) + purchase_model.save.assert_called_once_with([fee.purchase]) + def test_fee_get_non_cog_returns_zero_without_move_lines(self): 'fee non-cog amount is zero before any accounting move line exists' fee = fee_module.Fee()