lot quantity logic

This commit is contained in:
2026-05-13 17:39:13 +02:00
parent 343fc8527c
commit a71ce107f0
6 changed files with 636 additions and 43 deletions

View File

@@ -24,6 +24,7 @@ Statut: `migration partielle`
- `BR-PT-CON-002`: delivery period coherent.
- `BR-PT-CON-003`: lieux stock propages dans Create Contracts.
- `BR-PT-LOT-001`: cycle de vie des lots et des quantites.
- `BR-PT-LOT-002`: quantity contractuelle, execute physique et ligne finie.
- `BR-PT-MAT-001`: Create Contracts multi-lots.
- `BR-PT-SHP-001`: affectation controller.
- `BR-PT-SHP-002`: couts SLA controller.

View File

@@ -41,18 +41,30 @@ physical lot created consumes that specific forecast. The selected `lot.qt`
line is reduced, then the line's virtual lot is recalculated so that it only
represents the remaining open balance.
The immutable quantity consistency rule is:
The first immutable quantity consistency rule is:
```text
sum of the line's physical lots + the line's virtual lot
= the line's contractual/theoretical quantity
```
The second immutable quantity consistency rule is:
```text
sum of lot.qt.lot_quantity for the line's virtual lot_p
= lot.qt.hist.quantity of the line's virtual lot
```
Each physical lot addition must subtract the same quantity from both sides of
this equality: the forecast `lot.qt` line decreases, and the virtual lot
quantity history decreases as well.
This consistency must remain true even if the contractual quantity is adjusted
along the way. The contractual/theoretical quantity is the only quantity that
the user enters on the line. The standard line quantity is a counter: it
follows the contractual quantity as long as there is no physical lot, then it
reflects the sum of the executed physical lots.
the user enters on the line. The standard line quantity is a read-only
technical counter: it is initialized from the contractual quantity as long as
there is no physical lot, then it reflects the sum of the executed physical
lots.
Removing a physical lot is allowed only while its stock move is not finalized.
If the lot was already matched or linked to a shipment, the user must confirm
@@ -65,6 +77,53 @@ quantity states of a lot must be changed only through a dedicated business
action, currently `Do weighing`. This rule ensures that open quantity, virtual
lot, fee, and stock move recalculations remain synchronized.
### BR-PT-LOT-002 - Contractual quantity, physical execution, and finished line
The contractual/theoretical quantity remains the contract basis as long as the
line is not marked as finished. It is therefore the basis for the commercial
line amount, even if physical lots have already been added and the technical
`quantity` field only reflects the sum of those physical lots.
The `quantity` field is not a business input. It is a technical counter:
- before any physical lot exists, it is initialized from the
contractual/theoretical quantity;
- as soon as physical lots exist, it reflects the sum of executed physical
lots;
- it must not drive the contractual amount while the line is not marked as
finished.
The `Mark as finished` checkbox means that the user accepts ignoring the open
balance still carried by the virtual lot. It does not modify the historical
contractual quantity. It does not delete physical lots. It only means that
calculations that should no longer include the open balance may switch to the
physical execution.
Strict rules:
- while `finished = False`, the commercial line amount must be calculated on
`quantity_theorical`;
- when `finished = True`, the remaining open/virtual balance is ignored for
execution calculations and the line amount may therefore use the physical
counter `quantity` again;
- if the finished line has physical lots, the line amount must use the
quantity state defined by the Weight basis of the relevant contract, when
that state exists on the lots;
- `finished` must never erase the initial contract trace;
- `finished` must never hide or delete physical lot PnL.
This rule applies to the amount carried by the contract line. Invoicing remains
a separate flow: it may select a specific quantity state from `lot_qt_hist`
depending on the contract, for example a bill of lading weight on the purchase
side and a Weight Report on the sale side. The line amount must therefore not
try to replace the invoicing quantity selection logic.
The same physical lot can therefore have two different commercial readings: on
the purchase contract, it may be valued with the BL state; on the sale
contract, once matched, it may be valued with another state, for example LR for
Landing Report. The state used always depends on the displayed contract, not on
one global lot state.
### Tolerance and physical quantities
A physical lot may have a quantity that differs from the initial forecast,
@@ -90,7 +149,8 @@ action may break the global quantity consistency rule.
(`Contractual Qt`).
- Sale contractual quantity: `sale.line.quantity_theorical`
(`Th. quantity`).
- Purchase/sale quantity counter: `quantity`.
- Purchase/sale quantity counter: `quantity`, not editable by the user on
trade lines.
- Lot type: `lot.lot.lot_type`, values `virtual` and `physic`.
- Purchase link: `lot.lot.line`.
- Sale link: `lot.lot.sale_line`.
@@ -100,6 +160,10 @@ action may break the global quantity consistency rule.
- Forecast quantity: `lot.qt.lot_quantity`.
- Contract/line tolerances: `tol_min`, `tol_max`, `tol_min_qt`,
`tol_max_qt`, `tol_min_v`, `tol_max_v`.
- Finished line flag: `purchase.line.finished`, `sale.line.finished`.
- Purchase/sale Weight basis: `purchase.purchase.wb`, `sale.sale.wb`.
- Final quantity state associated with the Weight basis:
`purchase.weight.basis.qt_type`.
- Quantity state history: `lot.lot.lot_hist` to `lot.qt.hist`.
- History fields: `lot.qt.hist.quantity_type`, `lot.qt.hist.quantity`,
`lot.qt.hist.gross_quantity`.
@@ -109,14 +173,19 @@ action may break the global quantity consistency rule.
- Purchase: `modules/purchase_trade/purchase.py`, `Line.validate`.
- Sale: `modules/purchase_trade/sale.py`, `SaleLine.validate`.
- On creation and when `quantity_theorical` is entered, if `quantity` is empty
or equal to zero and no physical lot exists, `quantity` is initialized from
`quantity_theorical`.
- If the line is not `created_by_code`, has no lot yet, the product is not a
service, and `quantity != 0`, a `virtual` lot is created.
service, and `quantity_theorical != 0`, a `virtual` lot is created.
- The virtual lot receives its first `lot.qt.hist` entry.
- The open `lot.qt` creation is triggered by
`modules/purchase_trade/lot.py`, `Lot.validate`, through
`createVirtualPart` when no `lot.qt` exists yet for the virtual lot.
- On purchase, the virtual lot feeds `lot.qt.lot_p`.
- On sale, the virtual lot feeds `lot.qt.lot_s` with `lot_p = None`.
- Legacy flows that do not yet have `quantity_theorical` may still use
`quantity` as a fallback, but it is no longer the business input field.
### Contractual quantity update
@@ -149,6 +218,12 @@ free_quantity = target_quantity - sum(already matched or shipped lot.qt)
created.
- The line fees are resynchronized after the update.
Invariants to preserve after every update:
- `sum(physical lots) + virtual lot current quantity = quantity_theorical`;
- `sum(lot.qt.lot_quantity where lot_p = virtual lot) =
virtual lot current quantity`.
Purchase-specific point:
- If `quantity_theorical` was empty during initialization, the code uses the
@@ -221,6 +296,32 @@ Sale-specific point:
- without a physical lot: `quantity` follows the virtual lot;
- with physical lots: `quantity` reflects executed physical quantity.
### Line amount
- Purchase: `purchase.line.on_change_with_amount()`.
- Sale: `sale.line.on_change_with_amount()`.
- While `finished = False`, the amount quantity basis is
`quantity_theorical`.
- When `finished = True`, the amount quantity basis may switch back to
physical execution, because the user has accepted ignoring the remaining open
virtual balance.
- If the finished line has physical lots and the contract has a Weight basis
with `qt_type`, the amount sums physical quantities in that `lot.qt.hist`
state, converted into the line unit.
- Purchase resolves this state through `purchase.purchase.wb.qt_type`.
- Sale resolves this state through `sale.sale.wb.qt_type`.
- The same matched physical lot may therefore contribute to a purchase amount
in BL state and to a sale amount in LR state.
- If `quantity_theorical` is empty in a legacy flow, the code keeps a fallback
to `quantity`.
- If `quantity` is empty on a finished line, the code keeps a fallback to
`quantity_theorical`.
- If the Weight basis state does not exist yet on a lot, the code keeps the
previous fallback instead of treating the amount as final.
- Invoicing remains independent from this line amount rule: invoice lines may
use a selected quantity state from `lot.qt.hist` according to the
contractual invoicing rule.
### Tolerance: current state and point to confirm
The code already exposes tolerances on contracts and lines:
@@ -245,6 +346,17 @@ To confirm / potential gap:
### Nearby tests
- `modules/purchase_trade/tests/test_module.py`
- `test_trade_line_quantity_is_readonly`
- `test_purchase_line_initial_quantity_uses_theoretical_quantity`
- `test_sale_line_initial_quantity_uses_theoretical_quantity`
- `test_trade_line_initial_quantity_does_not_override_existing_counter`
- `test_trade_line_initial_quantity_does_not_run_with_physical_lot`
- `test_purchase_line_amount_uses_theoretical_quantity_until_finished`
- `test_purchase_line_amount_uses_physical_counter_when_finished`
- `test_purchase_line_amount_uses_purchase_weight_basis_when_finished`
- `test_sale_line_amount_uses_theoretical_quantity_until_finished`
- `test_sale_line_amount_uses_physical_counter_when_finished`
- `test_sale_line_amount_uses_sale_weight_basis_when_finished`
- `test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases`
- `test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity`
- `test_purchase_line_write_syncs_open_lot_qt_with_physical_lots`

View File

@@ -41,18 +41,30 @@ de Lots Management, donc depuis une ligne `lot.qt`. Si l'utilisateur choisit
`lot.qt` choisie est réduite, puis le lot virtuel de la ligne est recalculé
pour représenter seulement le reliquat encore ouvert.
La règle immuable de cohérence est :
La première règle immuable de cohérence est :
```text
somme des lots physiques de la ligne + lot virtuel de la ligne
= quantité contractuelle/théorique de la ligne
```
La deuxième règle immuable de cohérence est :
```text
somme des lot.qt.lot_quantity pour le lot_p virtuel de la ligne
= lot.qt.hist.quantity du lot virtuel de la ligne
```
Chaque ajout de lot physique doit retrancher la même quantité des deux côtés
de cette égalité : la ligne `lot.qt` forecastée diminue, et l'historique de
quantité du lot virtuel diminue aussi.
Cette cohérence doit rester vraie même si la quantité contractuelle est
ajustée en cours de route. La quantité contractuelle/théorique est la seule
quantité saisie par l'utilisateur sur la ligne. La quantité standard de la
ligne est un compteur : elle reprend la quantité contractuelle tant qu'il n'y a
pas de lot physique, puis elle reflète la somme des lots physiques exécutés.
ligne est un compteur technique en lecture seule : elle est initialisée depuis
la quantité contractuelle tant qu'il n'y a pas de lot physique, puis elle
reflète la somme des lots physiques exécutés.
Le retrait d'un lot physique est autorisé seulement tant que son mouvement
stock n'est pas finalisé. Si ce lot était déjà matché ou rattaché à un
@@ -66,6 +78,53 @@ une action métier dédiée, aujourd'hui `Do weighing`. Cette règle garantit qu
les recalculs de quantité ouverte, de lot virtuel, de fees et de mouvements
stock restent synchronisés.
### BR-PT-LOT-002 - Quantité contractuelle, exécuté physique et ligne finie
La quantité contractuelle/théorique reste la base du contrat tant que la ligne
n'est pas marquée comme finie. Elle sert donc de base au montant commercial de
la ligne, même si des lots physiques ont déjà été ajoutés et que la quantité
technique `quantity` reflète seulement la somme de ces lots physiques.
Le champ `quantity` n'est pas une saisie métier. Il est un compteur technique :
- avant tout lot physique, il est initialisé depuis la quantité
contractuelle/théorique ;
- dès qu'il existe des lots physiques, il reflète la somme des lots physiques
exécutés ;
- il ne doit pas piloter le montant contractuel tant que la ligne n'est pas
marquée comme finie.
La coche `Mark as finished` indique que l'utilisateur accepte d'ignorer le
reliquat ouvert encore porté par le lot virtuel. Elle ne modifie pas la
quantité contractuelle historique. Elle ne supprime pas les lots physiques. Elle
signifie seulement que les calculs qui ne doivent plus tenir compte du reliquat
ouvert peuvent basculer sur l'exécuté physique.
Règles strictes :
- tant que `finished = False`, le montant commercial de la ligne doit être
calculé sur `quantity_theorical` ;
- quand `finished = True`, le reliquat ouvert/virtuel restant est ignoré pour
les calculs d'exécution et le montant de ligne peut donc revenir au compteur
physique `quantity` ;
- si la ligne finie possède des lots physiques, le montant de ligne doit
utiliser l'état de quantité défini par le Weight basis du contrat concerné,
quand cet état existe sur les lots ;
- `finished` ne doit jamais effacer la trace du contrat initial ;
- `finished` ne doit jamais masquer ou supprimer le PnL des lots physiques.
Cette règle concerne le montant porté par la ligne de contrat. La facturation
reste un flux distinct : elle peut choisir un état de quantité précis dans
`lot_qt_hist` selon le contrat, par exemple un poids BL à l'achat et un Weight
Report à la vente. Le montant de ligne ne doit donc pas essayer de remplacer la
logique de sélection des quantités de facturation.
Le même lot physique peut donc avoir deux lectures commerciales différentes :
sur le contrat d'achat, il peut être valorisé avec l'état BL ; sur le contrat de
vente, une fois matché, il peut être valorisé avec un autre état, par exemple
LR pour Landing Report. L'état utilisé dépend toujours du contrat affiché, pas
d'un état global unique du lot.
### Tolérance et quantités physiques
Un lot physique peut avoir une quantité différente de la prévision initiale,
@@ -92,7 +151,8 @@ des quantités.
(`Contractual Qt`).
- Quantité contractuelle vente : `sale.line.quantity_theorical`
(`Th. quantity`).
- Quantité compteur achat/vente : `quantity`.
- Quantité compteur achat/vente : `quantity`, non éditable par l'utilisateur
dans les lignes trade.
- Type de lot : `lot.lot.lot_type`, valeurs `virtual` et `physic`.
- Lien achat : `lot.lot.line`.
- Lien vente : `lot.lot.sale_line`.
@@ -102,6 +162,10 @@ des quantités.
- Quantité forecast : `lot.qt.lot_quantity`.
- Tolérances contrat/ligne : `tol_min`, `tol_max`, `tol_min_qt`,
`tol_max_qt`, `tol_min_v`, `tol_max_v`.
- Ligne finie : `purchase.line.finished`, `sale.line.finished`.
- Weight basis achat/vente : `purchase.purchase.wb`, `sale.sale.wb`.
- État de quantité final associé au Weight basis :
`purchase.weight.basis.qt_type`.
- Historique des états de quantité : `lot.lot.lot_hist` vers `lot.qt.hist`.
- Champs d'historique : `lot.qt.hist.quantity_type`,
`lot.qt.hist.quantity`, `lot.qt.hist.gross_quantity`.
@@ -111,15 +175,21 @@ des quantités.
- Achat : `modules/purchase_trade/purchase.py`, `Line.validate`.
- Vente : `modules/purchase_trade/sale.py`, `SaleLine.validate`.
- À la création et lors de la saisie de `quantity_theorical`, si `quantity` est
vide ou égale à zéro et qu'aucun lot physique n'existe, `quantity` est
initialisée depuis `quantity_theorical`.
- Si la ligne n'est pas `created_by_code`, qu'elle n'a pas encore de lot, que
le produit n'est pas un service et que `quantity != 0`, un lot `virtual` est
créé.
le produit n'est pas un service et que `quantity_theorical != 0`, un lot
`virtual` est créé.
- Le lot virtuel reçoit une première entrée `lot.qt.hist`.
- La création du `lot.qt` ouvert est déclenchée par
`modules/purchase_trade/lot.py`, `Lot.validate`, via `createVirtualPart`
quand aucun `lot.qt` n'existe encore pour le lot virtuel.
- Pour l'achat, le lot virtuel alimente `lot.qt.lot_p`.
- Pour la vente, le lot virtuel alimente `lot.qt.lot_s` avec `lot_p = None`.
- Les anciens flux qui n'ont pas encore `quantity_theorical` peuvent encore
utiliser `quantity` comme fallback legacy, mais ce n'est plus le champ de
saisie métier.
### Modification de la quantité contractuelle
@@ -153,6 +223,12 @@ free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés)
`lot.qt` libre est créé.
- Les fees de la ligne sont resynchronisés après modification.
Invariants à préserver après chaque modification :
- `sum(physical lots) + virtual lot current quantity = quantity_theorical` ;
- `sum(lot.qt.lot_quantity where lot_p = virtual lot) =
virtual lot current quantity`.
Point spécifique achat :
- Si `quantity_theorical` était vide au moment de l'initialisation, le code
@@ -230,6 +306,32 @@ Point spécifique vente :
- sans physique : `quantity` suit le virtuel ;
- avec physiques : `quantity` reflète l'exécuté physique.
### Montant de ligne
- Achat : `purchase.line.on_change_with_amount()`.
- Vente : `sale.line.on_change_with_amount()`.
- Tant que `finished = False`, la quantité de base du montant est
`quantity_theorical`.
- Quand `finished = True`, la quantité de base du montant peut revenir à
l'exécuté physique, car l'utilisateur a accepté d'ignorer le reliquat
virtuel ouvert.
- Si la ligne finie possède des lots physiques et que le contrat porte un
Weight basis avec `qt_type`, le montant somme les quantités physiques dans
cet état `lot.qt.hist`, converties dans l'unité de la ligne.
- L'achat résout cet état via `purchase.purchase.wb.qt_type`.
- La vente résout cet état via `sale.sale.wb.qt_type`.
- Le même lot physique matché peut donc contribuer à un montant achat en état
BL et à un montant vente en état LR.
- Si `quantity_theorical` est vide dans un ancien flux, le code conserve un
fallback sur `quantity`.
- Si `quantity` est vide sur une ligne finie, le code conserve un fallback sur
`quantity_theorical`.
- Si l'état du Weight basis n'existe pas encore sur un lot, le code conserve le
fallback précédent au lieu de considérer le montant comme final.
- La facturation reste indépendante de cette règle de montant de ligne :
les lignes de facture peuvent utiliser un état de quantité choisi dans
`lot.qt.hist` selon la règle contractuelle de facturation.
### Tolérance : état actuel et point à confirmer
Le code expose déjà les tolérances sur contrats et lignes :
@@ -254,6 +356,17 @@ Le code expose déjà les tolérances sur contrats et lignes :
### Tests proches
- `modules/purchase_trade/tests/test_module.py`
- `test_trade_line_quantity_is_readonly`
- `test_purchase_line_initial_quantity_uses_theoretical_quantity`
- `test_sale_line_initial_quantity_uses_theoretical_quantity`
- `test_trade_line_initial_quantity_does_not_override_existing_counter`
- `test_trade_line_initial_quantity_does_not_run_with_physical_lot`
- `test_purchase_line_amount_uses_theoretical_quantity_until_finished`
- `test_purchase_line_amount_uses_physical_counter_when_finished`
- `test_purchase_line_amount_uses_purchase_weight_basis_when_finished`
- `test_sale_line_amount_uses_theoretical_quantity_until_finished`
- `test_sale_line_amount_uses_physical_counter_when_finished`
- `test_sale_line_amount_uses_sale_weight_basis_when_finished`
- `test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases`
- `test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity`
- `test_purchase_line_write_syncs_open_lot_qt_with_physical_lots`

View File

@@ -1118,6 +1118,47 @@ class QualityAnalysis(ModelSQL,ModelView):
class Line(metaclass=PoolMeta):
__name__ = 'purchase.line'
@classmethod
def __setup__(cls):
super().__setup__()
cls.quantity.readonly = True
@staticmethod
def _is_empty_quantity(quantity):
if quantity in (None, ''):
return True
return Decimal(str(quantity or 0)) == Decimal(0)
@staticmethod
def _has_physical_lot(line):
return any(
getattr(lot, 'lot_type', None) == 'physic'
for lot in (getattr(line, 'lots', None) or []))
@classmethod
def _sync_initial_quantity_from_theorical(cls, line):
if cls._has_physical_lot(line):
return False
if not cls._is_empty_quantity(getattr(line, 'quantity', None)):
return False
quantity = getattr(line, 'quantity_theorical', None)
if cls._is_empty_quantity(quantity):
return False
line.quantity = Decimal(str(quantity)).quantize(Decimal("0.00001"))
return True
@classmethod
def _set_initial_quantity_values(cls, values):
if 'quantity_theorical' not in values:
return
if not cls._is_empty_quantity(values.get('quantity')):
return
quantity = values.get('quantity_theorical')
if cls._is_empty_quantity(quantity):
return
values['quantity'] = Decimal(str(quantity)).quantize(
Decimal("0.00001"))
@classmethod
def default_pricing_rule(cls):
try:
@@ -1150,6 +1191,7 @@ class Line(metaclass=PoolMeta):
def create(cls, vlist):
for values in vlist:
cls._check_delivery_period_values([cls()], values)
cls._set_initial_quantity_values(values)
return super().create(vlist)
def _check_delivery_period(self):
@@ -1286,8 +1328,8 @@ class Line(metaclass=PoolMeta):
return pricing_rule
@fields.depends('product')
def on_change_with_attribute_set(self, name=None):
@fields.depends('product')
def on_change_with_attribute_set(self, name=None):
if self.product and self.product.template and self.product.template.attribute_set:
return self.product.template.attribute_set.id
@@ -1308,7 +1350,14 @@ class Line(metaclass=PoolMeta):
label=attribute.string,
value=attribute.format(value)
))
return " | ".join(filter(None, values))
return " | ".join(filter(None, values))
@fields.depends(
'quantity_theorical', 'quantity', 'lots',
methods=['_recompute_trade_price_fields'])
def on_change_quantity_theorical(self):
if self._sync_initial_quantity_from_theorical(self):
self._recompute_trade_price_fields()
@classmethod
def default_price_type(cls):
@@ -1408,6 +1457,44 @@ class Line(metaclass=PoolMeta):
return self._linked_to_line_price(premium)
return premium
def _get_amount_quantity(self):
if getattr(self, 'finished', False):
weight_basis_quantity = self._get_weight_basis_quantity()
if weight_basis_quantity is not None:
return weight_basis_quantity
quantity = getattr(self, 'quantity', None)
if quantity is None:
quantity = getattr(self, 'quantity_theorical', None)
return quantity
quantity = getattr(self, 'quantity_theorical', None)
if quantity is None:
quantity = getattr(self, 'quantity', None)
return quantity
def _get_weight_basis_quantity(self):
physical_lots = [
lot for lot in (getattr(self, 'lots', None) or [])
if getattr(lot, 'lot_type', None) == 'physic']
if not physical_lots:
return None
wb = getattr(getattr(self, 'purchase', None), 'wb', None)
qt_type = getattr(wb, 'qt_type', None)
if not qt_type:
return None
state_id = getattr(qt_type, 'id', qt_type)
quantity = Decimal(0)
for lot in physical_lots:
hist_types = [
getattr(hist, 'quantity_type', None)
for hist in (getattr(lot, 'lot_hist', None) or [])]
hist_type_ids = [getattr(hist_type, 'id', hist_type)
for hist_type in hist_types]
if qt_type not in hist_types and state_id not in hist_type_ids:
return None
quantity += Decimal(str(
lot.get_current_quantity_converted(state_id, self.unit) or 0))
return quantity
def _get_basis_component_price(self):
price = Decimal(0)
if self.terms:
@@ -1468,15 +1555,17 @@ class Line(metaclass=PoolMeta):
return self.get_price()
@fields.depends(
'type', 'quantity', 'unit_price', 'unit', 'product',
'purchase', '_parent_purchase.currency',
'type', 'quantity', 'quantity_theorical', 'finished',
'unit_price', 'unit', 'product', 'lots',
'purchase', '_parent_purchase.currency', '_parent_purchase.wb',
'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):
if self.type == 'line' and self.unit_price is not None:
quantity = self._get_amount_quantity()
if quantity is None:
return Decimal(0)
currency = self.purchase.currency if self.purchase else None
amount = Decimal(str(self.quantity)) * (
amount = Decimal(str(quantity)) * (
Decimal(self.unit_price or 0) + self._get_premium_price())
if currency:
return currency.round(amount)
@@ -1538,11 +1627,13 @@ class Line(metaclass=PoolMeta):
for record in records:
old_values[record.id] = record.quantity_theorical
super().write(*args)
super().write(*args)
lines = sum(args[::2], [])
for line in lines:
if line.id in old_values:
if cls._sync_initial_quantity_from_theorical(line):
cls.save([line])
new = Decimal(line.quantity_theorical or 0)
virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual']
if not virtual_lots:
@@ -1665,20 +1756,25 @@ class Line(metaclass=PoolMeta):
if pc.triggers:
for tr in pc.triggers:
line.check_from_to(tr)
line.check_pricing()
#no lot need to create one with line quantity
if not line.created_by_code:
if not line.lots and line.product.type != 'service' and line.quantity != Decimal(0):
FeeLots = Pool().get('fee.lots')
LotQtHist = Pool().get('lot.qt.hist')
LotQtType = Pool().get('lot.qt.type')
line.check_pricing()
#no lot need to create one with line quantity
if not line.created_by_code:
if cls._sync_initial_quantity_from_theorical(line):
cls.save([line])
if (not line.lots and line.product.type != 'service'
and line.quantity_theorical
and Decimal(str(line.quantity_theorical or 0)) != Decimal(0)):
FeeLots = Pool().get('fee.lots')
LotQtHist = Pool().get('lot.qt.hist')
LotQtType = Pool().get('lot.qt.type')
Lot = Pool().get('lot.lot')
lot = Lot()
lot.line = line.id
lot.lot_qt = None
lot.lot_unit = None
lot.lot_unit_line = line.unit
lot.lot_quantity = round(Decimal(line.quantity),5)
lot.lot_qt = None
lot.lot_unit = None
lot.lot_unit_line = line.unit
lot.lot_quantity = round(
Decimal(str(line.quantity_theorical)), 5)
lot.lot_gross_quantity = None
lot.lot_status = 'forecast'
lot.lot_type = 'virtual'
@@ -1691,8 +1787,8 @@ class Line(metaclass=PoolMeta):
logger.info("PURCHASE_VALIDATE:%s",lot.lot_quantity)
lqh.gross_quantity = lot.lot_quantity
lot.lot_hist = [lqh]
if line.quantity > 0:
Lot.save([lot])
if Decimal(str(line.quantity_theorical or 0)) > 0:
Lot.save([lot])
#check if fees need to be updated
if line.fees:
for fee in line.fees:

View File

@@ -1077,6 +1077,47 @@ class PriceComposition(metaclass=PoolMeta):
class SaleLine(metaclass=PoolMeta):
__name__ = 'sale.line'
@classmethod
def __setup__(cls):
super().__setup__()
cls.quantity.readonly = True
@staticmethod
def _is_empty_quantity(quantity):
if quantity in (None, ''):
return True
return Decimal(str(quantity or 0)) == Decimal(0)
@staticmethod
def _has_physical_lot(line):
return any(
getattr(lot, 'lot_type', None) == 'physic'
for lot in (getattr(line, 'lots', None) or []))
@classmethod
def _sync_initial_quantity_from_theorical(cls, line):
if cls._has_physical_lot(line):
return False
if not cls._is_empty_quantity(getattr(line, 'quantity', None)):
return False
quantity = getattr(line, 'quantity_theorical', None)
if cls._is_empty_quantity(quantity):
return False
line.quantity = Decimal(str(quantity)).quantize(Decimal("0.00001"))
return True
@classmethod
def _set_initial_quantity_values(cls, values):
if 'quantity_theorical' not in values:
return
if not cls._is_empty_quantity(values.get('quantity')):
return
quantity = values.get('quantity_theorical')
if cls._is_empty_quantity(quantity):
return
values['quantity'] = Decimal(str(quantity)).quantize(
Decimal("0.00001"))
def get_invoice_line(self, lots=None, action=None):
lines = super().get_invoice_line(lots, action)
if action != 'prov' or not lots:
@@ -1133,6 +1174,7 @@ class SaleLine(metaclass=PoolMeta):
def create(cls, vlist):
for values in vlist:
cls._check_delivery_period_values([cls()], values)
cls._set_initial_quantity_values(values)
return super().create(vlist)
def _check_delivery_period(self):
@@ -1296,6 +1338,13 @@ class SaleLine(metaclass=PoolMeta):
value=attribute.format(value)
))
return " | ".join(filter(None, values))
@fields.depends(
'quantity_theorical', 'quantity', 'lots',
methods=['_recompute_trade_price_fields'])
def on_change_quantity_theorical(self):
if self._sync_initial_quantity_from_theorical(self):
self._recompute_trade_price_fields()
@classmethod
def default_price_type(cls):
@@ -1399,6 +1448,44 @@ class SaleLine(metaclass=PoolMeta):
return self._linked_to_line_price(premium)
return premium
def _get_amount_quantity(self):
if getattr(self, 'finished', False):
weight_basis_quantity = self._get_weight_basis_quantity()
if weight_basis_quantity is not None:
return weight_basis_quantity
quantity = getattr(self, 'quantity', None)
if quantity is None:
quantity = getattr(self, 'quantity_theorical', None)
return quantity
quantity = getattr(self, 'quantity_theorical', None)
if quantity is None:
quantity = getattr(self, 'quantity', None)
return quantity
def _get_weight_basis_quantity(self):
physical_lots = [
lot for lot in (getattr(self, 'lots', None) or [])
if getattr(lot, 'lot_type', None) == 'physic']
if not physical_lots:
return None
wb = getattr(getattr(self, 'sale', None), 'wb', None)
qt_type = getattr(wb, 'qt_type', None)
if not qt_type:
return None
state_id = getattr(qt_type, 'id', qt_type)
quantity = Decimal(0)
for lot in physical_lots:
hist_types = [
getattr(hist, 'quantity_type', None)
for hist in (getattr(lot, 'lot_hist', None) or [])]
hist_type_ids = [getattr(hist_type, 'id', hist_type)
for hist_type in hist_types]
if qt_type not in hist_types and state_id not in hist_type_ids:
return None
quantity += Decimal(str(
lot.get_current_quantity_converted(state_id, self.unit) or 0))
return quantity
def get_price(self,lot_premium=0):
return round(
Decimal(self.unit_price or 0)
@@ -1462,13 +1549,14 @@ class SaleLine(metaclass=PoolMeta):
return self.get_price()
@fields.depends(
'type', 'quantity', 'unit_price', 'unit', 'product',
'sale', '_parent_sale.currency',
'type', 'quantity', 'quantity_theorical', 'finished',
'unit_price', 'unit', 'product', 'lots',
'sale', '_parent_sale.currency', '_parent_sale.wb',
'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)) * (
amount = Decimal(str(self._get_amount_quantity() or 0)) * (
Decimal(self.unit_price or 0) + self._get_premium_price())
if currency:
return currency.round(amount)
@@ -1658,6 +1746,8 @@ class SaleLine(metaclass=PoolMeta):
for line in lines:
if line.id not in old_values:
continue
if cls._sync_initial_quantity_from_theorical(line):
cls.save([line])
if old_values[line.id] is None:
continue
old = Decimal(old_values[line.id] or 0)
@@ -1788,13 +1878,19 @@ class SaleLine(metaclass=PoolMeta):
#no lot need to create one with line quantity
logger.info("FROM_VALIDATE_LINE:%s",line.created_by_code)
if not line.created_by_code:
if not line.lots and line.product.type != 'service' and line.quantity != Decimal(0):
if cls._sync_initial_quantity_from_theorical(line):
cls.save([line])
if (not line.lots and line.product.type != 'service'
and line.quantity_theorical
and Decimal(str(line.quantity_theorical or 0)) != Decimal(0)):
Lot = Pool().get('lot.lot')
lot = Lot()
lot.sale_line = line.id
lot.lot_qt = line.quantity
lot.lot_qt = line.quantity_theorical
lot.lot_unit_line = line.unit
lot.lot_quantity = Decimal(str(line.quantity)).quantize(Decimal("0.00001"))#round(line.quantity,5)
lot.lot_quantity = Decimal(
str(line.quantity_theorical)).quantize(
Decimal("0.00001"))
lot.lot_status = 'forecast'
lot.lot_type = 'virtual'
lot.lot_product = line.product
@@ -1805,7 +1901,7 @@ class SaleLine(metaclass=PoolMeta):
lqh.quantity = lot.lot_quantity
lqh.gross_quantity = lot.lot_quantity
lot.lot_hist = [lqh]
if line.quantity > 0:
if Decimal(str(line.quantity_theorical or 0)) > 0:
Lot.save([lot])
#check if fees need to be updated
if line.fees:

View File

@@ -1016,6 +1016,181 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(pricing_model.save.call_args_list[0].args[0][0].unfixed_qt, Decimal('10'))
self.assertEqual(pricing_model.save.call_args_list[1].args[0][0].unfixed_qt, Decimal('12'))
def test_trade_line_quantity_is_readonly(self):
'trade line quantity is a technical counter, not an editable business input'
SaleLine = Pool().get('sale.line')
PurchaseLine = Pool().get('purchase.line')
self.assertTrue(SaleLine.quantity.readonly)
self.assertTrue(PurchaseLine.quantity.readonly)
def test_purchase_line_initial_quantity_uses_theoretical_quantity(self):
'purchase line initializes technical quantity from contractual quantity'
PurchaseLine = Pool().get('purchase.line')
line = Mock(
quantity=None,
quantity_theorical=Decimal('42'),
lots=[],
)
self.assertTrue(PurchaseLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('42.00000'))
def test_sale_line_initial_quantity_uses_theoretical_quantity(self):
'sale line initializes technical quantity from contractual quantity'
SaleLine = Pool().get('sale.line')
line = Mock(
quantity=Decimal('0'),
quantity_theorical=Decimal('24'),
lots=[],
)
self.assertTrue(SaleLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('24.00000'))
def test_trade_line_initial_quantity_does_not_override_existing_counter(self):
'initial sync keeps an existing technical quantity'
PurchaseLine = Pool().get('purchase.line')
physical = Mock(lot_type='virtual')
line = Mock(
quantity=Decimal('10'),
quantity_theorical=Decimal('20'),
lots=[physical],
)
self.assertFalse(PurchaseLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('10'))
def test_trade_line_initial_quantity_does_not_run_with_physical_lot(self):
'initial sync never overwrites the executed physical quantity'
SaleLine = Pool().get('sale.line')
physical = Mock(lot_type='physic')
line = Mock(
quantity=Decimal('0'),
quantity_theorical=Decimal('20'),
lots=[physical],
)
self.assertFalse(SaleLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('0'))
def test_purchase_line_amount_uses_theoretical_quantity_until_finished(self):
'purchase line amount uses contractual quantity while line remains open'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = False
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = None
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
def test_purchase_line_amount_uses_physical_counter_when_finished(self):
'purchase line amount uses executed quantity once line is finished'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = None
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
def test_purchase_line_amount_uses_purchase_weight_basis_when_finished(self):
'purchase line amount uses purchase weight basis for physical lots'
PurchaseLine = Pool().get('purchase.line')
bl = Mock(id=1)
unit = Mock()
lot = Mock(
lot_type='physic',
lot_hist=[Mock(quantity_type=bl)],
)
lot.get_current_quantity_converted.return_value = Decimal('8')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = Mock(wb=Mock(qt_type=bl))
line.unit = unit
line.lots = [lot]
self.assertEqual(line.on_change_with_amount(), Decimal('16'))
lot.get_current_quantity_converted.assert_called_once_with(1, unit)
def test_sale_line_amount_uses_theoretical_quantity_until_finished(self):
'sale line amount uses contractual quantity while line remains open'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = False
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = None
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
def test_sale_line_amount_uses_physical_counter_when_finished(self):
'sale line amount uses executed quantity once line is finished'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = None
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
def test_sale_line_amount_uses_sale_weight_basis_when_finished(self):
'sale line amount uses sale weight basis for physical lots'
SaleLine = Pool().get('sale.line')
bl = Mock(id=1)
lr = Mock(id=2)
unit = Mock()
lot = Mock(
lot_type='physic',
lot_hist=[Mock(quantity_type=bl), Mock(quantity_type=lr)],
)
lot.get_current_quantity_converted.return_value = Decimal('7')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('8')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = Mock(wb=Mock(qt_type=lr))
line.unit = unit
line.lots = [lot]
self.assertEqual(line.on_change_with_amount(), Decimal('14'))
lot.get_current_quantity_converted.assert_called_once_with(2, unit)
def test_pricing_manual_fields_are_editable_except_eod(self):
'manual pricing rows only edit quantity and settlement while derived values stay computed'
Pricing = Pool().get('pricing.pricing')