From ee1cb3e7fb85b9f493898caf57939fb13150fd0d Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 16 Jun 2026 23:47:35 +0200 Subject: [PATCH] Fee CN/DN --- modules/account_invoice/invoice.py | 10 +- modules/purchase/purchase.py | 105 ++++++++++++++++---- modules/purchase_trade/fee.py | 66 ++++++++---- modules/purchase_trade/tests/test_module.py | 83 ++++++++++++++++ 4 files changed, 225 insertions(+), 39 deletions(-) diff --git a/modules/account_invoice/invoice.py b/modules/account_invoice/invoice.py index ef45aa0..466ab0a 100755 --- a/modules/account_invoice/invoice.py +++ b/modules/account_invoice/invoice.py @@ -2057,10 +2057,16 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): var_qt = sum([i.quantity for i in gl]) logger.info("LOT_TO_PROCESS:%s",lot) logger.info("FEE_TO_PROCESS:%s",gl[0].fee) - if (gl[0].fee and not gl[0].product.landed_cost): + fee_correction = ( + gl[0].fee + and any(Decimal(str(i.quantity or 0)) < 0 for i in gl) + and any(Decimal(str(i.quantity or 0)) > 0 for i in gl)) + if (gl[0].fee and not gl[0].product.landed_cost + and not fee_correction): diff = gl[0].fee.amount - gl[0].fee.get_non_cog(lot) account_move = gl[0].fee._get_account_move_fee(lot,'in',diff) - Move.save([account_move]) + if account_move: + Move.save([account_move]) if (lot and not gl[0].fee) or (gl[0].fee and gl[0].product.landed_cost): adjust_move_lines = [] mov = None diff --git a/modules/purchase/purchase.py b/modules/purchase/purchase.py index 951292c..4f9cd0f 100755 --- a/modules/purchase/purchase.py +++ b/modules/purchase/purchase.py @@ -1016,16 +1016,18 @@ class Purchase( for purchase in purchases: logger.info("PROCESS_INVOICE:%s",action) invoice = purchase.create_invoice(lots,action) + if not invoice: + continue if action == 'prov': invoice.reference = 'Provisional' elif action == 'service': invoice.reference = 'Service' else: invoice.reference = 'Final' - if invoice: - invoices[purchase] = invoice + invoices[purchase] = invoice - cls._save_invoice(invoices,prepayment) + if invoices: + cls._save_invoice(invoices,prepayment) @classmethod def _save_invoice(cls, invoices,prepayment=None): @@ -1854,6 +1856,85 @@ class Line(sequence_ordered(), ModelSQL, ModelView): def on_change_with_currency(self, name=None): return self.purchase.currency if self.purchase else None + @staticmethod + def _record_id(record): + return getattr(record, 'id', record) + + @classmethod + def _get_last_fee_invoice_line(cls, fee, lot): + fee_id = cls._record_id(fee) + lot_id = cls._record_id(lot) + if not fee_id or not lot_id: + return + InvoiceLine = Pool().get('account.invoice.line') + lines = InvoiceLine.search([ + ('fee', '=', fee_id), + ('lot', '=', lot_id), + ('quantity', '>', 0), + ('invoice.state', '!=', 'cancelled'), + ], order=[ + ('invoice.invoice_date', 'DESC'), + ('invoice.id', 'DESC'), + ('id', 'DESC'), + ], limit=1) + return lines[0] if lines else None + + @staticmethod + def _fee_invoice_line_changed(previous_line, invoice_line): + if not previous_line: + return True + previous_quantity = Decimal(str(previous_line.quantity or 0)) + quantity = Decimal(str(invoice_line.quantity or 0)) + previous_price = Decimal(str(previous_line.unit_price or 0)) + price = Decimal(str(invoice_line.unit_price or 0)) + return previous_quantity != quantity or previous_price != price + + @classmethod + def _get_fee_reversal_invoice_line(cls, previous_line, origin): + if not previous_line: + return + InvoiceLine = Pool().get('account.invoice.line') + reversal_line, = InvoiceLine.copy([previous_line], default={ + 'invoice': None, + 'quantity': -previous_line.quantity, + 'unit_price': previous_line.unit_price, + 'party': previous_line.invoice.party, + 'origin': str(origin), + }) + return reversal_line + + def _get_service_fee_invoice_lines(self, invoice_line, lot): + Fee = Pool().get('fee.fee') + fee = Fee.search(['purchase','=',self.purchase.id]) + if not fee: + return [invoice_line] + + fee = fee[0] + invoice_line.fee = fee + if fee.mode == 'lumpsum': + invoice_line.quantity = 1 + elif fee.mode == 'ppack': + invoice_line.quantity = fee.quantity + else: + state_id = 0 + LotQtType = Pool().get('lot.qt.type') + lqt = LotQtType.search([('name','=','BL')]) + if lqt: + state_id = lqt[0].id + invoice_line.quantity = fee.get_fee_lots_qt(state_id) + + if getattr(fee, 'state', None) != 'invoiced': + return [invoice_line] + + previous_line = self._get_last_fee_invoice_line(fee, lot) + if not self._fee_invoice_line_changed(previous_line, invoice_line): + return [] + reversal_line = self._get_fee_reversal_invoice_line( + previous_line, self) + if reversal_line: + return [reversal_line, invoice_line] + return [invoice_line] + def get_invoice_line(self,lots=None,action=None): 'Return a list of invoice line for purchase line' pool = Pool() @@ -1933,21 +2014,9 @@ class Line(sequence_ordered(), ModelSQL, ModelView): invoice_line.unit_price = self.unit_price invoice_line.product = self.product invoice_line.stock_moves = [] - Fee = Pool().get('fee.fee') - fee = Fee.search(['purchase','=',self.purchase.id]) - if fee: - invoice_line.fee = fee[0] - if fee[0].mode == 'lumpsum': - invoice_line.quantity = 1 - elif fee[0].mode == 'ppack': - invoice_line.quantity = fee[0].quantity - else: - state_id = 0 - LotQtType = Pool().get('lot.qt.type') - lqt = LotQtType.search([('name','=','BL')]) - if lqt: - state_id = lqt[0].id - invoice_line.quantity = fee[0].get_fee_lots_qt(state_id) + lines.extend( + self._get_service_fee_invoice_lines(invoice_line, l)) + continue lines.append(invoice_line) logger.info("GETINVLINE:%s",self.product.type) diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 434b097..4ae6f3d 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -339,15 +339,12 @@ class Fee(ModelSQL,ModelView): return round(Decimal(sum([e.credit-e.debit for e in ml])),2) return Decimal(0) - @classmethod - def __setup__(cls): - super().__setup__() - cls._buttons.update({ - 'invoice': { - 'invisible': (Eval('state') == 'invoiced'), - 'depends': ['state'], - }, - }) + @classmethod + def __setup__(cls): + super().__setup__() + cls._buttons.update({ + 'invoice': {}, + }) @classmethod def default_state(cls): @@ -368,12 +365,22 @@ class Fee(ModelSQL,ModelView): @classmethod @ModelView.button - @filter_state('not invoiced') def invoice(cls, fees): Purchase = Pool().get('purchase.purchase') FeeLots = Pool().get('fee.lots') + Warning = Pool().get('res.user.warning') fees_to_invoice = [] for fee in fees: + if fee.state == 'invoiced': + warning_name = Warning.format( + "Fee already invoiced", [fee]) + if Warning.check(warning_name): + raise UserWarning( + warning_name, + "This fee has already been invoiced. Continuing will " + "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.ensure_ordered_purchase() if fee.purchase: fl = FeeLots.search([('fee','=',fee.id)]) @@ -420,10 +427,31 @@ class Fee(ModelSQL,ModelView): return round(self.price * Decimal(sm[0].lot.get_lot_price()) / 100,4) return price - def get_invoice(self,name): - if self.purchase: - if self.purchase.invoices: - return self.purchase.invoices[0] + def get_invoice(self,name): + InvoiceLine = Pool().get('account.invoice.line') + invoice_lines = InvoiceLine.search([ + ('fee', '=', self.id), + ('quantity', '>', 0), + ('invoice.state', '!=', 'cancelled'), + ], order=[ + ('invoice.invoice_date', 'DESC'), + ('invoice.id', 'DESC'), + ('id', 'DESC'), + ], limit=1) + if invoice_lines: + return invoice_lines[0].invoice + if self.purchase: + if self.purchase.invoices: + invoices = [ + invoice for invoice in self.purchase.invoices + if getattr(invoice, 'state', None) != 'cancelled'] + if invoices: + return sorted( + invoices, + key=lambda invoice: ( + invoice.invoice_date or datetime.date.min, + invoice.id or 0), + reverse=True)[0] def get_landed_status(self,name): if self.product: @@ -706,11 +734,11 @@ class Fee(ModelSQL,ModelView): Purchase = Pool().get('purchase.purchase') PurchaseLine = Pool().get('purchase.line') logger.info("ADJUST_PURCHASE_VALUES:%s",self) - if self.type == 'ordered' and self.state == 'not invoiced' and self.purchase: - logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity) - if self.mode == 'lumpsum': - if self.amount != self.purchase.lines[0].unit_price: - self.purchase.lines[0].unit_price = self.amount + if self.type == 'ordered' and self.purchase: + logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity) + 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 diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index b5703ff..bfcde7a 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -13,6 +13,7 @@ from trytond.pyson import Eval from trytond.tests.test_tryton import ModuleTestCase, with_transaction from trytond.exceptions import UserError 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 from trytond.modules.purchase_trade import lot as lot_module from trytond.modules.purchase_trade import purchase as purchase_module @@ -2607,6 +2608,88 @@ class PurchaseTradeTestCase(ModuleTestCase): save.assert_called_once_with([fee]) create_accruals.assert_called_once_with() + def test_purchase_service_fee_invoice_reverses_previous_line(self): + 're-invoiced service fee creates a reversal and a current line' + Line = base_purchase_module.Line + line = Line() + line.purchase = Mock(id=20) + line.unit_price = Decimal('15') + lot = Mock(id=30) + invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('15')) + fee = Mock( + state='invoiced', + mode='perqt', + get_fee_lots_qt=Mock(return_value=Decimal('12'))) + previous_line = Mock( + quantity=Decimal('10'), + unit_price=Decimal('11'), + invoice=Mock(party=Mock())) + reversal_line = Mock() + fee_model = Mock() + fee_model.search.return_value = [fee] + lqt_model = Mock() + lqt_model.search.return_value = [] + invoice_line_model = Mock() + invoice_line_model.copy.return_value = [reversal_line] + + with patch( + 'trytond.modules.purchase.purchase.Pool' + ) as PoolMock, patch.object( + Line, '_get_last_fee_invoice_line', + return_value=previous_line): + PoolMock.return_value.get.side_effect = lambda name: { + 'fee.fee': fee_model, + 'lot.qt.type': lqt_model, + 'account.invoice.line': invoice_line_model, + }[name] + + result = line._get_service_fee_invoice_lines(invoice_line, lot) + + self.assertEqual(result, [reversal_line, invoice_line]) + self.assertEqual(invoice_line.fee, fee) + self.assertEqual(invoice_line.quantity, Decimal('12')) + invoice_line_model.copy.assert_called_once_with( + [previous_line], default={ + 'invoice': None, + 'quantity': Decimal('-10'), + 'unit_price': Decimal('11'), + 'party': previous_line.invoice.party, + 'origin': str(line), + }) + + def test_purchase_service_fee_invoice_skips_unchanged_reinvoice(self): + 'unchanged re-invoiced service fee does not create a zero note' + 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('15')) + fee = Mock( + state='invoiced', + mode='perqt', + get_fee_lots_qt=Mock(return_value=Decimal('12'))) + previous_line = Mock( + quantity=Decimal('12'), + unit_price=Decimal('15'), + invoice=Mock(party=Mock())) + fee_model = Mock() + fee_model.search.return_value = [fee] + lqt_model = Mock() + lqt_model.search.return_value = [] + + with patch( + 'trytond.modules.purchase.purchase.Pool' + ) as PoolMock, patch.object( + Line, '_get_last_fee_invoice_line', + return_value=previous_line): + PoolMock.return_value.get.side_effect = lambda name: { + 'fee.fee': fee_model, + 'lot.qt.type': lqt_model, + }[name] + + self.assertEqual( + line._get_service_fee_invoice_lines(invoice_line, lot), []) + 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()