Fee report

This commit is contained in:
2026-06-22 17:16:27 +02:00
parent 6af3de0ffb
commit 703868001b
4 changed files with 170 additions and 6 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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')

View File

@@ -14,4 +14,7 @@
<field name="r_state" width="80"/>
<field name="r_inv" width="90"/>
<field name="r_dn_cn" width="90"/>
<field name="r_lot_invoice_status" width="110"/>
<field name="r_lot_payment_status" width="110"/>
<field name="r_fee_payment_status" width="110"/>
</tree>