Lot invoicing + Fee invoicing

This commit is contained in:
2026-06-22 17:44:55 +02:00
parent 703868001b
commit ed818c2287
6 changed files with 224 additions and 54 deletions

View File

@@ -58,6 +58,18 @@ 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.
fee. Le statut de paiement du fee distingue `Invoice paid`, `DN/CN paid`,
`All paid` et `Not paid` selon le paiement complet de sa facture et de son
eventuelle DN/CN.
## Facturation depuis Invoice physical lots
Le dialogue affiche les fees `ordered` des lignes correspondant au cote actif:
- `Purchase`: fees des `purchase.line` des lots selectionnes;
- `Sale`: fees des `sale.line` des lots selectionnes.
Seuls les fees coches `To invoice` sont traites. En provisoire, un fee sans
facture est facture normalement et un fee deja facture est ignore. En finale,
un fee sans facture suit la facturation standard; un fee deja facture suit le
flux DN/CN existant de `fee.fee.invoice`.

View File

@@ -1559,9 +1559,10 @@ class FeeReport(
('paid', 'Paid'),
], "Lot payment"), 'get_lot_payment_status')
r_fee_payment_status = fields.Function(fields.Selection([
('not', 'Not paid'),
('partial', 'Partially paid'),
('paid', 'Paid'),
('not_paid', 'Not paid'),
('invoice_paid', 'Invoice paid'),
('dn_cn_paid', 'DN/CN paid'),
('all_paid', 'All paid'),
], "Fee payment"), 'get_fee_payment_status')
r_state = fields.Selection([
('not invoiced', 'Not invoiced'),
@@ -1661,14 +1662,27 @@ class FeeReport(
_, _, invoices = self._lot_invoice_data(Fee(self.id))
return self._invoice_payment_status(invoices)
@classmethod
def _fee_payment_status(cls, invoice, dn_cn):
invoice_paid = (
cls._invoice_payment_status(cls._unique_invoices([invoice]))
== 'paid')
dn_cn_paid = (
cls._invoice_payment_status(cls._unique_invoices([dn_cn]))
== 'paid')
if invoice_paid and dn_cn_paid:
return 'all_paid'
if invoice_paid:
return 'invoice_paid'
if dn_cn_paid:
return 'dn_cn_paid'
return 'not_paid'
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)
return self._fee_payment_status(
fee.get_invoice('inv'), getattr(fee, 'dn_cn', None))
def get_shipment_origin(self, name):
if self.r_shipment_in:

View File

@@ -25,7 +25,16 @@ from trytond.modules.purchase_trade.service import ContractFactory
from trytond.modules.purchase_trade.company_defaults import (
default_itsa_unit, is_itsa_company)
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
def _fee_invoice_action(fee, invoice_action):
invoice = fee.get_invoice('inv') if fee else None
if invoice_action == 'final' and invoice:
return 'dn_cn'
if invoice:
return 'already_invoiced'
return 'standard'
class LotAccountingGraph(ModelSQL,ModelView):
"Lot accounting graph"
@@ -4667,7 +4676,7 @@ class LotInvoice(Wizard):
def transition_start(self):
return 'inv'
def default_inv(self, fields):
def default_inv(self, fields):
lot_p = []
lot_s = []
fee_pur = []
@@ -4675,8 +4684,10 @@ class LotInvoice(Wizard):
fee_sale = []
pp_sale = []
act = 'prov'
line = None
sale_line = None
line = None
sale_line = None
purchase_lines = []
sale_lines = []
val = {}
Lot = Pool().get('lot.lot')
LotQt = Pool().get('lot.qt')
@@ -4690,7 +4701,9 @@ class LotInvoice(Wizard):
lot = lqts[0].lot_p
else:
lot = Lot(i)
line = lot.line
line = lot.line
if line and line not in purchase_lines:
purchase_lines.append(line)
if lot.line.purchase:
if lot.line.purchase.wb:
if lot.line.purchase.wb.qt_type in [e.quantity_type for e in lot.lot_hist]:
@@ -4715,7 +4728,9 @@ class LotInvoice(Wizard):
unit = val['lot_unit']
val['lot_currency'] = lot.lot_price_ct_symbol
lot_p.append(val)
sale_line = lot.sale_line
sale_line = lot.sale_line
if sale_line and sale_line not in sale_lines:
sale_lines.append(sale_line)
val_s = val.copy() # ou utiliser deepcopy si certains champs sont des objets imbriqués
val_s['lot_price'] = lot.lot_price_sale
val_s['lot_amount'] = lot.get_current_quantity_converted() * lot.lot_price_sale if lot.lot_price_sale else Decimal(0)
@@ -4737,22 +4752,28 @@ class LotInvoice(Wizard):
val_s['lot_currency'] = lot.lot_price_ct_symbol_sale
val_s['lot_unit'] = sale_line.unit.id if sale_line else None
lot_s.append(val_s)
if line:
if line.fees:
for f in line.fees:
if f.type == 'ordered':
val = {}
val['fee'] = f.id
val['fee_type'] = f.product.id
val['fee_quantity'] = f.quantity
val['fee_price'] = f.price
val['fee_unit'] = line.unit.id
val['fee_amount'] = f.amount
val['fee_landed_cost'] = f.fee_landed_cost
fee_pur.append(val)
if line.purchase:
Prep = Pool().get('account.invoice')
prep = Prep.search([('description','=','Prepayment'),('party','=',line.purchase.party.id),('state','!=','draft')])
seen_fees = set()
for purchase_line in purchase_lines:
if purchase_line.fees:
for f in purchase_line.fees:
if f.type == 'ordered':
fee_key = getattr(f, 'id', None) or id(f)
if fee_key in seen_fees:
continue
seen_fees.add(fee_key)
val = {}
val['fee'] = f.id
val['fee_type'] = f.product.id
val['fee_quantity'] = f.quantity
val['fee_price'] = f.price
val['fee_unit'] = purchase_line.unit.id
val['fee_amount'] = f.amount
val['fee_landed_cost'] = f.fee_landed_cost
fee_pur.append(val)
if line:
if line.purchase:
Prep = Pool().get('account.invoice')
prep = Prep.search([('description','=','Prepayment'),('party','=',line.purchase.party.id),('state','!=','draft')])
if prep:
for p in prep:
for li in p.lines:
@@ -4760,21 +4781,27 @@ class LotInvoice(Wizard):
val['inv'] = li.id
val['inv_amount'] = li.amount
pp_pur.append(val)
if sale_line:
if sale_line.fees:
for f in sale_line.fees:
if f.type == 'ordered':
val = {}
val['fee'] = f.id
val['fee_type'] = f.product.id
val['fee_quantity'] = f.quantity
val['fee_price'] = f.price
val['fee_unit'] = sale_line.unit.id
val['fee_amount'] = f.amount
val['fee_landed_cost'] = f.fee_landed_cost
fee_sale.append(val)
if sale_line.sale:
Prep = Pool().get('account.invoice')
seen_fees = set()
for current_sale_line in sale_lines:
if current_sale_line.fees:
for f in current_sale_line.fees:
if f.type == 'ordered':
fee_key = getattr(f, 'id', None) or id(f)
if fee_key in seen_fees:
continue
seen_fees.add(fee_key)
val = {}
val['fee'] = f.id
val['fee_type'] = f.product.id
val['fee_quantity'] = f.quantity
val['fee_price'] = f.price
val['fee_unit'] = current_sale_line.unit.id
val['fee_amount'] = f.amount
val['fee_landed_cost'] = f.fee_landed_cost
fee_sale.append(val)
if sale_line:
if sale_line.sale:
Prep = Pool().get('account.invoice')
prep = Prep.search([('description','=','Prepayment'),('party','=',sale_line.sale.party.id),('state','!=','draft')])
if prep:
for p in prep:
@@ -4842,10 +4869,33 @@ class LotInvoice(Wizard):
break
if not invoice_line:
raise UserError("No invoice line was generated from the selected lots.")
self._invoice_selected_fees()
self.message.invoice = invoice_line.invoice
return 'message'
def _invoice_selected_fees(self):
Fee = Pool().get('fee.fee')
fee_lines = (
self.inv.fee_pur
if self.inv.type == 'purchase' else self.inv.fee_sale)
fees = []
seen = set()
for line in fee_lines or []:
fee = getattr(line, 'fee', None)
if not getattr(line, 'to_invoice', False) or not fee:
continue
fee_key = getattr(fee, 'id', None) or id(fee)
if fee_key in seen:
continue
seen.add(fee_key)
action = _fee_invoice_action(fee, self.inv.action)
if action == 'already_invoiced':
continue
fees.append(fee)
if fees:
Fee.invoice(fees)
@classmethod
def _split_sale_padding(cls, padding, lots):
padding = Decimal(str(padding or 0))
@@ -4983,19 +5033,31 @@ class LotInvoiceStart(ModelView):
def default_type(cls):
return 'purchase'
class LotInvoicingFee(ModelView):
class LotInvoicingFee(ModelView):
"Fees"
__name__ = "lot.invoicing.fee"
lfs = fields.Many2One('lot.invoice.start',"Invoicing")
fee = fields.Many2One('fee.fee',"Fee")
fee_type = fields.Many2One('product.product',"Fee type")
to_invoice = fields.Boolean("To invoice")
fee_type = fields.Many2One('product.product',"Fee type")
invoice_action = fields.Function(fields.Selection([
('standard', 'Standard invoice'),
('dn_cn', 'DN/CN'),
('already_invoiced', 'Already invoiced'),
], "Invoice action"), 'on_change_with_invoice_action')
to_invoice = fields.Boolean("To invoice", states={
'readonly': Eval('invoice_action') == 'already_invoiced',
}, depends=['invoice_action'])
fee_unit = fields.Many2One('product.uom',"Unit",readonly=True)
fee_quantity = fields.Numeric("Qt",digits=(1,5),readonly=True)
fee_price = fields.Numeric("Price",digits=(1,4),readonly=True)
fee_amount = fields.Numeric("Amount",digits=(1,2),readonly=True)
fee_landed_cost = fields.Boolean("To inventory",readonly=True)
fee_landed_cost = fields.Boolean("To inventory",readonly=True)
@fields.depends('fee', '_parent_lfs.action')
def on_change_with_invoice_action(self, name=None):
action = self.lfs.action if self.lfs else None
return _fee_invoice_action(self.fee, action)
class LotInvoicingInv(ModelView):
"Fees"

View File

@@ -5897,7 +5897,7 @@ class PurchaseTradeTestCase(ModuleTestCase):
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'
'fee payment status distinguishes invoice and DN/CN payments'
FeeReport = Pool().get('fee.report')
report = FeeReport()
report.id = 10
@@ -5916,10 +5916,28 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(
report.get_fee_payment_status('r_fee_payment_status'),
'partial')
'invoice_paid')
fee.get_invoice.assert_called_once_with('inv')
unpaid_dn_cn.payment_lines = [SimpleNamespace()]
unpaid_dn_cn.amount_to_pay = Decimal('0')
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'),
'all_paid')
unpaid = SimpleNamespace(
state='posted', payment_lines=[],
amount_to_pay=Decimal('5'))
self.assertEqual(
FeeReport._fee_payment_status(unpaid, unpaid), 'not_paid')
self.assertEqual(
FeeReport._fee_payment_status(unpaid, unpaid_dn_cn),
'dn_cn_paid')
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')
@@ -8315,6 +8333,68 @@ class PurchaseTradeTestCase(ModuleTestCase):
lot_module.LotInvoice._split_sale_padding(Decimal('1000'), lots),
{1: Decimal('500'), 2: Decimal('500')})
def test_lot_invoice_fee_action_follows_lot_invoice_stage(self):
'fee action is standard in provisional and DN/CN in final'
not_invoiced = Mock()
not_invoiced.get_invoice.return_value = None
invoiced = Mock()
invoiced.get_invoice.return_value = Mock()
self.assertEqual(
lot_module._fee_invoice_action(not_invoiced, 'prov'),
'standard')
self.assertEqual(
lot_module._fee_invoice_action(invoiced, 'prov'),
'already_invoiced')
self.assertEqual(
lot_module._fee_invoice_action(not_invoiced, 'final'),
'standard')
self.assertEqual(
lot_module._fee_invoice_action(invoiced, 'final'),
'dn_cn')
def test_lot_invoice_only_invoices_checked_fees_for_selected_side(self):
'lot invoice invoices only checked fees from purchase or sale side'
purchase_fee = Mock(id=1)
purchase_fee.get_invoice.return_value = None
ignored_purchase_fee = Mock(id=2)
ignored_purchase_fee.get_invoice.return_value = None
sale_fee = Mock(id=3)
sale_fee.get_invoice.return_value = None
wizard = SimpleNamespace(inv=SimpleNamespace(
type='purchase', action='prov',
fee_pur=[
SimpleNamespace(fee=purchase_fee, to_invoice=True),
SimpleNamespace(fee=ignored_purchase_fee, to_invoice=False),
],
fee_sale=[SimpleNamespace(fee=sale_fee, to_invoice=True)],
))
fee_model = Mock()
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
lot_module.LotInvoice._invoice_selected_fees(wizard)
fee_model.invoice.assert_called_once_with([purchase_fee])
def test_lot_invoice_final_invoices_checked_existing_fee_as_dn_cn(self):
'final lot invoice sends an already invoiced checked fee to Fee.invoice'
fee = Mock(id=1)
fee.get_invoice.return_value = Mock()
wizard = SimpleNamespace(inv=SimpleNamespace(
type='sale', action='final', fee_pur=[],
fee_sale=[SimpleNamespace(fee=fee, to_invoice=True)],
))
fee_model = Mock()
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
lot_module.LotInvoice._invoice_selected_fees(wizard)
fee_model.invoice.assert_called_once_with([fee])
@with_transaction()
def test_invoice_line_included_padding_reads_sale_lot_padding(self):
'invoice line exposes the sale provisional padding stored on the lot'

View File

@@ -5,5 +5,6 @@
<field name="fee_price" width="80"/>
<field name="fee_amount" width="100"/>
<field name="fee_landed_cost" width="80"/>
<field name="invoice_action" width="110"/>
<field name="to_invoice" width="60"/>
</tree>

View File

@@ -6,5 +6,6 @@
<field name="fee_price"/>
<field name="fee_amount"/>
<field name="fee_landed_cost"/>
<field name="invoice_action"/>
<field name="to_invoice"/>
</tree>