Lot invoicing + Fee invoicing
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user