diff --git a/modules/purchase_trade/docs/business/INDEX.md b/modules/purchase_trade/docs/business/INDEX.md index 6e089be..3eeff12 100644 --- a/modules/purchase_trade/docs/business/INDEX.md +++ b/modules/purchase_trade/docs/business/INDEX.md @@ -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. diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.en.md b/modules/purchase_trade/docs/business/lots-and-quantities.en.md index 739251f..2230d0f 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.en.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.en.md @@ -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` diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.md b/modules/purchase_trade/docs/business/lots-and-quantities.md index 0dd0d9e..6270099 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.md @@ -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` diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 6e37116..2bc51ff 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -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: diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index fd1e5a2..db439e9 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -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: diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index b0431c9..c79170c 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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')