diff --git a/modules/purchase_trade/AGENTS.md b/modules/purchase_trade/AGENTS.md index 7f77f22..3e35915 100644 --- a/modules/purchase_trade/AGENTS.md +++ b/modules/purchase_trade/AGENTS.md @@ -66,6 +66,17 @@ de negoce physique: - ne pas proratiser depuis le poids (`net` / `gross`) - Le `FREIGHT VALUE` d'un template facture vient du `fee.fee` du shipment dont le produit est `Maritime freight`. +- Pour `stock/insurance.fodt`, le `Amount insured` doit venir en priorite de + `110%` du total des `incoming_moves` (fallback fee `Insurance` si aucun + montant incoming calculable). +- Pour le surveyor du certificat d'assurance shipment, la priorite est: + `shipment.surveyor` -> `shipment.controller` -> fournisseur du fee + `Insurance`. +- Pour `payment_order.fodt`, utiliser des proprietes + `invoice.report_payment_order_*` plutot que des tokens legacy `<...>`. +- Ajouter un champ de template dans `Document Templates` ne rend pas le report + visible dans la fiche: il faut aussi l'action `ir.action.report` + + `ir.action.keyword` (`form_print`) cote `account.invoice`. - Le wizard `Create contracts` en mode `matched` peut maintenant partir de plusieurs `lot.qt`, mais doit conserver un matching par lot source et laisser `created_by_code = True` sur les lignes creees pour ne pas declencher les diff --git a/modules/purchase_trade/docs/template-properties.md b/modules/purchase_trade/docs/template-properties.md index f6555a7..e25154a 100644 --- a/modules/purchase_trade/docs/template-properties.md +++ b/modules/purchase_trade/docs/template-properties.md @@ -1,8 +1,8 @@ # Template Properties - Purchase Trade Statut: `draft` -Version: `v0.1` -Derniere mise a jour: `2026-03-27` +Version: `v0.2` +Derniere mise a jour: `2026-04-07` ## 1) Objectif @@ -200,6 +200,81 @@ Source code: `modules/purchase_trade/invoice.py` - Usage: devise du `FREIGHT VALUE` - Source de verite: devise du fee `Maritime freight`, fallback devise facture +### Payment order + +- `report_payment_order_short_name` + - Usage: nom court emetteur du payment order + - Source de verite: `invoice.company.party.rec_name` + +- `report_payment_order_document_reference` + - Usage: reference du document payment order + - Source de verite: `invoice.number`, fallback `invoice.reference` + +- `report_payment_order_from_account_nb` + - Usage: compte bancaire emetteur + - Source de verite: premier `bank.account` de la societe + +- `report_payment_order_to_bank_name` + - Usage: banque destinataire + - Source de verite: banque du premier compte bancaire du partenaire facture + +- `report_payment_order_to_bank_city` + - Usage: ville banque destinataire + - Source de verite: adresse de la banque destinataire + +- `report_payment_order_amount` + - Usage: montant payment order + - Source de verite: `invoice.total_amount` + +- `report_payment_order_currency_code` + - Usage: devise payment order + - Source de verite: `invoice.currency` (`code`, fallback `rec_name/symbol`) + +- `report_payment_order_amount_text` + - Usage: montant en lettres + - Source de verite: conversion `amount_to_currency_words(invoice.total_amount)` + +- `report_payment_order_value_date` + - Usage: date valeur + - Source de verite: `invoice.payment_term_date`, fallback `invoice.invoice_date` + +- `report_payment_order_company_address` + - Usage: bloc beneficiaire + - Source de verite: `invoice.invoice_address.full_address`, fallback + `invoice.report_address` + +- `report_payment_order_beneficiary_account_nb` + - Usage: compte beneficiaire + - Source de verite: premier compte bancaire du `invoice.party` + +- `report_payment_order_beneficiary_bank_name` + - Usage: banque beneficiaire + - Source de verite: banque du compte beneficiaire + +- `report_payment_order_beneficiary_bank_city` + - Usage: ville banque beneficiaire + - Source de verite: adresse banque beneficiaire + +- `report_payment_order_swift_code` + - Usage: swift/bic beneficiaire + - Source de verite: `bank.bic` + +- `report_payment_order_other_instructions` + - Usage: instructions complementaires + - Source de verite: `invoice.description` + +- `report_payment_order_reference` + - Usage: reference business de paiement + - Source de verite: `invoice.reference`, fallback `invoice.number` + +- `report_payment_order_current_user` + - Usage: signataire payment order + - Source de verite: utilisateur courant (`res.user`) + +- `report_payment_order_current_user_email` + - Usage: email retour swift + - Source de verite: contact email du `party` utilisateur, fallback `user.email` + ## 5) Proprietes disponibles sur `account.invoice.line` Source code: `modules/purchase_trade/invoice.py` diff --git a/modules/purchase_trade/docs/template-rules.md b/modules/purchase_trade/docs/template-rules.md index 581dc8c..913cc58 100644 --- a/modules/purchase_trade/docs/template-rules.md +++ b/modules/purchase_trade/docs/template-rules.md @@ -1,8 +1,8 @@ # Template Rules - Purchase Trade Statut: `draft` -Version: `v0.3` -Derniere mise a jour: `2026-04-02` +Version: `v0.4` +Derniere mise a jour: `2026-04-07` ## 1) Scope @@ -167,11 +167,48 @@ Derniere mise a jour: `2026-04-02` - numero du certificat: `shipment.bl_number`, sinon `shipment.number` - `insured for account of`: client de la premiere ligne metier retrouvee via lot physique, sinon `shipment.supplier` - - `surveyor`: `shipment.controller`, sinon fournisseur du fee `Insurance` + - `surveyor`: `shipment.surveyor`, sinon `shipment.controller`, sinon + fournisseur du fee `Insurance` - lieu/date d'emission: ville de la societe + date du jour - Si une source differente est decidee plus tard, corriger la propriete Python plutot que complexifier `insurance.fodt` +### TR-017 - `payment_order.fodt` doit utiliser des proprietes `report_payment_order_*` + +- Pour `modules/account_invoice/payment_order.fodt`, ne pas utiliser des + placeholders externes legacy (tokens metier entre `<...>` du systeme source). +- Tous les placeholders du template doivent pointer vers des proprietes Python + stables exposees sur `account.invoice`: + - `report_payment_order_document_reference` + - `report_payment_order_from_account_nb` + - `report_payment_order_to_bank_name` + - `report_payment_order_to_bank_city` + - `report_payment_order_amount` + - `report_payment_order_currency_code` + - `report_payment_order_amount_text` + - `report_payment_order_value_date` + - `report_payment_order_company_address` + - `report_payment_order_beneficiary_account_nb` + - `report_payment_order_beneficiary_bank_name` + - `report_payment_order_beneficiary_bank_city` + - `report_payment_order_swift_code` + - `report_payment_order_other_instructions` + - `report_payment_order_reference` + - `report_payment_order_current_user` + - `report_payment_order_current_user_email` +- Eviter les marqueurs conditionnels heredites de l'ancien moteur (`++...`): + privilegier des placeholders simples avec fallback `or ''`. + +### TR-018 - Un template configure n'apparait dans le form que si une action report existe + +- Ajouter un champ dans `Document Templates` ne suffit pas a rendre un + template imprimable depuis la fiche. +- Pour afficher l'entree dans `account.invoice`, il faut aussi: + - un `ir.action.report` sur `model = account.invoice` + - un `ir.action.keyword` `form_print` lie a cette action +- Appliquer cette regle pour `Payment Order` comme pour `Invoice`, + `Prepayment` et `CN/DN`. + ### TR-007 - Pour une facture trade, privilegier le lot physique comme chemin de navigation - Pour remonter d'une facture vers des donnees logistiques ou metier, ne pas dupliquer de chemins differents selon achat/vente. diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 8ffd94c..1f59204 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -777,7 +777,7 @@ class SaleLine(metaclass=PoolMeta): del_period = fields.Many2One('product.month',"Delivery Period") lots = fields.One2Many('lot.lot','sale_line',"Lots",readonly=True) fees = fields.One2Many('fee.fee', 'sale_line', 'Fees') - quantity_theorical = fields.Numeric("Th. quantity", digits='unit', readonly=True) + quantity_theorical = fields.Numeric("Th. quantity", digits='unit', readonly=False) premium = fields.Numeric("Premium/Discount",digits='unit') price_type = fields.Selection([ ('cash', 'Cash Price'), @@ -1245,28 +1245,77 @@ class SaleLine(metaclass=PoolMeta): Pricing.save([p]) index += 1 - # @classmethod - # def write(cls, records, values): - # if 'quantity' in values: - # for record in records: - # old_qt = record.quantity - # new_qt = values['quantity'] - # logger.info("WRITE_OLD_QT:%s",old_qt) - # logger.info("WRITE_NEW_QT:%s",new_qt) - # if old_qt != new_qt: - # LotQt = Pool().get('lot.qt') - # lqts = LotQt.search(['lot_s','=',record.lots[0]]) - # if len(lqts)>1: - # raise UserError("You cannot changed quantity with open quantities defined !") - # return - # elif len(lqts)==1: - # if lqts[0].lot_p or lqts[0].lot_shipment_origin: - # raise UserError("You cannot changed quantity with open quantities defined !") - # return - # lqts[0].lot_quantity = new_qt - # LotQt.save(lqts) - - # super().write(records, values) + @classmethod + def write(cls, *args): + Lot = Pool().get('lot.lot') + LotQt = Pool().get('lot.qt') + old_values = {} + + for records, values in zip(args[::2], args[1::2]): + if 'quantity_theorical' in values: + for record in records: + old_values[record.id] = record.quantity_theorical + + super().write(*args) + + lines = sum(args[::2], []) + for line in lines: + if line.id not in old_values: + continue + old = Decimal(old_values[line.id] or 0) + new = Decimal(line.quantity_theorical or 0) + delta = new - old + if delta == 0: + continue + + virtual_lots = [ + lot for lot in (line.lots or []) + if lot.lot_type == 'virtual' + ] + if not virtual_lots: + continue + + vlot = virtual_lots[0] + lqts = LotQt.search([ + ('lot_s', '=', vlot.id), + ('lot_p', '=', None), + ('lot_shipment_in', '=', None), + ('lot_shipment_internal', '=', None), + ('lot_shipment_out', '=', None), + ]) + + if delta > 0: + new_qty = round( + Decimal(vlot.get_current_quantity_converted() or 0) + delta, + 5) + vlot.set_current_quantity(new_qty, new_qty, 1) + Lot.save([vlot]) + if lqts: + lqt = lqts[0] + lqt.lot_quantity = round( + Decimal(lqt.lot_quantity or 0) + delta, 5) + LotQt.save([lqt]) + else: + lqt = LotQt() + lqt.lot_p = None + lqt.lot_s = vlot.id + lqt.lot_quantity = round(delta, 5) + lqt.lot_unit = line.unit + LotQt.save([lqt]) + elif delta < 0: + decrease = abs(delta) + if not lqts or Decimal(lqts[0].lot_quantity or 0) < decrease: + raise UserError("Please unlink or unmatch lot") + new_qty = round( + Decimal(vlot.get_current_quantity_converted() or 0) + - decrease, + 5) + vlot.set_current_quantity(new_qty, new_qty, 1) + Lot.save([vlot]) + lqt = lqts[0] + lqt.lot_quantity = round( + Decimal(lqt.lot_quantity or 0) - decrease, 5) + LotQt.save([lqt]) @classmethod def delete(cls, lines): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 71b9a1d..284e3aa 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -184,6 +184,80 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( PurchaseLine.default_pricing_rule(), 'Default pricing rule') + def test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases(self): + 'sale line write increases virtual lot and open lot.qt when contractual qty grows' + SaleLine = Pool().get('sale.line') + line = Mock(id=1, quantity_theorical=Decimal('10')) + line.unit = Mock() + vlot = Mock(id=99, lot_type='virtual') + vlot.get_current_quantity_converted.return_value = Decimal('10') + line.lots = [vlot] + lqt = Mock(lot_quantity=Decimal('10')) + + lot_model = Mock() + lotqt_model = Mock() + lotqt_model.search.return_value = [lqt] + + with patch( + 'trytond.modules.purchase_trade.sale.Pool' + ) as PoolMock, patch( + 'trytond.modules.purchase_trade.sale.super' + ) as super_mock: + PoolMock.return_value.get.side_effect = lambda name: { + 'lot.lot': lot_model, + 'lot.qt': lotqt_model, + }[name] + + def fake_super_write(*args): + for records, values in zip(args[::2], args[1::2]): + if 'quantity_theorical' in values: + for record in records: + record.quantity_theorical = values['quantity_theorical'] + + super_mock.return_value.write.side_effect = fake_super_write + + SaleLine.write([line], {'quantity_theorical': Decimal('12')}) + + self.assertEqual(lqt.lot_quantity, Decimal('12')) + vlot.set_current_quantity.assert_called_once_with( + Decimal('12'), Decimal('12'), 1) + lot_model.save.assert_called() + lotqt_model.save.assert_called() + + def test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity(self): + 'sale line write blocks contractual qty decrease when open lot.qt is insufficient' + SaleLine = Pool().get('sale.line') + line = Mock(id=2, quantity_theorical=Decimal('10')) + vlot = Mock(id=100, lot_type='virtual') + vlot.get_current_quantity_converted.return_value = Decimal('10') + line.lots = [vlot] + lqt = Mock(lot_quantity=Decimal('1')) + + lot_model = Mock() + lotqt_model = Mock() + lotqt_model.search.return_value = [lqt] + + with patch( + 'trytond.modules.purchase_trade.sale.Pool' + ) as PoolMock, patch( + 'trytond.modules.purchase_trade.sale.super' + ) as super_mock: + PoolMock.return_value.get.side_effect = lambda name: { + 'lot.lot': lot_model, + 'lot.qt': lotqt_model, + }[name] + + def fake_super_write(*args): + for records, values in zip(args[::2], args[1::2]): + if 'quantity_theorical' in values: + for record in records: + record.quantity_theorical = values['quantity_theorical'] + + super_mock.return_value.write.side_effect = fake_super_write + + with self.assertRaises(UserError): + SaleLine.write([line], {'quantity_theorical': Decimal('8')}) + def test_party_execution_achieved_percent_uses_real_area_statistics(self): 'party execution achieved percent reflects the controller share in its area' PartyExecution = Pool().get('party.execution')