Check From To + pricing component domain filter

This commit is contained in:
2026-04-28 19:23:42 +02:00
parent aa9a18fb93
commit 30d4362c09
7 changed files with 276 additions and 12 deletions

View File

@@ -63,9 +63,14 @@ Guide rapide pour les agents qui codent dans ce repository.
- Quand un template affiche les placeholders en brut, verifier que les champs sont bien des placeholders Relatorio dans le XML (pas du texte litteral). - Quand un template affiche les placeholders en brut, verifier que les champs sont bien des placeholders Relatorio dans le XML (pas du texte litteral).
- Eviter les apostrophes echappees style `\'` dans placeholders; preferer `"` et `'`. - Eviter les apostrophes echappees style `\'` dans placeholders; preferer `"` et `'`.
## 4.bis) Memo templates de session ## 4.bis) Memos metier et templates
- Voir aussi `notes/template_business_rules.md` pour le recap detaille (business rules + decisions templates de la session). - Regles metier transverses:
- `notes/business_rules.md`
- Regles metier locales `purchase_trade`:
- `modules/purchase_trade/docs/business-rules.md`
- Decisions templates / reports:
- `notes/template_business_rules.md`
## 5) Workflow de modification (obligatoire) ## 5) Workflow de modification (obligatoire)

View File

@@ -485,6 +485,43 @@ Owner technique: `a completer`
- Priorite: - Priorite:
- `importante` - `importante`
### BR-PT-014 - Delivery period: From doit rester inferieur ou egal a To
- Intent: eviter les periodes de livraison incoherentes sur les lignes achat et
vente.
- Description:
- Les champs `from_del` et `to_del` sont presents sur `purchase.line` et
`sale.line`.
- Si les deux dates sont renseignees, `from_del` ne doit jamais etre
posterieur a `to_del`.
- Resultat attendu:
- la sauvegarde d'une `purchase.line` ou `sale.line` est bloquee si
`from_del > to_del`
- une date ouverte reste autorisee si seulement une des deux bornes est
renseignee
- Priorite:
- `importante`
### BR-PT-015 - Pricing manuel: composant limite a la ligne courante
- Intent: eviter qu'une ligne de pricing saisie manuellement utilise un
composant rattache a une autre ligne de contrat.
- Description:
- Dans l'onglet `Pricing dates` d'une `purchase.line`, le champ
`pricing.pricing.price_component` doit proposer uniquement les composants
dont `pricing.component.line` est la ligne achat courante.
- Dans l'onglet `Pricing dates` d'une `sale.line`, il doit proposer
uniquement les composants dont `pricing.component.sale_line` est la ligne
vente courante.
- Une ligne de pricing sans composant reste possible pour le mode manuel sans
component.
- Resultat attendu:
- le domaine UI filtre les composants sur la ligne courante
- une validation serveur bloque aussi un composant appartenant a une autre
ligne
- Priorite:
- `importante`
## 4) Exemples concrets ## 4) Exemples concrets
### Exemple E1 - Augmentation simple ### Exemple E1 - Augmentation simple

View File

@@ -4,6 +4,7 @@ from trytond.model import fields
from trytond.pool import Pool, PoolMeta from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id from trytond.pyson import Bool, Eval, Id
from trytond.model import (ModelSQL, ModelView) from trytond.model import (ModelSQL, ModelView)
from trytond.exceptions import UserError
from trytond.tools import is_full_text, lstrip_wildcard from trytond.tools import is_full_text, lstrip_wildcard
from trytond.transaction import Transaction, inactive_records from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP from decimal import getcontext, Decimal, ROUND_HALF_UP
@@ -330,7 +331,14 @@ class Pricing(ModelSQL,ModelView):
__name__ = 'pricing.pricing' __name__ = 'pricing.pricing'
pricing_date = fields.Date("Date") pricing_date = fields.Date("Date")
price_component = fields.Many2One('pricing.component', "Component")#, domain=[('id', 'in', Eval('line.price_components'))], ondelete='CASCADE') price_component = fields.Many2One(
'pricing.component', "Component",
domain=[
'OR',
('line', '=', Eval('line', -1)),
('sale_line', '=', Eval('sale_line', -1)),
],
depends=['line', 'sale_line'])#, ondelete='CASCADE')
quantity = fields.Numeric("Qt",digits='unit') quantity = fields.Numeric("Qt",digits='unit')
settl_price = fields.Numeric("Settl. price",digits='unit') settl_price = fields.Numeric("Settl. price",digits='unit')
fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True) fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True)
@@ -433,6 +441,32 @@ class Pricing(ModelSQL,ModelView):
cls._sync_manual_last(records) cls._sync_manual_last(records)
cls._sync_eod_price(records) cls._sync_eod_price(records)
@staticmethod
def _component_matches_owner(record):
def record_id(value):
return getattr(value, 'id', value)
component = getattr(record, 'price_component', None)
if not component:
return True
line = getattr(record, 'line', None)
if line:
return record_id(getattr(component, 'line', None)) == record_id(line)
sale_line = getattr(record, 'sale_line', None)
if sale_line:
return (
record_id(getattr(component, 'sale_line', None))
== record_id(sale_line))
return True
@classmethod
def validate(cls, records):
super(Pricing, cls).validate(records)
for record in records:
if not cls._component_matches_owner(record):
raise UserError(
"Pricing component must belong to the related line.")
@classmethod @classmethod
def _sync_eod_price(cls, records): def _sync_eod_price(cls, records):
if not records: if not records:

View File

@@ -1094,6 +1094,13 @@ class Line(metaclass=PoolMeta):
return configurations[0].pricing_rule or '' return configurations[0].pricing_rule or ''
return '' return ''
@staticmethod
def _has_invalid_delivery_period(line):
return (
bool(line.from_del)
and bool(line.to_del)
and line.from_del > line.to_del)
quantity_theorical = fields.Numeric("Contractual Qt", digits='unit', readonly=False) quantity_theorical = fields.Numeric("Contractual Qt", digits='unit', readonly=False)
price_type = fields.Selection([ price_type = fields.Selection([
('cash', 'Cash Price'), ('cash', 'Cash Price'),
@@ -1562,6 +1569,9 @@ class Line(metaclass=PoolMeta):
def validate(cls, lines): def validate(cls, lines):
super(Line, cls).validate(lines) super(Line, cls).validate(lines)
for line in lines: for line in lines:
if cls._has_invalid_delivery_period(line):
raise UserError(
"Delivery period From date must be before To date.")
if line.price_components: if line.price_components:
for pc in line.price_components: for pc in line.price_components:
if pc.triggers: if pc.triggers:

View File

@@ -1077,6 +1077,13 @@ class SaleLine(metaclass=PoolMeta):
return configurations[0].pricing_rule or '' return configurations[0].pricing_rule or ''
return '' return ''
@staticmethod
def _has_invalid_delivery_period(line):
return (
bool(line.from_del)
and bool(line.to_del)
and line.from_del > line.to_del)
del_period = fields.Many2One('product.month',"Delivery Period") del_period = fields.Many2One('product.month',"Delivery Period")
lots = fields.One2Many('lot.lot','sale_line',"Lots",readonly=True) lots = fields.One2Many('lot.lot','sale_line',"Lots",readonly=True)
fees = fields.One2Many('fee.fee', 'sale_line', 'Fees') fees = fields.One2Many('fee.fee', 'sale_line', 'Fees')
@@ -1683,6 +1690,9 @@ class SaleLine(metaclass=PoolMeta):
Pnl = Pool().get('valuation.valuation') Pnl = Pool().get('valuation.valuation')
super(SaleLine, cls).validate(salelines) super(SaleLine, cls).validate(salelines)
for line in salelines: for line in salelines:
if cls._has_invalid_delivery_period(line):
raise UserError(
"Delivery period From date must be before To date.")
if line.price_components: if line.price_components:
for pc in line.price_components: for pc in line.price_components:
if pc.triggers: if pc.triggers:

View File

@@ -6,6 +6,7 @@ from decimal import Decimal
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from trytond.pool import Pool from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.tests.test_tryton import ModuleTestCase, with_transaction from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.exceptions import UserError from trytond.exceptions import UserError
from trytond.modules.purchase_trade import valuation as valuation_module from trytond.modules.purchase_trade import valuation as valuation_module
@@ -523,6 +524,61 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertTrue(Pricing.unfixed_qt_price.readonly) self.assertTrue(Pricing.unfixed_qt_price.readonly)
self.assertTrue(Pricing.eod_price.readonly) self.assertTrue(Pricing.eod_price.readonly)
def test_pricing_component_domain_is_limited_to_owner_line(self):
'pricing component choices are limited to the purchase or sale line'
Pricing = Pool().get('pricing.pricing')
self.assertEqual(Pricing.price_component.domain, [
'OR',
('line', '=', Eval('line', -1)),
('sale_line', '=', Eval('sale_line', -1)),
])
def test_pricing_component_must_belong_to_pricing_owner(self):
'pricing rows reject components from another purchase or sale line'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
other = Mock(id=11)
self.assertTrue(Pricing._component_matches_owner(Mock(
line=owner,
sale_line=None,
price_component=Mock(line=owner, sale_line=None),
)))
self.assertFalse(Pricing._component_matches_owner(Mock(
line=owner,
sale_line=None,
price_component=Mock(line=other, sale_line=None),
)))
self.assertTrue(Pricing._component_matches_owner(Mock(
line=None,
sale_line=owner,
price_component=Mock(line=None, sale_line=owner),
)))
self.assertFalse(Pricing._component_matches_owner(Mock(
line=None,
sale_line=owner,
price_component=Mock(line=None, sale_line=other),
)))
def test_purchase_and_sale_delivery_period_reject_from_after_to(self):
'delivery period dates must be chronological on purchase and sale lines'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
invalid = Mock(
from_del=datetime.date(2026, 5, 1),
to_del=datetime.date(2026, 4, 30),
)
valid = Mock(
from_del=datetime.date(2026, 4, 1),
to_del=datetime.date(2026, 4, 30),
)
self.assertTrue(PurchaseLine._has_invalid_delivery_period(invalid))
self.assertTrue(SaleLine._has_invalid_delivery_period(invalid))
self.assertFalse(PurchaseLine._has_invalid_delivery_period(valid))
self.assertFalse(SaleLine._has_invalid_delivery_period(valid))
def test_pricing_eod_uses_weighted_average_for_manual_rows(self): def test_pricing_eod_uses_weighted_average_for_manual_rows(self):
'manual pricing eod uses the weighted average of fixed and unfixed legs' 'manual pricing eod uses the weighted average of fixed and unfixed legs'
Pricing = Pool().get('pricing.pricing') Pricing = Pool().get('pricing.pricing')

112
notes/business_rules.md Normal file
View File

@@ -0,0 +1,112 @@
# Business rules
Memo racine pour les regles metier transverses ou multi-modules.
Pour les regles propres a un module, preferer la documentation locale quand
elle existe, par exemple:
- `modules/purchase_trade/docs/business-rules.md`
- `modules/purchase_trade/docs/padding-invoice-accounting.md`
## Session 2026-04-28 - Devises, TVA, padding et lots
### `account.move.line` / saisie manuelle en seconde devise
- `rate` est un vrai champ stocke et editable, plus seulement un champ de
visualisation.
- Le sens du taux suit l'usage Tryton: taux devise seconde / devise societe.
Donc, avec une devise societe a 1, le montant societe se calcule par
`amount_second_currency / rate`.
- Une ligne est consideree comme manuelle si elle n'a ni `origin`, ni
`move_origin`, ni `move.origin`.
- En manuel, si l'utilisateur saisit d'abord `amount_second_currency`, le
systeme reprend le dernier taux disponible pour la devise seconde a la date
de la ligne, puis calcule `debit` ou `credit`.
- Si l'utilisateur veut forcer le taux, il efface d'abord `debit` / `credit`,
saisit `rate`, et le montant societe est recalcule depuis le montant en
devise seconde.
- Si le montant societe et le montant devise seconde sont deja presents, le
taux implicite est `abs(amount_second_currency) / abs(base_amount)`.
- Les lignes generees automatiquement depuis facture doivent aussi renseigner
`rate` quand `amount_second_currency` existe.
- Un fallback central sur `account.move.line.create` calcule le taux manquant
depuis les montants pour eviter les chemins oublies.
- Comme `rate` devient stocke, une mise a jour du module est necessaire; les
anciennes lignes doivent etre regenerees ou backfillees si on veut afficher
le taux historique.
### Causes racines des bugs devises
- Deux `on_change_amount_second_currency` existaient; le second ecrasait la
logique de conversion.
- La branche negative testait deux fois `> 0`, donc un montant devise seconde
negatif ne generait pas correctement le credit.
- Le taux initial etait calcule a l'envers par rapport au standard Tryton
attendu.
- Les records transitoires Tryton peuvent ne pas exposer un champ non assigne;
les chemins generiques doivent utiliser `getattr(record, field, None)`.
### `account.invoice` / taux et devise
- `on_change_with_rate` depend explicitement de `currency`, `company`,
`invoice_date`, `selection_rate`, `rate_date` et `lines`.
- Ne pas declarer `lines.amount` dans `@fields.depends`: ce champ relationnel
n'existe pas cote definition de vue et provoque un `KeyError`.
- Les move lines de facture, lignes produit, lignes stock/drop et lignes TVA
doivent renseigner `rate` des qu'elles portent `amount_second_currency`.
### TVA
- Les taxes sur `invoice.lines[].taxes` alimentent les taxes calculees de la
facture via les lignes taxables.
- Les taxes globales de facture (`invoice.taxes`) peuvent etre automatiques ou
manuelles.
- Les taxes manuelles sont preservees par `update_taxes`; les taxes
automatiques sont synchronisees, supprimees ou recreees selon les lignes.
- Une ligne facture genere des `account.tax.line` de type `base`.
- Une ligne de taxe facture genere une move line comptable avec
`account.tax.line` de type `tax`; si la taxe est manuelle, une ligne `base`
complementaire peut etre ajoutee.
- La seconde devise est portee par `account.move.line`
(`second_currency` / `amount_second_currency`), pas par `account.tax.line`
qui suit la devise comptable de la move line.
### Padding
- Les chemins padding doivent verifier que `lot` existe avant de lire
`sale_invoice_line_prov` ou `sale_invoice_line`.
- Le bug observe n'etait pas la logique padding elle-meme mais un acces direct
a un lot absent sur certaines lignes de facture.
### Payment term
- Le calcul des echeances doit fonctionner meme si la premiere ligne facture
n'a pas d'`origin`.
- `term_lines` doit etre calcule hors de la branche qui depend du modele
d'origine, avec une ligne metier optionnelle.
### Periodes comptables
- `Period.find` utilise un cache par societe/date/etat.
- Une periode creee ou ouverte peut continuer a etre vue comme absente par un
worker tant que le cache ou le serveur n'a pas ete rafraichi.
### `lot.qt` / validation sale et purchase
- Cote `sale.line`, la creation de la ligne cree deja le lot virtuel et sa
quantite.
- Le passage initial de `quantity_theorical` de `None` a la quantite de la
ligne ne doit pas etre traite comme un delta physique; sinon `lot.qt` est
double.
- Le correctif sale ignore le delta quand l'ancienne valeur theorique est
`None`; les modifications suivantes restent bien appliquees en delta.
- Cote `purchase.line`, la logique etait deja protegee par une baseline sur la
quantite courante du lot virtuel quand l'ancienne valeur est `None`.
### Valuation / PNL achat
- Une purchase line peut ne pas produire de valorisation si elle est finie,
sans lot, filtree par `valuation_type`, sans `lot_price` pour les modes
`priced` / `efp`, sans fees relies a des lots, ou sans derivatives.
- En mode `basis`, sans composant, le fallback metier continue de passer par la
ligne summary quand elle existe.