diff --git a/modules/purchase_trade/docs/fees.md b/modules/purchase_trade/docs/fees.md index cfb653f..3a60894 100644 --- a/modules/purchase_trade/docs/fees.md +++ b/modules/purchase_trade/docs/fees.md @@ -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`. diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 58a5e1c..42150f3 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -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: diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 6cd673f..8c887de 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -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" diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index e4adc28..a9c54df 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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' diff --git a/modules/purchase_trade/view/lot_invoicing_fee_tree.xml b/modules/purchase_trade/view/lot_invoicing_fee_tree.xml index 9d33171..5b83394 100755 --- a/modules/purchase_trade/view/lot_invoicing_fee_tree.xml +++ b/modules/purchase_trade/view/lot_invoicing_fee_tree.xml @@ -5,5 +5,6 @@ + diff --git a/modules/purchase_trade/view/lot_invoicing_lot_fee.xml b/modules/purchase_trade/view/lot_invoicing_lot_fee.xml index 929eb1c..6386c19 100755 --- a/modules/purchase_trade/view/lot_invoicing_lot_fee.xml +++ b/modules/purchase_trade/view/lot_invoicing_lot_fee.xml @@ -6,5 +6,6 @@ +