From 703868001be52580d2997e15c662095d23d7d527 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 22 Jun 2026 17:16:27 +0200 Subject: [PATCH] Fee report --- modules/purchase_trade/docs/fees.md | 12 ++ modules/purchase_trade/fee.py | 104 +++++++++++++++++- modules/purchase_trade/tests/test_module.py | 57 ++++++++++ .../purchase_trade/view/fee_report_list.xml | 3 + 4 files changed, 170 insertions(+), 6 deletions(-) diff --git a/modules/purchase_trade/docs/fees.md b/modules/purchase_trade/docs/fees.md index a0d0545..cfb653f 100644 --- a/modules/purchase_trade/docs/fees.md +++ b/modules/purchase_trade/docs/fees.md @@ -49,3 +49,15 @@ Le recalcul est declenche apres: `stock.shipment.in.validate()` recalcule deja le PnL depuis les lots du shipment, mais ce workflow ne couvre pas les changements de fee saisis apres coup dans l'onglet Fees. + +## Fee Report + +La colonne `Purchase` du Fee Report vient exclusivement du lot lie au fee +(`fee.lots -> lot.line -> purchase.line.purchase`). Le purchase technique +stocke sur `fee.fee.purchase`, cree pour facturer le service, ne doit jamais +etre presente comme le contrat d'achat metier. + +Les statuts d'invoicing et de paiement des lots suivent les lots effectifs du +fee. Le statut de paiement du fee consolide sa facture et son eventuelle +DN/CN; il est `Paid` seulement si tous les documents concernes sont payes et +`Partially paid` des qu'au moins un paiement existe sans paiement complet. diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index f0ea47f..58a5e1c 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -1548,6 +1548,21 @@ class FeeReport( r_inv = fields.Function(fields.Many2One('account.invoice',"Invoice"),'get_invoice') r_dn_cn = fields.Function(fields.Many2One('account.invoice',"DN/CN"), 'get_dn_cn') + r_lot_invoice_status = fields.Function(fields.Selection([ + ('not', 'Not invoiced'), + ('partial', 'Partially invoiced'), + ('invoiced', 'Invoiced'), + ], "Lot invoicing"), 'get_lot_invoice_status') + r_lot_payment_status = fields.Function(fields.Selection([ + ('not', 'Not paid'), + ('partial', 'Partially paid'), + ('paid', 'Paid'), + ], "Lot payment"), 'get_lot_payment_status') + r_fee_payment_status = fields.Function(fields.Selection([ + ('not', 'Not paid'), + ('partial', 'Partially paid'), + ('paid', 'Paid'), + ], "Fee payment"), 'get_fee_payment_status') r_state = fields.Selection([ ('not invoiced', 'Not invoiced'), ('invoiced', 'Invoiced'), @@ -1580,6 +1595,80 @@ class FeeReport( fee = Fee(self.id) dn_cn = getattr(fee, 'dn_cn', None) return getattr(dn_cn, 'id', dn_cn) + + @staticmethod + def _unique_invoices(invoices): + unique = [] + keys = set() + for invoice in invoices: + if not invoice or getattr(invoice, 'state', None) == 'cancelled': + continue + key = getattr(invoice, 'id', None) or id(invoice) + if key not in keys: + keys.add(key) + unique.append(invoice) + return unique + + @classmethod + def _lot_invoice_data(cls, fee): + lots = fee._get_effective_fee_lots() + invoices = [] + invoiced_lots = 0 + for lot in lots: + lot_invoices = [] + for name in ('invoice_line', 'invoice_line_prov'): + line = getattr(lot, name, None) + if line: + lot_invoices.append(getattr(line, 'invoice', None)) + lot_invoices = cls._unique_invoices(lot_invoices) + if lot_invoices: + invoiced_lots += 1 + invoices.extend(lot_invoices) + return lots, invoiced_lots, cls._unique_invoices(invoices) + + @staticmethod + def _invoice_payment_status(invoices): + if not invoices: + return 'not' + statuses = [] + for invoice in invoices: + payment_lines = list( + getattr(invoice, 'payment_lines', None) or []) + if not payment_lines: + statuses.append('not') + elif (getattr(invoice, 'state', None) == 'paid' + or getattr(invoice, 'amount_to_pay', None) == 0): + statuses.append('paid') + else: + statuses.append('partial') + if all(status == 'paid' for status in statuses): + return 'paid' + if any(status != 'not' for status in statuses): + return 'partial' + return 'not' + + def get_lot_invoice_status(self, name): + Fee = Pool().get('fee.fee') + lots, invoiced_lots, _ = self._lot_invoice_data(Fee(self.id)) + if not lots or not invoiced_lots: + return 'not' + if invoiced_lots == len(lots): + return 'invoiced' + return 'partial' + + def get_lot_payment_status(self, name): + Fee = Pool().get('fee.fee') + _, _, invoices = self._lot_invoice_data(Fee(self.id)) + return self._invoice_payment_status(invoices) + + def get_fee_payment_status(self, name): + Fee = Pool().get('fee.fee') + fee = Fee(self.id) + invoices = self._unique_invoices([ + fee.get_invoice('inv'), + getattr(fee, 'dn_cn', None), + ]) + return self._invoice_payment_status(invoices) def get_shipment_origin(self, name): if self.r_shipment_in: @@ -1626,16 +1715,19 @@ class FeeReport( pl = PurchaseLine.__table__() SaleLine = Pool().get('sale.line') sl = SaleLine.__table__() + Lot = Pool().get('lot.lot') + lot = Lot.__table__() fee_lot_lines = ( fl - .join(pl, 'LEFT', condition=fl.line == pl.id) - .join(sl, 'LEFT', condition=fl.sale_line == sl.id) + .join(lot, 'LEFT', condition=fl.lot == lot.id) + .join(pl, 'LEFT', condition=lot.line == pl.id) + .join(sl, 'LEFT', condition=lot.sale_line == sl.id) .select( fl.fee.as_('fee'), - Min(fl.line).as_('purchase_line'), + Min(lot.line).as_('purchase_line'), Min(pl.purchase).as_('purchase'), - Min(fl.sale_line).as_('sale_line'), + Min(lot.sale_line).as_('sale_line'), Min(sl.sale).as_('sale'), group_by=[fl.fee])) @@ -1655,8 +1747,8 @@ class FeeReport( wh &= (fr.fee_counterparty == party) if fee_type: wh &= (fr.fee_type == fee_type) - purchase_expr = Coalesce(fee_lot_lines.purchase, fr.purchase) - purchase_line_expr = Coalesce(fee_lot_lines.purchase_line, fr.line) + purchase_expr = fee_lot_lines.purchase + purchase_line_expr = fee_lot_lines.purchase_line sale_expr = fee_lot_lines.sale if purchase: wh &= (purchase_expr == purchase) diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 4b34f66..e4adc28 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -5863,6 +5863,63 @@ class PurchaseTradeTestCase(ModuleTestCase): fee_model.browse.assert_called_once_with([10]) fee_model.invoice.assert_called_once_with(fees) + def test_fee_report_lot_invoice_status_uses_effective_lots(self): + 'fee report consolidates invoicing over the effective linked lots' + FeeReport = Pool().get('fee.report') + invoice = SimpleNamespace(id=1, state='posted') + invoiced = SimpleNamespace( + invoice_line=SimpleNamespace(invoice=invoice), + invoice_line_prov=None) + not_invoiced = SimpleNamespace( + invoice_line=None, invoice_line_prov=None) + fee = SimpleNamespace( + _get_effective_fee_lots=lambda: [invoiced, not_invoiced]) + + lots, count, invoices = FeeReport._lot_invoice_data(fee) + + self.assertEqual(lots, [invoiced, not_invoiced]) + self.assertEqual(count, 1) + self.assertEqual(invoices, [invoice]) + + def test_fee_report_payment_status_consolidates_invoice_payments(self): + 'fee report marks mixed paid and unpaid invoices as partially paid' + FeeReport = Pool().get('fee.report') + paid = SimpleNamespace( + state='paid', payment_lines=[SimpleNamespace()], + amount_to_pay=Decimal('0')) + unpaid = SimpleNamespace( + state='posted', payment_lines=[], + amount_to_pay=Decimal('10')) + + self.assertEqual( + FeeReport._invoice_payment_status([paid, unpaid]), 'partial') + self.assertEqual(FeeReport._invoice_payment_status([paid]), 'paid') + self.assertEqual(FeeReport._invoice_payment_status([unpaid]), 'not') + + def test_fee_report_payment_status_includes_fee_dn_cn(self): + 'fee payment status consolidates the invoice and its DN/CN' + FeeReport = Pool().get('fee.report') + report = FeeReport() + report.id = 10 + paid = SimpleNamespace( + id=1, state='paid', payment_lines=[SimpleNamespace()], + amount_to_pay=Decimal('0')) + unpaid_dn_cn = SimpleNamespace( + id=2, state='posted', payment_lines=[], + amount_to_pay=Decimal('5')) + fee = Mock(dn_cn=unpaid_dn_cn) + fee.get_invoice.return_value = paid + fee_model = Mock(return_value=fee) + + with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock: + PoolMock.return_value.get.return_value = fee_model + + self.assertEqual( + report.get_fee_payment_status('r_fee_payment_status'), + 'partial') + + fee.get_invoice.assert_called_once_with('inv') + def test_sale_report_converts_mixed_units_for_total_and_words(self): 'sale report totals prefer the virtual lot unit as common unit' Sale = Pool().get('sale.sale') diff --git a/modules/purchase_trade/view/fee_report_list.xml b/modules/purchase_trade/view/fee_report_list.xml index b0e35dc..507e347 100755 --- a/modules/purchase_trade/view/fee_report_list.xml +++ b/modules/purchase_trade/view/fee_report_list.xml @@ -14,4 +14,7 @@ + + +