diff --git a/modules/purchase_trade/AGENTS.md b/modules/purchase_trade/AGENTS.md index 31c03c9..8a00366 100644 --- a/modules/purchase_trade/AGENTS.md +++ b/modules/purchase_trade/AGENTS.md @@ -49,6 +49,8 @@ de negoce physique: ## 4) Invariants metier a preserver - Un lot `virtual` est la reference d'ouverture de quantite pour une `purchase.line`. +- Une `sale.line` doit aussi avoir au minimum un lot `virtual`; une valuation + cote sale ne doit donc pas disparaitre juste parce que le lot est `open`. - Le lot physique est le pont principal entre: - `purchase.line` - `sale.line` @@ -59,6 +61,33 @@ de negoce physique: - ne pas multiplier des chemins d'acces concurrents - Le `FREIGHT VALUE` d'un template facture vient du `fee.fee` du shipment dont le produit est `Maritime freight`. +- En valuation / PnL: + - la valeur stockee dans `type` est la cle technique (`pur. priced`, + `sale priced`, `pur. fee`, etc.), pas le label affiche dans l'UI + - les references doivent rester coherentes avec le type de lot: + `Purchase/Open`, `Purchase/Physic`, `Sale/Open`, `Sale/Physic` + - pour une sale matchee, les lignes de valuation purchase generees sur un lot + physique doivent aussi renseigner `sale` et `sale_line` afin de remonter + dans l'onglet PnL de la sale + - une sale non matchee doit etre valorisable "sale-first" et alimenter + `valuation.valuation` / `valuation.valuation.line` + - si une `sale.line` `basis` n'a ni `price_summary` ni `lot_price_sale`, + creer quand meme une ligne `sale priced` avec `price = 0` et `amount = 0` + plutot que de ne rien generer + - le MTM ne doit etre renseigne que pour `pur. priced`, `sale priced` et + `derivative`; jamais pour les fees + - `mtm_price` doit afficher le prix brut de valorisation (sans ratio), alors + que `mtm` reste le montant calcule selon la logique de strategie +- En pricing: + - le `premium` doit impacter le prix total et donc le `amount`, aussi bien + en `priced` qu'en `basis` + - si `linked currency` est active, le `premium` est saisi dans la devise / + unite liee (ex: `USC/LB`) puis converti vers le `unit_price` de la ligne + - en `basis + linked currency`, le `linked_price` doit representer le prix + basis brut (hors premium) dans la devise liee; le `unit_price` final + ajoute ensuite le premium converti + - en valuation `basis`, le premium s'applique a chaque composant, pas + uniquement a une ligne de resume ## 5) Conventions de modification @@ -83,6 +112,13 @@ de negoce physique: - Pour les donnees achat/vente partagees, ne pas supposer qu'une facture de vente doit lire directement sur la `sale.line`: souvent, la verite metier passe par le lot physique et/ou la `account.invoice.line`. +- Dans les ecrans PnL, le label `Sale price` correspond au type stocke + `sale priced`; idem pour `Pur. price` / `pur. priced`. +- Une ligne `basis` sans resume de pricing peut sinon disparaitre de la + valuation si aucun fallback explicite a `0` n'est prevu. +- Le calcul du prix peut diverger entre `unit_price`, `linked_price`, + `lot_price` et valuation si le premium n'est pas traite explicitement dans + chaque maillon. ## 7) Definition of done (module `purchase_trade`) diff --git a/modules/purchase_trade/docs/business-rules.md b/modules/purchase_trade/docs/business-rules.md index 3095758..98e0b69 100644 --- a/modules/purchase_trade/docs/business-rules.md +++ b/modules/purchase_trade/docs/business-rules.md @@ -1,8 +1,8 @@ # Business Rules - Purchase Trade Statut: `draft` -Version: `v0.2` -Derniere mise a jour: `2026-03-27` +Version: `v0.3` +Derniere mise a jour: `2026-04-01` Owner metier: `a completer` Owner technique: `a completer` @@ -100,6 +100,153 @@ Owner technique: `a completer` - Priorite: - `importante` +### BR-PT-004 - La valuation doit couvrir les flux purchase et sale, y compris les sales non matchees + +- Intent: obtenir un PnL coherent cote achat et cote vente, meme lorsqu'une + sale n'est pas encore matchee a une purchase. +- Description: + - Le flux historique de valuation part de `purchase.line` puis remonte vers + les ventes via les lots/lots matchants. + - Le systeme doit egalement savoir valoriser directement une `sale.line` + non matchee ("sale-first"). + - Une sale non matchee doit creer des lignes dans + `valuation.valuation` et `valuation.valuation.line` afin d'apparaitre dans + l'onglet PnL de la sale. +- Resultat attendu: + - pour une `sale.line` non matchee, generer au minimum les types: + - `sale priced` + - `sale fee` + - `derivative` si la ligne porte des derives + - si la sale est matchee via un lot physique, les lignes purchase portees par + ce lot physique doivent aussi renseigner `sale` et `sale_line` + - une sale matchee doit donc voir: + - ses lignes `sale *` + - les lignes purchase portees par le lot physique partage +- Priorite: + - `structurante` + +### BR-PT-005 - Les references de valuation doivent decrire la nature du lot de la ligne + +- Intent: eviter les ambiguïtes dans les ecrans PnL entre lots `open` et lots + `physic`. +- Description: + - La reference affichee dans la valuation doit decrire la ligne elle-meme, + pas son vis-a-vis. + - Les references autorisees pour les lignes de prix sont: + - `Purchase/Open` + - `Purchase/Physic` + - `Sale/Open` + - `Sale/Physic` +- Resultat attendu: + - un lot `virtual` cote purchase ne doit jamais sortir avec la reference + `Purchase/Physic` + - un lot `virtual` cote sale ne doit jamais sortir avec la reference + `Sale/Physic` + - un lot physique matche peut produire: + - une ligne purchase en `Purchase/Physic` + - une ligne sale en `Sale/Physic` + - un open sale matche a un open purchase peut produire des quantites egales + tout en gardant des references differentes (`Purchase/Open` vs `Sale/Open`) +- Priorite: + - `importante` + +### BR-PT-006 - Une sale basis sans prix detaille doit quand meme apparaitre en valuation + +- Intent: ne pas perdre les lignes de PnL lorsque le detail de pricing n'est + pas encore renseigne. +- Description: + - Une `sale.line` de type `basis` peut exister avec un lot `virtual`, sans + `price_summary` et sans `lot_price_sale`. + - Dans ce cas, la valuation doit quand meme creer une ligne `sale priced`. +- Resultat attendu: + - si `price_summary` est vide: + - creer une ligne `sale priced` + - avec `price = 0` + - avec `amount = 0` + - avec un `state` de type `unfixed` + - si `lot_price_sale` est vide sur un lot sale, utiliser `sale_line.unit_price` + comme fallback quand il existe +- Priorite: + - `importante` + +### BR-PT-007 - Le MTM de valuation ne s'applique pas aux fees + +- Intent: distinguer les lignes de prix marquables au marche des lignes de + frais qui ne doivent pas etre mark-to-market. +- Description: + - Le systeme peut renseigner `mtm_price`, `mtm` et `strategy` uniquement pour: + - `pur. priced` + - `sale priced` + - `derivative` + - Les fees (`pur. fee`, `sale fee`, `shipment fee`, `line fee`) ne doivent + jamais porter de valorisation MTM. +- Resultat attendu: + - les lignes de fee doivent conserver: + - `mtm_price = NULL` + - `mtm = NULL` + - `strategy = NULL` + - `mtm_price` doit representer le prix brut de valorisation sans appliquer le + ratio de composant + - `mtm` reste le montant calcule selon la logique de strategie +- Priorite: + - `structurante` + +### BR-PT-008 - Le premium fait partie du prix contractuel en `priced` et en `basis` + +- Intent: garantir que le montant total valorise et facture reflete toujours le + premium/discount saisi sur la ligne. +- Description: + - Le `premium` d'une `purchase.line` ou `sale.line` doit impacter le prix + total quelle que soit la `price_type`. + - Cette regle vaut pour: + - le `unit_price` + - les calculs de `amount` + - la valuation / PnL +- Resultat attendu: + - en `priced`, le prix total = prix de base + premium + - en `basis`, le premium s'ajoute aussi au prix total + - en valuation `basis`, le premium s'applique a chaque composant valorise + (ex: meme premium repete sur chaque bloc ICE) +- Exemple metier: + - `8.30 USC/LB 500 TONS ON ICE MCH'26` + - `8.30 USC/LB 500 TONS ON ICE MAY 26` + - le premium `8.30 USC/LB` s'applique a chaque composant +- Priorite: + - `structurante` + +### BR-PT-009 - En linked currency, le premium est exprime dans la devise/unite liee + +- Intent: respecter la facon dont les traders saisissent les prix sur certains + produits (ex: coton en `USC/LB`). +- Description: + - Quand `enable_linked_currency` est coche, le `premium` est saisi dans la + devise / unite liee, pas dans la devise / unite native de la ligne. + - Le systeme doit convertir ce premium vers le `unit_price` de la ligne pour + les calculs internes. +- Resultat attendu: + - `premium` est interprete dans le repere `linked_currency` / `linked_unit` + - le `unit_price` converti integre ce premium + - les `amount` et valuations bases sur `unit_price` reflectent donc ce + premium converti +- Priorite: + - `structurante` + +### BR-PT-010 - En `basis + linked currency`, le linked price suit le basis brut + +- Intent: rendre lisible la decomposition entre prix basis de marche et premium. +- Description: + - Quand une ligne est en `basis` et `linked currency`, le bloc + `linked_price` doit etre recalcule automatiquement. + - Ce `linked_price` doit representer le prix basis brut, hors premium. + - Le `unit_price` final de la ligne est ensuite obtenu en ajoutant le premium + converti. +- Resultat attendu: + - modification du basis -> mise a jour automatique du `linked_price` + - `linked_price` = base market / basis + - `unit_price` = `linked_price` converti + premium converti +- Priorite: + - `importante` + ## 4) Exemples concrets ### Exemple E1 - Augmentation simple @@ -146,6 +293,8 @@ Owner technique: `a completer` - Fichiers Python concernes: - `modules/purchase_trade/purchase.py` - `modules/purchase_trade/lot.py` + - `modules/purchase_trade/valuation.py` + - `modules/purchase_trade/sale.py` ## 6) Strategie de tests @@ -155,3 +304,11 @@ Pour cette regle, couvrir au minimum: - augmentation sans `lot.qt` ouvert - diminution possible - diminution impossible avec erreur +- valuation purchase/sale sur lot physique matche +- valuation sale-first sur sale non matchee avec lot virtual +- valuation sale `basis` sans `price_summary` +- absence de MTM sur les fees +- premium en `priced` +- premium en `basis` +- premium en `linked currency` +- synchro `basis` -> `linked_price` -> `unit_price` diff --git a/modules/purchase_trade/docs/template-rules.md b/modules/purchase_trade/docs/template-rules.md index 947a35b..1a140ce 100644 --- a/modules/purchase_trade/docs/template-rules.md +++ b/modules/purchase_trade/docs/template-rules.md @@ -126,6 +126,18 @@ Derniere mise a jour: `2026-03-27` - utiliser `fee.get_amount()` - Si le fee a sa propre devise, preferer aussi exposer le symbole de devise depuis le fee plutot que depuis la facture. +### TR-009 - Ne pas dereferencer directement `del_period.description` dans les templates + +- Eviter les expressions du type: + - `sale.lines[0].del_period.description` + - `purchase.lines[0].del_period.description` +- Meme avec un `if ... else`, ces acces sont fragiles dans un `.fodt` et + rendent le debug plus difficile. +- Preferer une propriete Python stable: + - `sale.report_delivery_period_description` + - `purchase.report_delivery_period_description` + - `invoice.report_delivery_period_description` + ## 4) Workflow recommande pour corriger un template en erreur 1. Identifier le placeholder exact qui provoque l'erreur Relatorio. diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index ea11701..65d2e97 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -188,6 +188,16 @@ class Invoice(metaclass=PoolMeta): return trade.report_payment_date return '' + @property + def report_delivery_period_description(self): + trade = self._get_report_trade() + if trade and getattr(trade, 'report_delivery_period_description', None): + return trade.report_delivery_period_description + line = self._get_report_trade_line() + if line and getattr(line, 'del_period', None): + return line.del_period.description or '' + return '' + @property def report_payment_description(self): trade = self._get_report_trade() diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 4b9f9fa..1c55e16 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -384,16 +384,22 @@ class Purchase(metaclass=PoolMeta): return '' @property - def report_delivery(self): - del_date = 'PROMPT' - if self.lines: - if self.lines[0].estimated_date: - delivery_date = [dd.estimated_date for dd in self.lines[0].estimated_date if dd.trigger=='deldate'] - if delivery_date: - del_date = delivery_date[0] - if del_date: - del_date = format_date_en(del_date) - return del_date + def report_delivery(self): + del_date = 'PROMPT' + if self.lines: + if self.lines[0].estimated_date: + delivery_date = [dd.estimated_date for dd in self.lines[0].estimated_date if dd.trigger=='deldate'] + if delivery_date: + del_date = delivery_date[0] + if del_date: + del_date = format_date_en(del_date) + return del_date + + @property + def report_delivery_period_description(self): + if self.lines and self.lines[0].del_period: + return self.lines[0].del_period.description or '' + return '' @property def report_payment_date(self): @@ -543,14 +549,16 @@ class Purchase(metaclass=PoolMeta): OpenPosition = Pool().get('open.position') # OpenPosition.create_from_purchase_line(line) - #line unit_price calculation - if line.price_type == 'basis' and line.lots: #line.price_pricing and line.price_components and - unit_price = line.get_basis_price() - logger.info("VALIDATEPURCHASE:%s",unit_price) - if unit_price != line.unit_price: - line.unit_price = unit_price - logger.info("VALIDATEPURCHASE2:%s",line.unit_price) - Line.save([line]) + #line unit_price calculation + if line.price_type == 'basis' and line.lots: #line.price_pricing and line.price_components and + previous_linked_price = line.linked_price + line.sync_linked_price_from_basis() + unit_price = line.get_basis_price() + logger.info("VALIDATEPURCHASE:%s",unit_price) + if unit_price != line.unit_price or line.linked_price != previous_linked_price: + line.unit_price = unit_price + logger.info("VALIDATEPURCHASE2:%s",line.unit_price) + Line.save([line]) if line.price_type == 'efp': if line.derivatives: for d in line.derivatives: @@ -1076,13 +1084,14 @@ class Line(metaclass=PoolMeta): # ],"Certification",states={'readonly': (Eval('inherit_cer')),}) certif = fields.Many2One('purchase.certification',"Certification",states={'readonly': (Eval('inherit_cer')),}) inherit_cer = fields.Boolean("Inherit certification") - enable_linked_currency = fields.Boolean("Linked currencies") - linked_price = fields.Numeric("Price", digits='unit',states={ - 'invisible': (~Eval('enable_linked_currency')), - }) - linked_currency = fields.Many2One('currency.linked',"Currency",states={ - 'invisible': (~Eval('enable_linked_currency')), - }) + enable_linked_currency = fields.Boolean("Linked currencies") + linked_price = fields.Numeric("Price", digits='unit',states={ + 'invisible': (~Eval('enable_linked_currency')), + 'readonly': Eval('price_type') == 'basis', + }, depends=['enable_linked_currency', 'price_type']) + linked_currency = fields.Many2One('currency.linked',"Currency",states={ + 'invisible': (~Eval('enable_linked_currency')), + }) linked_unit = fields.Many2One('product.uom', 'Unit',states={ 'invisible': (~Eval('enable_linked_currency')), }) @@ -1204,49 +1213,139 @@ class Line(metaclass=PoolMeta): if ps: return sum((e.progress if e.progress else 0) * (e.ratio if e.ratio else 0) / 100 for e in ps) - def getVirtualLot(self): - if self.lots: - return [l for l in self.lots if l.lot_type=='virtual'][0] - - def get_basis_price(self): - price = Decimal(0) - if self.terms: - for t in self.terms: - price += (t.manual_price if t.manual_price else Decimal(0)) - else: + def getVirtualLot(self): + if self.lots: + return [l for l in self.lots if l.lot_type=='virtual'][0] + + def _get_linked_unit_factor(self): + if not (self.enable_linked_currency and self.linked_currency): + return None + factor = Decimal(self.linked_currency.factor or 0) + if not factor: + return None + unit_factor = Decimal(1) + if self.linked_unit: + Uom = Pool().get('product.uom') + unit_factor = Decimal(str( + Uom.compute_qty(self.unit, float(1), self.linked_unit) or 0)) + return factor * unit_factor + + def _linked_to_line_price(self, price): + factor = self._get_linked_unit_factor() + price = Decimal(price or 0) + if not factor: + return price + return round(price * factor, 4) + + def _line_to_linked_price(self, price): + factor = self._get_linked_unit_factor() + price = Decimal(price or 0) + if not factor: + return price + return round(price / factor, 4) + + def _get_premium_price(self): + premium = Decimal(self.premium or 0) + if not premium: + return Decimal(0) + if self.enable_linked_currency and self.linked_currency: + return self._linked_to_line_price(premium) + return premium + + def _get_basis_component_price(self): + price = Decimal(0) + if self.terms: + for t in self.terms: + price += (t.manual_price if t.manual_price else Decimal(0)) + else: for pc in self.price_components: PP = Pool().get('purchase.pricing.summary') - pp = PP.search([('price_component','=',pc.id),('line','=',self.id)]) - if pp: - price += pp[0].price * (pc.ratio / 100) - return round(price,4) + pp = PP.search([('price_component','=',pc.id),('line','=',self.id)]) + if pp: + price += pp[0].price * (pc.ratio / 100) + return round(price,4) + + def get_basis_price(self): + return round(self._get_basis_component_price(), 4) + + def sync_linked_price_from_basis(self): + if self.enable_linked_currency and self.linked_currency: + self.linked_price = self._line_to_linked_price( + self._get_basis_component_price()) + + def get_price(self,lot_premium=0): + return round( + Decimal(self.unit_price or 0) + + Decimal(lot_premium or 0), + 4) + + def get_price_linked_currency(self,lot_premium=0): + return round( + self._linked_to_line_price( + Decimal(self.linked_price or 0) + + Decimal(lot_premium or 0)), + 4) - def get_price(self,lot_premium=0): - return (self.unit_price + Decimal(lot_premium)) if self.unit_price else Decimal(0) + (self.premium if self.premium else Decimal(0)) - - def get_price_linked_currency(self,lot_premium=0): - if self.linked_unit: - Uom = Pool().get('product.uom') - qt = Uom.compute_qty(self.unit, float(1), self.linked_unit) - return round(((self.linked_price + Decimal(lot_premium) + (self.premium if self.premium else Decimal(0))) * self.linked_currency.factor) * Decimal(qt), 4) - else: - return round((self.linked_price + Decimal(lot_premium) + (self.premium if self.premium else Decimal(0))) * self.linked_currency.factor, 4) - - @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','premium','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') - def on_change_with_unit_price(self, name=None): - Date = Pool().get('ir.date') - logger.info("ONCHANGEUNITPRICE:%s",self.unit_price) - if self.price_type == 'basis' and self.lots: #self.price_pricing and self.price_components and - price = self.get_basis_price() - logger.info("ONCHANGEUNITPRICE_IN:%s",price) - return price - if self.enable_linked_currency and self.linked_price and self.linked_currency and self.price_type == 'priced': - return self.get_price_linked_currency() - if self.price_type == 'efp': - if hasattr(self, 'derivatives') and self.derivatives: - for d in self.derivatives: - return round(d.price_index.get_price(Date.today(),self.unit,self.purchase.currency,True),4) - return self.unit_price + @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') + def on_change_with_unit_price(self, name=None): + Date = Pool().get('ir.date') + logger.info("ONCHANGEUNITPRICE:%s",self.unit_price) + if self.price_type == 'basis': + self.sync_linked_price_from_basis() + price = self.get_basis_price() + logger.info("ONCHANGEUNITPRICE_IN:%s",price) + return price + if self.enable_linked_currency and self.linked_price and self.linked_currency and self.price_type == 'priced': + return self.get_price_linked_currency() + if self.price_type == 'efp': + if hasattr(self, 'derivatives') and self.derivatives: + for d in self.derivatives: + return round(d.price_index.get_price(Date.today(),self.unit,self.purchase.currency,True),4) + return self.get_price() + + @fields.depends( + 'type', 'quantity', 'unit_price', 'purchase', '_parent_purchase.currency', + 'premium', 'enable_linked_currency', 'linked_currency', 'linked_unit') + def on_change_with_amount(self): + if (self.type == 'line' + and self.quantity is not None + and self.unit_price is not None): + currency = self.purchase.currency if self.purchase else None + amount = Decimal(str(self.quantity)) * ( + Decimal(self.unit_price or 0) + self._get_premium_price()) + if currency: + return currency.round(amount) + return amount + return Decimal(0) + + @fields.depends(methods=['on_change_with_unit_price', 'on_change_with_amount']) + def _recompute_trade_price_fields(self): + self.unit_price = self.on_change_with_unit_price() + self.amount = self.on_change_with_amount() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_premium(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_price_type(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_enable_linked_currency(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_linked_price(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_linked_currency(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_linked_unit(self): + self._recompute_trade_price_fields() @classmethod def write(cls, *args): diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 82f9c8b..51cb4b2 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -394,16 +394,22 @@ class Sale(metaclass=PoolMeta): return '' @property - def report_delivery(self): - del_date = 'PROMPT' - if self.lines: - if self.lines[0].estimated_date: - delivery_date = [dd.estimated_date for dd in self.lines[0].estimated_date if dd.trigger=='deldate'] - if delivery_date: - del_date = delivery_date[0] - if del_date: - del_date = format_date_en(del_date) - return del_date + def report_delivery(self): + del_date = 'PROMPT' + if self.lines: + if self.lines[0].estimated_date: + delivery_date = [dd.estimated_date for dd in self.lines[0].estimated_date if dd.trigger=='deldate'] + if delivery_date: + del_date = delivery_date[0] + if del_date: + del_date = format_date_en(del_date) + return del_date + + @property + def report_delivery_period_description(self): + if self.lines and self.lines[0].del_period: + return self.lines[0].del_period.description or '' + return '' @property def report_payment_date(self): @@ -547,12 +553,14 @@ class Sale(metaclass=PoolMeta): # OpenPosition = Pool().get('open.position') # OpenPosition.create_from_sale_line(line) - if line.price_type == 'basis' and line.lots: #line.price_pricing and line.price_components and - unit_price = line.get_basis_price() - if unit_price != line.unit_price: - Line = Pool().get('sale.line') - line.unit_price = unit_price - Line.save([line]) + if line.price_type == 'basis' and line.lots: #line.price_pricing and line.price_components and + previous_linked_price = line.linked_price + line.sync_linked_price_from_basis() + unit_price = line.get_basis_price() + if unit_price != line.unit_price or line.linked_price != previous_linked_price: + Line = Pool().get('sale.line') + line.unit_price = unit_price + Line.save([line]) if line.price_type == 'efp': if line.derivatives: for d in line.derivatives: @@ -621,13 +629,14 @@ class SaleLine(metaclass=PoolMeta): # ('bci', 'BCI'), # ],"Certification",states={'readonly': (Eval('inherit_cer')),}) inherit_cer = fields.Boolean("Inherit certification") - enable_linked_currency = fields.Boolean("Linked currencies") - linked_price = fields.Numeric("Price", digits='unit',states={ - 'invisible': (~Eval('enable_linked_currency')), - }) - linked_currency = fields.Many2One('currency.linked',"Currency",states={ - 'invisible': (~Eval('enable_linked_currency')), - }) + enable_linked_currency = fields.Boolean("Linked currencies") + linked_price = fields.Numeric("Price", digits='unit',states={ + 'invisible': (~Eval('enable_linked_currency')), + 'readonly': Eval('price_type') == 'basis', + }, depends=['enable_linked_currency', 'price_type']) + linked_currency = fields.Many2One('currency.linked',"Currency",states={ + 'invisible': (~Eval('enable_linked_currency')), + }) linked_unit = fields.Many2One('product.uom', 'Unit',states={ 'invisible': (~Eval('enable_linked_currency')), }) @@ -757,44 +766,132 @@ class SaleLine(metaclass=PoolMeta): if ps: return sum((e.progress if e.progress else 0) * (e.ratio if e.ratio else 0) / 100 for e in ps) - def getVirtualLot(self): - if self.lots: - return [l for l in self.lots if l.lot_type=='virtual'][0] - - def get_price(self,lot_premium=0): - return (self.unit_price + Decimal(lot_premium) + (self.premium if self.premium else Decimal(0))) if self.unit_price else Decimal(0) - - def get_basis_price(self): - price = Decimal(0) - for pc in self.price_components: - PP = Pool().get('sale.pricing.summary') - pp = PP.search([('price_component','=',pc.id),('sale_line','=',self.id)]) - if pp: - price += pp[0].price * (pc.ratio / 100) - return round(price,4) - - def get_price_linked_currency(self,lot_premium=0): - if self.linked_unit: - Uom = Pool().get('product.uom') - qt = Uom.compute_qty(self.unit, float(1), self.linked_unit) - return round(((self.linked_price + Decimal(lot_premium) + (self.premium if self.premium else Decimal(0))) * self.linked_currency.factor) * Decimal(qt), 4) - else: - return round((self.linked_price + Decimal(lot_premium) + (self.premium if self.premium else Decimal(0))) * self.linked_currency.factor, 4) - - @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','premium','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') - def on_change_with_unit_price(self, name=None): - Date = Pool().get('ir.date') - logger.info("ONCHANGEUNITPRICE:%s",self.unit_price) - if self.price_type == 'basis' and self.lots: #self.price_pricing and self.price_components and - logger.info("ONCHANGEUNITPRICE_IN:%s",self.get_basis_price()) - return self.get_basis_price() - if self.enable_linked_currency and self.linked_price and self.linked_currency and self.price_type == 'priced': - return self.get_price_linked_currency() - if self.price_type == 'efp': - if hasattr(self, 'derivatives') and self.derivatives: + def getVirtualLot(self): + if self.lots: + return [l for l in self.lots if l.lot_type=='virtual'][0] + + def _get_linked_unit_factor(self): + if not (self.enable_linked_currency and self.linked_currency): + return None + factor = Decimal(self.linked_currency.factor or 0) + if not factor: + return None + unit_factor = Decimal(1) + if self.linked_unit: + Uom = Pool().get('product.uom') + unit_factor = Decimal(str( + Uom.compute_qty(self.unit, float(1), self.linked_unit) or 0)) + return factor * unit_factor + + def _linked_to_line_price(self, price): + factor = self._get_linked_unit_factor() + price = Decimal(price or 0) + if not factor: + return price + return round(price * factor, 4) + + def _line_to_linked_price(self, price): + factor = self._get_linked_unit_factor() + price = Decimal(price or 0) + if not factor: + return price + return round(price / factor, 4) + + def _get_premium_price(self): + premium = Decimal(self.premium or 0) + if not premium: + return Decimal(0) + if self.enable_linked_currency and self.linked_currency: + return self._linked_to_line_price(premium) + return premium + + def get_price(self,lot_premium=0): + return round( + Decimal(self.unit_price or 0) + + Decimal(lot_premium or 0), + 4) + + def _get_basis_component_price(self): + price = Decimal(0) + for pc in self.price_components: + PP = Pool().get('sale.pricing.summary') + pp = PP.search([('price_component','=',pc.id),('sale_line','=',self.id)]) + if pp: + price += pp[0].price * (pc.ratio / 100) + return round(price,4) + + def get_basis_price(self): + return round(self._get_basis_component_price(), 4) + + def sync_linked_price_from_basis(self): + if self.enable_linked_currency and self.linked_currency: + self.linked_price = self._line_to_linked_price( + self._get_basis_component_price()) + + def get_price_linked_currency(self,lot_premium=0): + return round( + self._linked_to_line_price( + Decimal(self.linked_price or 0) + + Decimal(lot_premium or 0)), + 4) + + @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') + def on_change_with_unit_price(self, name=None): + Date = Pool().get('ir.date') + logger.info("ONCHANGEUNITPRICE:%s",self.unit_price) + if self.price_type == 'basis': + self.sync_linked_price_from_basis() + logger.info("ONCHANGEUNITPRICE_IN:%s",self.get_basis_price()) + return self.get_basis_price() + if self.enable_linked_currency and self.linked_price and self.linked_currency and self.price_type == 'priced': + return self.get_price_linked_currency() + if self.price_type == 'efp': + if hasattr(self, 'derivatives') and self.derivatives: for d in self.derivatives: return d.price_index.get_price(Date.today(),self.unit,self.sale.currency,True) - return self.get_price() + return self.get_price() + + @fields.depends( + 'type', 'quantity', 'unit_price', 'sale', '_parent_sale.currency', + 'premium', 'enable_linked_currency', 'linked_currency', 'linked_unit') + def on_change_with_amount(self): + if self.type == 'line': + currency = self.sale.currency if self.sale else None + amount = Decimal(str(self.quantity or 0)) * ( + Decimal(self.unit_price or 0) + self._get_premium_price()) + if currency: + return currency.round(amount) + return amount + return Decimal(0) + + @fields.depends(methods=['on_change_with_unit_price', 'on_change_with_amount']) + def _recompute_trade_price_fields(self): + self.unit_price = self.on_change_with_unit_price() + self.amount = self.on_change_with_amount() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_premium(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_price_type(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_enable_linked_currency(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_linked_price(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_linked_currency(self): + self._recompute_trade_price_fields() + + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_linked_unit(self): + self._recompute_trade_price_fields() def check_from_to(self,tr): if tr.pricing_period: diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index 4271ea7..bf71f45 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -188,9 +188,22 @@ class ValuationBase(ModelSQL): @staticmethod def _supports_strategy_mtm(values): return values and values.get('type') in {'pur. priced', 'sale priced'} + + @staticmethod + def _get_basis_component_total(record): + getter = getattr(record, '_get_basis_component_price', None) + if getter: + return Decimal(getter() or 0) + return Decimal(0) + + @classmethod + def _get_basis_premium_delta(cls, record): + total = Decimal(record.get_basis_price() or 0) + components = cls._get_basis_component_total(record) + return round(total - components, 4) @classmethod - def _build_basis_pnl(cls, *, line, lot, sale_line, pc, sign): + def _build_basis_pnl(cls, *, line, lot, sale_line, pc, sign, extra_price=Decimal(0)): Currency = Pool().get('currency.currency') Date = Pool().get('ir.date') values = cls._base_pnl( @@ -210,6 +223,7 @@ class ValuationBase(ModelSQL): logger.info("COMPONENTS:%s",c) if c: price = c[0].manual_price + price = Decimal(price or 0) + Decimal(extra_price or 0) values.update({ 'reference': f"{pc.get_name()} / {pc.ratio}%", @@ -305,8 +319,11 @@ class ValuationBase(ModelSQL): for lot in line.lots: if line.price_type == 'basis': + premium_delta = cls._get_basis_premium_delta(line) for pc in line.price_summary or []: - values = cls._build_basis_pnl(line=line, lot=lot, sale_line=None, pc=pc, sign=-1) + values = cls._build_basis_pnl( + line=line, lot=lot, sale_line=None, pc=pc, sign=-1, + extra_price=premium_delta) if values and lot.sale_line: values['sale'] = lot.sale_line.sale.id values['sale_line'] = lot.sale_line.id @@ -361,8 +378,11 @@ class ValuationBase(ModelSQL): continue if sl_line.price_type == 'basis': + premium_delta = cls._get_basis_premium_delta(sl_line) for pc in sl_line.price_summary or []: - values = cls._build_basis_pnl(line=line, lot=sl, sale_line=sl_line, pc=pc, sign=+1) + values = cls._build_basis_pnl( + line=line, lot=sl, sale_line=sl_line, pc=pc, sign=+1, + extra_price=premium_delta) if sl_line.mtm and cls._supports_strategy_mtm(values): for strat in line.mtm: values['mtm_price'] = cls._get_strategy_mtm_price(strat, sl_line) @@ -400,7 +420,7 @@ class ValuationBase(ModelSQL): return price_lines @classmethod - def _build_basis_pnl_from_sale_line(cls, *, sale_line, lot, pc): + def _build_basis_pnl_from_sale_line(cls, *, sale_line, lot, pc, extra_price=Decimal(0)): Currency = Pool().get('currency.currency') Date = Pool().get('ir.date') values = cls._base_sale_pnl( @@ -410,7 +430,7 @@ class ValuationBase(ModelSQL): ) qty = lot.get_current_quantity_converted() - price = pc.price + price = Decimal(pc.price or 0) + Decimal(extra_price or 0) values.update({ 'reference': f"{pc.get_name()} / {pc.ratio}%", @@ -506,7 +526,7 @@ class ValuationBase(ModelSQL): values = cls._build_simple_pnl_from_sale_line( sale_line=sale_line, lot=lot, - price=Decimal(0), + price=Decimal(sale_line.unit_price or 0), state='unfixed', pnl_type='sale priced' ) @@ -523,9 +543,11 @@ class ValuationBase(ModelSQL): price_lines.append(values) continue + premium_delta = cls._get_basis_premium_delta(sale_line) for pc in summaries: values = cls._build_basis_pnl_from_sale_line( - sale_line=sale_line, lot=lot, pc=pc) + sale_line=sale_line, lot=lot, pc=pc, + extra_price=premium_delta) if sale_line.mtm and cls._supports_strategy_mtm(values): for strat in sale_line.mtm: values['mtm_price'] = cls._get_strategy_mtm_price(strat, sale_line) diff --git a/modules/purchase_trade/view/purchase_line_tree_sequence.xml b/modules/purchase_trade/view/purchase_line_tree_sequence.xml index 18a7c67..51b3e08 100755 --- a/modules/purchase_trade/view/purchase_line_tree_sequence.xml +++ b/modules/purchase_trade/view/purchase_line_tree_sequence.xml @@ -3,8 +3,9 @@ this repository contains the full copyright notices and license terms. --> + - \ No newline at end of file + diff --git a/modules/purchase_trade/view/sale_line_tree_sequence.xml b/modules/purchase_trade/view/sale_line_tree_sequence.xml index 18a7c67..51b3e08 100755 --- a/modules/purchase_trade/view/sale_line_tree_sequence.xml +++ b/modules/purchase_trade/view/sale_line_tree_sequence.xml @@ -3,8 +3,9 @@ this repository contains the full copyright notices and license terms. --> + - \ No newline at end of file + diff --git a/modules/sale/sale_ict.fodt b/modules/sale/sale_ict.fodt index d944063..1627c8c 100644 --- a/modules/sale/sale_ict.fodt +++ b/modules/sale/sale_ict.fodt @@ -4202,7 +4202,7 @@ - <sale.lines[0].del_period.description if sale.lines[0].del_period else ''> + <sale.report_delivery_period_description> @@ -4389,4 +4389,4 @@ - \ No newline at end of file +