This commit is contained in:
2026-05-14 10:25:39 +02:00
parent b0a0d31528
commit 0d034816f7
2 changed files with 462 additions and 656 deletions

View File

@@ -5,382 +5,291 @@ Mirror page: [lots-and-quantities.md](lots-and-quantities.md)
Status: `partial migration`
Last code verification: `2026-05-13`
This page consolidates the former rules `BR-PT-LOT-001`,
`BR-PT-LOT-002`, and `BR-PT-LOT-003` around one functional rule: the life
cycle of open, forecasted, and physical quantities.
This page consolidates `BR-PT-LOT-001`, `BR-PT-LOT-002`, and
`BR-PT-LOT-003`.
Goal: drive quantity rules from a readable business definition, then secure
them with Python guards and SQL diagnostics.
## Consultant Business Rule
## Key Points
### BR-PT-LOT-001 - Lot and quantity life cycle
- A trade line has exactly one virtual lot.
- The virtual lot represents the global open balance of the line.
- `lot.qt` represents the operational forecast: matching, forecast sale,
transport, shipment.
- Physical lots consume a `lot.qt` line.
- `quantity_theorical` is the business quantity entered by the user.
- `quantity` is a read-only technical counter.
- The line amount uses theoretical quantity while the line is not finished.
- A finished line may switch back to physical execution, possibly using the
Weight basis quantity state.
- Invoicing chooses its own quantity states from `lot.qt.hist`.
- Invariants are enforced by Python and auditable with SQL.
When a purchase line or sale line is created, the system creates one virtual
lot linked to that line. This virtual lot represents the contract quantity that
is still open. Its initial quantity is the contractual or theoretical quantity
defined on the line.
## Consultant Rules
At the same time, the system adds one open line in `lot.qt`. This line is the
basis for commercial and logistics forecasts: forecast sale, future matching,
planned transport, shipment, and so on. `lot.qt` therefore carries the
operational forecast, while the virtual lot remains the global representation
of the open balance in `lot.lot`.
### BR-PT-LOT-001 - Virtual lot / forecast / physical life cycle
Example: an open quantity `P1` can be progressively subdivided in `lot.qt` to
forecast several sales or transports:
- When a `purchase line` or `sale line` is created:
- one virtual lot is created;
- its initial quantity is the contractual/theoretical quantity;
- one open `lot.qt` line is created.
- Virtual lot = global open balance.
- `lot.qt` = forecast allocation of that balance.
- Example split of an open balance `P1`:
- `P1S1T1`
- `P1S1T2`
- `P1S2T3`
- `P1S2T4`
- These splits do not create several virtual lots.
- A physical lot is created from a specific `lot.qt` line.
- The physical lot consumes that forecast.
- The `lot.qt` line decreases.
- The virtual lot is recalculated.
- `P1S1T1`
- `P1S1T2`
- `P1S2T3`
- `P1S2T4`
### BR-PT-LOT-002 - Contractual quantity, counter quantity, finished line
These subdivisions do not create several virtual lots for `P1`. They only
describe the forecast allocation of the open quantity.
- `quantity_theorical` is the business quantity.
- `quantity` is not a business input.
- Before any physical lot:
- `quantity` is initialized from `quantity_theorical`.
- After physical lots are added:
- `quantity` reflects physical execution.
- While `finished = False`:
- the line amount uses `quantity_theorical`.
- When `finished = True`:
- the user accepts ignoring the open balance;
- the line amount may switch back to physical execution;
- the initial contract trace remains unchanged;
- physical lots and their PnL remain visible.
- If a Weight basis exists on the contract:
- purchase: state from `purchase.purchase.wb.qt_type`;
- sale: state from `sale.sale.wb.qt_type`;
- the same physical lot may be read as BL on purchase and LR on sale.
- Invoicing remains independent:
- it may select a specific state in `lot.qt.hist`;
- example: BL on purchase, Weight Report or LR on sale.
When physical lots are added, they are created from a specific Lots Management
line, therefore from a `lot.qt` line. If the user selects `P1S1T1`, the
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.
### BR-PT-LOT-003 - Quantity Invariants
The first immutable quantity consistency rule is:
Conservation invariant:
```text
sum of the line's physical lots + the line's virtual lot
= the line's contractual/theoretical quantity
sum(physical lots) + virtual lot quantity = quantity_theorical
```
The second immutable quantity consistency rule is:
Open forecast invariant:
```text
sum of lot.qt.lot_quantity for the line's virtual lot_p
= lot.qt.hist.quantity of the line's virtual lot
sum(non-zero lot.qt of the virtual lot) = max(virtual lot quantity, 0)
```
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.
Details:
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 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.
- Purchase: sum all `lot.qt` where `lot_p = virtual lot`, with or without
`lot_s`.
- Sale: sum all `lot.qt` where `lot_s = virtual lot`, with or without `lot_p`.
- `lot.qt = 0` rows are ignored:
- they may keep the memory of a consumed forecast;
- `lot.qt` never goes below zero.
- The virtual lot may become negative:
- it compensates the gap between `quantity_theorical` and physical execution;
- in that case expected open forecast remains zero.
- A non-zero `lot.qt` without `lot_p` and without `lot_s` is forbidden.
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
the action. The removal restores the quantity into the `lot.qt` line that
carried the forecast, matching, or transport context used to create the
physical lot.
### BR-PT-LOT-004 - Quantity history and weighing
The quantity history of a lot is never a direct data-entry area. The different
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.
- `lot.qt.hist` carries lot quantity states.
- The `lot.lot` form is not a direct entry area for states.
- Updates are allowed only through `Do weighing`.
- A virtual lot does not receive manual packing:
- `lot_qt` is not editable;
- `lot_unit` is not editable.
### BR-PT-LOT-002 - Contractual quantity, physical execution, and finished line
### Tolerances
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,
within the contract tolerance limits. Tolerance must be read as a global
tolerance on the line or contract, not as an independent tolerance per
transport.
If a physical lot consumes more or less than its forecast, this must
dynamically reduce or increase the remaining tolerance for the next physical
lots of the same line. No add, weighing, deletion, or contractual adjustment
action may break the global quantity consistency rule.
- Tolerance is global on the line or contract.
- It is not independent per transport.
- A physical lot may consume more or less than its forecast.
- This over/under execution consumes or restores remaining tolerance.
- Current gap: full remaining-tolerance control in `LotQt.add_physical_lots` /
`LotQt.add_physical_lot` still needs confirmation.
## Developer Section
### Main models and fields
### Key Fields
- Purchase line: `purchase.line`.
- Sale line: `sale.line`.
- Lot: `lot.lot`.
- Forecast / open quantity: `lot.qt`.
- Lot quantity history: `lot.qt.hist`.
- Purchase contractual quantity: `purchase.line.quantity_theorical`
(`Contractual Qt`).
- Sale contractual quantity: `sale.line.quantity_theorical`
(`Th. 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`.
- Shipment links: `lot_shipment_in`, `lot_shipment_internal`,
`lot_shipment_out`.
- Purchase/sale forecast matching: `lot.qt.lot_p`, `lot.qt.lot_s`.
- 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`.
- Lot packing fields: `lot.lot.lot_qt`, `lot.lot.lot_unit`.
- Purchase line: `purchase.line`
- Sale line: `sale.line`
- Lot: `lot.lot`
- Forecast: `lot.qt`
- History: `lot.qt.hist`
- Purchase business quantity: `purchase.line.quantity_theorical`
- Sale business quantity: `sale.line.quantity_theorical`
- Technical counter: `quantity`
- Finished line: `purchase.line.finished`, `sale.line.finished`
- Virtual / physical lot: `lot.lot.lot_type = virtual / physic`
- Purchase link: `lot.lot.line`
- Sale link: `lot.lot.sale_line`
- Purchase forecast: `lot.qt.lot_p`
- Sale forecast: `lot.qt.lot_s`
- Forecast quantity: `lot.qt.lot_quantity`
- Weight basis: `purchase.purchase.wb`, `sale.sale.wb`
- Weight basis state: `purchase.weight.basis.qt_type`
- Packing: `lot.lot.lot_qt`, `lot.lot.lot_unit`
- Tolerances: `tol_min`, `tol_max`, `tol_min_qt`, `tol_max_qt`,
`tol_min_v`, `tol_max_v`
### Line and virtual lot creation
### Line / Virtual Lot Creation
- 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_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.
- Purchase: `purchase.py`, `Line.validate`
- Sale: `sale.py`, `SaleLine.validate`
- If `quantity_theorical` is entered and `quantity` is empty or zero:
- `quantity` is initialized from `quantity_theorical`;
- only if no physical lot exists.
- If the line is eligible:
- not `created_by_code`;
- no lot yet;
- non-service product;
- `quantity_theorical != 0`;
- create one `virtual` lot.
- The virtual lot receives a first `lot.qt.hist` entry.
- `Lot.validate` creates the open `lot.qt` through `createVirtualPart`.
### Contractual quantity update
### Updating `quantity_theorical`
Verified code state:
- Purchase: `purchase.py`, `Line.write`.
- Sale: `sale.py`, `SaleLine.write`.
- Updating `quantity_theorical` recalculates a target virtual quantity:
- Purchase: `purchase.py`, `Line.write`
- Sale: `sale.py`, `SaleLine.write`
- Virtual lot target:
```text
target_quantity = quantity_theorical - sum(converted physical lots)
```
- If this target quantity becomes negative, the code blocks with
`Please unlink or unmatch lot`.
- The free `lot.qt` is then resynchronized while taking into account `lot.qt`
lines that are already allocated, meaning already matched or linked to a
shipment:
- If `target_quantity < 0`:
- block with `Please unlink or unmatch lot`.
- Free `lot.qt` target:
```text
free_quantity = target_quantity - sum(already matched or shipped lot.qt)
```
- If `free_quantity` becomes negative, the code blocks with
`Please unlink or unmatch lot`.
- If the virtual lot does not already carry the target quantity, the code calls
`vlot.set_current_quantity(target_quantity, target_quantity, 1)`.
- If a free `lot.qt` exists, its quantity is replaced with `free_quantity`.
- If no free `lot.qt` exists and `free_quantity > 0`, a new free `lot.qt` is
created.
- The line fees are resynchronized after the update.
- If `free_quantity < 0`:
- block with `Please unlink or unmatch lot`.
- If a free `lot.qt` exists:
- replace its quantity.
- If no free `lot.qt` exists and `free_quantity > 0`:
- create a new `lot.qt`.
- Line fees are resynchronized.
Invariants to preserve after every update:
### Adding Physical Lots
- `sum(physical lots) + virtual lot current quantity = quantity_theorical`;
- `sum(non-zero lot.qt.lot_quantity where lot_p = virtual lot) =
max(virtual lot current quantity, 0)`.
- For a purchase virtual lot, the second invariant sums all `lot.qt` lines
where `lot_p = virtual lot`, whether they are matched to a `lot_s` or not.
- For a sale virtual lot, the second invariant sums all `lot.qt` lines where
`lot_s = virtual lot`, whether they are linked to a `lot_p` or not.
- A zero `lot.qt` row is completely ignored by this check: it is the memory of
a consumed forecast and `lot.qt` never goes below zero.
- If the virtual lot becomes negative to compensate the difference between
`quantity_theorical` and physical execution, the expected `lot.qt` therefore
remains zero.
- A non-zero `lot.qt` row without both `lot_p` and `lot_s` is blocked by
`lot.qt.validate`.
- The technical guard is centralized in
`lot.lot.assert_lines_quantity_consistency()` and must be called at the end
of flows that modify lots, matching, transport, weighing, or the contractual
quantity.
- Wizard: `lot.add`
- Methods:
- `LotQt.add_physical_lots`
- `LotQt.add_physical_lot`
- Mandatory source: one `lot.qt` line.
- Direct add from a physical lot is refused.
- Physical add on sale side through this wizard is refused: use
`Apply matching`.
- The physical lot inherits:
- purchase line;
- matched sale, if any;
- shipment;
- product;
- unit;
- quantities;
- premium;
- chunk key.
- After creation:
- source `lot.qt` is reduced;
- `lot.qt` cannot become negative;
- virtual lot is recalculated;
- `quantity` is recalculated;
- moves and fees are updated when needed.
Purchase-specific point:
### Removing Physical Lots
- If `quantity_theorical` was empty during initialization, the code uses the
current virtual lot quantity as the baseline to avoid doubling the open
`lot.qt`.
- Wizard: `lot.remove`
- Open lot: removal forbidden.
- Lot with `stock.move`:
- move must be `draft`.
- Matched or shipped lot:
- confirmable warning.
- Effects:
- draft move deletion;
- restore quantity into `lot.qt`;
- restore context through shipment, `getVlot_p()`, `getVlot_s()`;
- recalculate virtual lot, `quantity`, fees.
Sale-specific point:
### Weighing / Quantity States
- If the previous `quantity_theorical` is empty, `SaleLine.write` does not yet
run this resynchronization.
- Wizard: `lot.weighing`
- UI action: `Do weighing`
- Writes or updates `lot.qt.hist`.
- May update `lot_state`.
- Synchronizes:
- lot;
- open quantities;
- fees.
- `lot.qt.hist` views are consultative.
### Adding physical lots
### Quantity Counter `quantity`
- Wizard: `modules/purchase_trade/lot.py`, `lot.add`.
- Main method: `LotQt.add_physical_lots`.
- Unit creation method: `LotQt.add_physical_lot`.
- Adding starts from a `lot.qt` line selected in Lots Management.
- The code refuses direct addition from a physical lot.
- The code refuses physical addition on the sale side through this wizard and
asks the user to use `Apply matching`.
- The new physical lot inherits the context of the `lot.qt` line: purchase
line, matched sale if present, shipment, product, unit, quantities, premium,
and chunk key.
- After creation, the code reduces the source `lot.qt` quantity by the total
physical quantity created and never lets the line go below zero.
- Saving/validating the physical lot recalculates:
- the line's virtual lot through `_recompute_virtual_lot`;
- the line quantity counter through `_recalc_line_quantity`;
- linked stock moves when needed;
- fees linked to the lot, line, or shipment.
- Method: `Lot._recalc_line_quantity`
- Without physical lots:
- `quantity` follows the virtual lot.
- With physical lots:
- `quantity` sums physical lots only.
- `quantity` is readonly on trade lines.
### Removing physical lots
### Line Amount
- Wizard: `modules/purchase_trade/lot.py`, `lot.remove`.
- Removing an open lot is forbidden.
- If the physical lot has a `stock.move`, that move must be in `draft` state.
- If the lot is matched or shipped, a confirmable warning is displayed.
- The code first deletes the draft move when present.
- The code restores the physical quantity into `lot.qt` while reusing the
context:
- original shipment through `lot.lot_shipment_origin`;
- sale virtual lot through `getVlot_s()` if the lot was matched;
- purchase virtual lot through `getVlot_p()` in `updateVirtualPart`.
- If a compatible `lot.qt` line already exists, it is incremented.
- Otherwise, a new `lot.qt` line is created.
- Deleting the physical lot then triggers recalculation of the virtual lot,
the line quantity counter, and fees.
- Purchase: `purchase.line.on_change_with_amount()`
- Sale: `sale.line.on_change_with_amount()`
- Helpers:
- `_get_amount_quantity()`
- `_get_weight_basis_quantity()`
- Priorities:
- `finished = False`: `quantity_theorical`
- `finished = True` + usable Weight basis: physical sum in that state
- `finished = True` without usable Weight basis: `quantity`
- legacy fallback: `quantity` if `quantity_theorical` is empty
### Quantity history and weighing
### Python Guards
- History is carried by `lot.qt.hist`.
- `lot.qt.hist` represents the different quantity states of a lot.
- Historical quantities must not be changed directly from the `lot.lot` form.
- The `lot.lot` view displays `lot_hist` as read-only.
- `lot.qt.hist` views are consultative: no direct editing from tree or form.
- The functional change path is the `lot.weighing` wizard, exposed by the
`Do weighing` action.
- The wizard writes or updates the `lot.qt.hist` entry for the selected
`lot_state`, then synchronizes the lot, open quantities, and fees.
- A virtual lot must not receive packing data entry from the lot form:
`lot_qt` and `lot_unit` are not editable for `lot_type = virtual`.
- Central check:
- `lot.lot.assert_lines_quantity_consistency()`
- Non-zero orphan `lot.qt` block:
- `lot.qt.validate`
- Called after:
- `quantity_theorical` update;
- physical lot creation / deletion;
- matching / unmatching;
- shipping / unshipping;
- weighing.
### Line quantity counter
### SQL Diagnostic
- Method: `lot.py`, `Lot._recalc_line_quantity`.
- If the line has only one lot, `quantity` takes the current quantity of that
lot.
- If the line has several lots, `quantity` sums physical lots only.
- This matches the functional rule:
- without a physical lot: `quantity` follows the virtual lot;
- with physical lots: `quantity` reflects executed physical quantity.
- Script:
- [sql/quantity_consistency_checks.sql](sql/quantity_consistency_checks.sql)
- Use:
- test database audit;
- historical data audit;
- qualification before repair.
- The script completely ignores `lot.qt = 0`.
### 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:
- Purchase: `purchase.purchase.tol_min`, `purchase.purchase.tol_max`,
`purchase.line.tol_min`, `purchase.line.tol_max`.
- Sale: `sale.sale.tol_min`, `sale.sale.tol_max`, `sale.line.tol_min`,
`sale.line.tol_max`.
- Display functions: `get_tol_min`, `get_tol_max`.
- Add wizard: `lot.add.line.tol_min`, `lot.add.line.tol_max`, with defaults
from the source contract.
To confirm / potential gap:
- In `LotQt.add_physical_lots` and `LotQt.add_physical_lot`, I do not yet see
a complete control that computes remaining global tolerance while taking
already created physical lots into account.
- The consultant rule above about dynamic global tolerance must therefore be
considered a target business rule to verify/implement before marking it
`active`.
### Nearby tests
## 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`
- `test_purchase_line_write_syncs_virtual_fee_quantity`
- `test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot`
To add if this rule becomes regression-sensitive:
- verify that `lot_hist` is read-only on the `lot.lot` form;
- verify that `Do weighing` can still create or update a quantity state;
- verify that a virtual lot does not allow direct entry of `lot_qt` and
`lot_unit`.
- Existing coverage:
- readonly `quantity`;
- initialization from `quantity_theorical`;
- protection when physical lots exist;
- amount on theoretical / physical / Weight basis;
- virtual lot resynchronization;
- blocking when open quantity is not enough.
- Tests to add:
- readonly `lot_hist`;
- `Do weighing` creates or updates a state;
- virtual lot without direct `lot_qt` / `lot_unit` entry;
- SQL checks replayed on inconsistent datasets.

View File

@@ -1,398 +1,295 @@
# Lots et quantités
Langue: `fr`
Page miroir: [lots-and-quantities.en.md](lots-and-quantities.en.md)
Statut: `migration partielle`
Dernière vérification code: `2026-05-13`
Langue : `fr`
Page miroir : [lots-and-quantities.en.md](lots-and-quantities.en.md)
Statut : `migration partielle`
Dernière vérification code : `2026-05-13`
Cette page consolide les anciennes règles `BR-PT-LOT-001`,
`BR-PT-LOT-002` et `BR-PT-LOT-003` autour d'une règle fonctionnelle unique :
le cycle de vie des quantités ouvertes, forecastées et physiques.
Cette page consolide `BR-PT-LOT-001`, `BR-PT-LOT-002` et `BR-PT-LOT-003`.
Objectif : piloter les règles de quantité depuis une définition métier lisible,
puis les sécuriser par des checks Python et des diagnostics SQL.
## Règle business consultant
## À retenir
### BR-PT-LOT-001 - Cycle de vie des lots et des quantités
- Une ligne trade possède un seul lot virtuel.
- Le lot virtuel représente le solde ouvert global de la ligne.
- `lot.qt` représente le forecast opérationnel : matching, vente prévue,
transport, shipment.
- Les lots physiques consomment une ligne `lot.qt`.
- `quantity_theorical` est la quantité métier saisie.
- `quantity` est un compteur technique non éditable.
- Le montant de ligne utilise la quantité théorique tant que la ligne n'est pas
finie.
- Une ligne finie peut revenir à l'exécuté physique, éventuellement dans l'état
de quantité du Weight basis.
- La facturation choisit ses propres états de quantité dans `lot.qt.hist`.
- Les invariants sont contrôlés par Python et auditables en SQL.
Lors de la création d'une ligne d'achat ou de vente, le système crée un lot
virtuel associé à cette ligne. Ce lot virtuel représente la quantité encore
ouverte du contrat. Sa quantité initiale reprend la quantité contractuelle ou
théorique de la ligne.
## Règles consultant
En parallèle, le système ajoute une ligne ouverte dans `lot.qt`. Cette ligne
sert de base aux prévisions commerciales et logistiques : vente prévisionnelle,
matching futur, transport planifié, shipment, etc. `lot.qt` porte donc le
forecast opérationnel, tandis que le lot virtuel reste la représentation
globale du solde ouvert dans `lot.lot`.
### BR-PT-LOT-001 - Cycle de vie lot virtuel / forecast / physique
Exemple : une quantité ouverte `P1` peut être progressivement subdivisée dans
`lot.qt` pour prévoir plusieurs ventes ou transports :
- À la création d'une `purchase line` ou `sale line` :
- un lot virtuel est créé ;
- sa quantité initiale reprend la quantité contractuelle/théorique ;
- une ligne ouverte est créée dans `lot.qt`.
- Le lot virtuel = solde ouvert global.
- `lot.qt` = découpage prévisionnel de ce solde.
- Exemple de découpage d'un solde `P1` :
- `P1S1T1`
- `P1S1T2`
- `P1S2T3`
- `P1S2T4`
- Ces découpages ne créent pas plusieurs lots virtuels.
- Un lot physique est créé depuis une ligne `lot.qt` précise.
- Le lot physique consomme cette prévision.
- La ligne `lot.qt` diminue.
- Le lot virtuel est recalculé.
- `P1S1T1`
- `P1S1T2`
- `P1S2T3`
- `P1S2T4`
### BR-PT-LOT-002 - Quantité contractuelle, quantité compteur, ligne finie
Ces subdivisions ne créent pas plusieurs lots virtuels pour `P1`. Elles
décrivent seulement la répartition prévisionnelle de la quantité ouverte.
- `quantity_theorical` est la quantité métier.
- `quantity` n'est pas une saisie métier.
- Avant lot physique :
- `quantity` est initialisée depuis `quantity_theorical`.
- Après ajout de lots physiques :
- `quantity` reflète l'exécuté physique.
- Tant que `finished = False` :
- le montant de ligne utilise `quantity_theorical`.
- Quand `finished = True` :
- l'utilisateur accepte d'ignorer le reliquat ouvert ;
- le montant de ligne peut revenir à l'exécuté physique ;
- la trace contractuelle initiale reste intacte ;
- les lots physiques et leur PnL restent visibles.
- Si un Weight basis existe sur le contrat :
- achat : état via `purchase.purchase.wb.qt_type` ;
- vente : état via `sale.sale.wb.qt_type` ;
- le même lot physique peut être lu en BL côté achat et en LR côté vente.
- La facturation reste indépendante :
- elle peut choisir un état précis dans `lot.qt.hist` ;
- exemple : BL à l'achat, Weight Report ou LR à la vente.
Quand des lots physiques sont ajoutés, ils sont créés depuis une ligne précise
de Lots Management, donc depuis une ligne `lot.qt`. Si l'utilisateur choisit
`P1S1T1`, le lot physique créé consomme ce forecast particulier. La ligne
`lot.qt` choisie est réduite, puis le lot virtuel de la ligne est recalculé
pour représenter seulement le reliquat encore ouvert.
### BR-PT-LOT-003 - Invariants de quantité
La première règle immuable de cohérence est :
Invariant de conservation :
```text
somme des lots physiques de la ligne + lot virtuel de la ligne
= quantité contractuelle/théorique de la ligne
somme(lots physiques) + quantité du lot virtuel = quantity_theorical
```
La deuxième règle immuable de cohérence est :
Invariant du forecast ouvert :
```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
somme(lot.qt non zéro du lot virtuel) = max(quantité du lot virtuel, 0)
```
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.
Précisions :
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 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.
- Achat : sommer tous les `lot.qt``lot_p = lot virtuel`, avec ou sans
`lot_s`.
- Vente : sommer tous les `lot.qt``lot_s = lot virtuel`, avec ou sans
`lot_p`.
- Les `lot.qt = 0` sont ignorés :
- ils peuvent mémoriser une prévision vidée ;
- `lot.qt` ne descend jamais sous zéro.
- Le lot virtuel peut devenir négatif :
- il compense l'écart entre `quantity_theorical` et l'exécuté physique ;
- dans ce cas le forecast ouvert attendu reste zéro.
- Un `lot.qt` non zéro sans `lot_p` ni `lot_s` est interdit.
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
shipment, l'utilisateur doit confirmer l'action. Le retrait restaure la
quantité dans la ligne `lot.qt` qui portait le contexte forecast, matching ou
transport ayant permis de créer le lot physique.
### BR-PT-LOT-004 - Historique de quantité et weighing
L'historique des quantités d'un lot n'est jamais une zone de saisie directe.
Les différents états de quantité d'un lot doivent être modifiés uniquement par
une action métier dédiée, aujourd'hui `Do weighing`. Cette règle garantit que
les recalculs de quantité ouverte, de lot virtuel, de fees et de mouvements
stock restent synchronisés.
- `lot.qt.hist` porte les états de quantité d'un lot.
- La fiche `lot.lot` n'est pas une zone de saisie directe des états.
- Modification autorisée uniquement via `Do weighing`.
- Un lot virtuel ne reçoit pas de packing manuel :
- `lot_qt` non éditable ;
- `lot_unit` non éditable.
### BR-PT-LOT-002 - Quantité contractuelle, exécuté physique et ligne finie
### Tolérances
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,
dans les limites de tolérance du contrat. La tolérance doit se lire comme une
tolérance globale sur la ligne ou le contrat, pas comme une tolérance
indépendante par transport.
Si un lot physique consomme plus ou moins que son forecast, cela doit réduire
ou augmenter dynamiquement la tolérance restante pour les prochains lots
physiques de la même ligne. Aucune action d'ajout, de weighing, de suppression
ou d'ajustement contractuel ne doit permettre de sortir de la cohérence globale
des quantités.
- La tolérance est globale sur la ligne ou le contrat.
- Elle n'est pas indépendante par transport.
- Un lot physique peut dépasser ou sous-consommer son forecast.
- Ce dépassement consomme ou restitue de la tolérance restante.
- Gap actuel : le contrôle complet de tolérance restante dans
`LotQt.add_physical_lots` / `LotQt.add_physical_lot` reste à confirmer.
## Section développeur
### Modèles et champs principaux
### Champs clés
- Ligne achat : `purchase.line`.
- Ligne vente : `sale.line`.
- Lot : `lot.lot`.
- Forecast / quantité ouverte : `lot.qt`.
- Historique des quantités de lot : `lot.qt.hist`.
- Quantité contractuelle achat : `purchase.line.quantity_theorical`
(`Contractual Qt`).
- Quantité contractuelle vente : `sale.line.quantity_theorical`
(`Th. 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`.
- Liens shipment : `lot_shipment_in`, `lot_shipment_internal`,
`lot_shipment_out`.
- Matching forecast achat/vente : `lot.qt.lot_p`, `lot.qt.lot_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`.
- Champs de packing du lot : `lot.lot.lot_qt`, `lot.lot.lot_unit`.
- Ligne achat : `purchase.line`
- Ligne vente : `sale.line`
- Lot : `lot.lot`
- Forecast : `lot.qt`
- Historique : `lot.qt.hist`
- Quantité métier achat : `purchase.line.quantity_theorical`
- Quantité métier vente : `sale.line.quantity_theorical`
- Compteur technique : `quantity`
- Ligne finie : `purchase.line.finished`, `sale.line.finished`
- Lot virtuel / physique : `lot.lot.lot_type = virtual / physic`
- Lien achat : `lot.lot.line`
- Lien vente : `lot.lot.sale_line`
- Forecast achat : `lot.qt.lot_p`
- Forecast vente : `lot.qt.lot_s`
- Quantité forecast : `lot.qt.lot_quantity`
- Weight basis : `purchase.purchase.wb`, `sale.sale.wb`
- État Weight basis : `purchase.weight.basis.qt_type`
- Packing : `lot.lot.lot_qt`, `lot.lot.lot_unit`
- Tolerances : `tol_min`, `tol_max`, `tol_min_qt`, `tol_max_qt`,
`tol_min_v`, `tol_max_v`
### Création de la ligne et du lot virtuel
### Création ligne / lot virtuel
- 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_theorical != 0`, un lot
`virtual` est créé.
- Achat : `purchase.py`, `Line.validate`
- Vente : `sale.py`, `SaleLine.validate`
- Si `quantity_theorical` est saisi et que `quantity` est vide ou zéro :
- `quantity` est initialisée depuis `quantity_theorical` ;
- seulement si aucun lot physique n'existe.
- Si la ligne est éligible :
- pas `created_by_code` ;
- pas encore de lot ;
- produit non service ;
- `quantity_theorical != 0` ;
- création d'un lot `virtual`.
- 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.
- `Lot.validate` crée le `lot.qt` ouvert via `createVirtualPart`.
### Modification de la quantité contractuelle
### Modification de `quantity_theorical`
État du code vérifié :
- Achat : `purchase.py`, `Line.write`.
- Vente : `sale.py`, `SaleLine.write`.
- La modification de `quantity_theorical` recalcule une quantité virtuelle
cible :
- Achat : `purchase.py`, `Line.write`
- Vente : `sale.py`, `SaleLine.write`
- Cible lot virtuel :
```text
target_quantity = quantity_theorical - somme(lots physiques convertis)
```
- Si cette quantité cible devient négative, le code bloque avec
`Please unlink or unmatch lot`.
- Le `lot.qt` libre est ensuite resynchronisé en tenant compte des lignes
`lot.qt` déjà allouées, c'est-à-dire déjà matchées ou rattachées à un
shipment :
- Si `target_quantity < 0` :
- blocage : `Please unlink or unmatch lot`.
- Cible `lot.qt` libre :
```text
free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés)
```
- Si `free_quantity` devient négative, le code bloque avec
`Please unlink or unmatch lot`.
- Si le lot virtuel ne porte pas déjà la quantité cible, le code appelle
`vlot.set_current_quantity(target_quantity, target_quantity, 1)`.
- Si un `lot.qt` libre existe, sa quantité est remplacée par `free_quantity`.
- Si aucun `lot.qt` libre n'existe et que `free_quantity > 0`, un nouveau
`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 non zéro where lot_p = virtual lot) =
max(virtual lot current quantity, 0)`.
- Pour un lot virtuel achat, le second invariant somme toutes les lignes
`lot.qt` où `lot_p = virtual lot`, qu'elles soient matchées à un `lot_s` ou
non.
- Pour un lot virtuel vente, le second invariant somme toutes les lignes
`lot.qt` où `lot_s = virtual lot`, qu'elles soient liées à un `lot_p` ou non.
- Une ligne `lot.qt` à zéro est totalement ignorée par ce check : elle sert de
mémoire d'une prévision vidée et `lot.qt` ne descend jamais sous zéro.
- Si le lot virtuel devient négatif pour compenser l'écart entre
`quantity_theorical` et l'exécuté physique, le `lot.qt` attendu reste donc
zéro.
- Une ligne `lot.qt` non zéro sans `lot_p` ni `lot_s` est bloquée par
`lot.qt.validate`.
- Le garde-fou technique est centralisé dans
`lot.lot.assert_lines_quantity_consistency()` et doit être appelé à la fin
des flux qui modifient des lots, du matching, du transport, du weighing ou la
quantité contractuelle.
Point spécifique achat :
- Si `quantity_theorical` était vide au moment de l'initialisation, le code
prend comme baseline la quantité courante du lot virtuel pour éviter de
doubler le `lot.qt` ouvert.
Point spécifique vente :
- Si l'ancienne `quantity_theorical` est vide, `SaleLine.write` ne lance pas
encore cette resynchronisation.
- Si `free_quantity < 0` :
- blocage : `Please unlink or unmatch lot`.
- Si un `lot.qt` libre existe :
- sa quantité est remplacée.
- Si aucun `lot.qt` libre n'existe et `free_quantity > 0` :
- création d'un nouveau `lot.qt`.
- Les fees de ligne sont resynchronisés.
### Ajout de lots physiques
- Wizard : `modules/purchase_trade/lot.py`, `lot.add`.
- Méthode principale : `LotQt.add_physical_lots`.
- Création unitaire : `LotQt.add_physical_lot`.
- L'ajout part obligatoirement d'une ligne `lot.qt` issue de Lots Management.
- Le code refuse l'ajout direct depuis un lot physique.
- Le code refuse l'ajout physique côté vente par ce wizard et demande
d'utiliser `Apply matching`.
- Le nouveau lot physique reprend le contexte de la ligne `lot.qt` :
ligne achat, vente matchée si présente, shipment, produit, unité,
quantités, premium et chunk key.
- Après création, le code réduit la quantité de la ligne `lot.qt` source du
total physique créé et ne laisse pas la ligne descendre sous zéro.
- La sauvegarde/validation du lot physique recalcule :
- le lot virtuel de la ligne via `_recompute_virtual_lot` ;
- la quantité compteur de la ligne via `_recalc_line_quantity` ;
- les mouvements stock liés si nécessaire ;
- les fees rattachés au lot, à la ligne ou au shipment.
- Wizard : `lot.add`
- Méthodes :
- `LotQt.add_physical_lots`
- `LotQt.add_physical_lot`
- Source obligatoire : une ligne `lot.qt`.
- Ajout direct depuis un lot physique refusé.
- Ajout physique côté vente par ce wizard refusé : utiliser `Apply matching`.
- Le lot physique reprend :
- ligne achat ;
- vente matchée si présente ;
- shipment ;
- produit ;
- unité ;
- quantités ;
- premium ;
- chunk key.
- Après création :
- réduction de la ligne `lot.qt` source ;
- pas de quantité `lot.qt` négative ;
- recalcul du lot virtuel ;
- recalcul de `quantity` ;
- mise à jour moves et fees si nécessaire.
### Retrait de lots physiques
- Wizard : `modules/purchase_trade/lot.py`, `lot.remove`.
- Le retrait d'un lot ouvert est interdit.
- Si le lot physique possède un `stock.move`, ce move doit être en état
`draft`.
- Si le lot est matché ou shippé, un warning confirmable est affiché.
- Le code supprime d'abord le move draft si présent.
- Le code restaure la quantité physique dans `lot.qt` en réutilisant le
contexte :
- shipment d'origine via `lot.lot_shipment_origin` ;
- lot virtuel sale via `getVlot_s()` si le lot était matché ;
- lot virtuel purchase via `getVlot_p()` dans `updateVirtualPart`.
- Si une ligne `lot.qt` compatible existe déjà, elle est incrémentée.
- Sinon une nouvelle ligne `lot.qt` est créée.
- La suppression du lot physique déclenche ensuite le recalcul du lot virtuel,
de la quantité compteur de ligne et des fees.
- Wizard : `lot.remove`
- Lot ouvert : retrait interdit.
- Lot avec `stock.move` :
- move obligatoire en `draft`.
- Lot matché ou shippé :
- warning confirmable.
- Effets :
- suppression du move draft ;
- restauration de la quantité dans `lot.qt` ;
- contexte restauré via shipment, `getVlot_p()`, `getVlot_s()` ;
- recalcul lot virtuel, `quantity`, fees.
### Historique de quantité et weighing
### Weighing / états de quantité
- L'historique est porté par `lot.qt.hist`.
- `lot.qt.hist` représente les différents états de quantité d'un lot.
- Les quantités historiques ne doivent pas être modifiées directement depuis
la fiche `lot.lot`.
- La vue `lot.lot` affiche `lot_hist` en lecture seule.
- Les vues `lot.qt.hist` sont consultatives: pas d'édition directe depuis
l'arbre ou le formulaire.
- Le chemin fonctionnel de modification est le wizard `lot.weighing`, exposé
par l'action `Do weighing`.
- Le wizard écrit ou met à jour l'entrée `lot.qt.hist` correspondant au
`lot_state` choisi, puis synchronise le lot, les quantités ouvertes et les
fees.
- Un lot virtuel ne doit pas recevoir de saisie de packing depuis la fiche
lot: les champs `lot_qt` et `lot_unit` sont non éditables pour
`lot_type = virtual`.
- Wizard : `lot.weighing`
- Action UI : `Do weighing`
- Écrit ou met à jour `lot.qt.hist`.
- Peut mettre à jour `lot_state`.
- Synchronise :
- lot ;
- quantités ouvertes ;
- fees.
- Les vues `lot.qt.hist` sont consultatives.
### Quantité compteur de ligne
### Quantité compteur `quantity`
- Méthode : `lot.py`, `Lot._recalc_line_quantity`.
- Si la ligne n'a qu'un seul lot, `quantity` reprend la quantité courante de ce
lot.
- Si la ligne a plusieurs lots, `quantity` somme uniquement les lots physiques.
- Cette logique correspond à la règle fonctionnelle :
- sans physique : `quantity` suit le virtuel ;
- avec physiques : `quantity` reflète l'exécuté physique.
- Méthode : `Lot._recalc_line_quantity`
- Sans physique :
- `quantity` suit le lot virtuel.
- Avec physiques :
- `quantity` somme uniquement les lots physiques.
- `quantity` est readonly côté ligne trade.
### 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.
- Achat : `purchase.line.on_change_with_amount()`
- Vente : `sale.line.on_change_with_amount()`
- Helper :
- `_get_amount_quantity()`
- `_get_weight_basis_quantity()`
- Priorités :
- `finished = False` : `quantity_theorical`
- `finished = True` + Weight basis disponible : somme physique dans cet état
- `finished = True` sans Weight basis exploitable : `quantity`
- fallback legacy : `quantity` si `quantity_theorical` vide
### Tolérance : état actuel et point à confirmer
### Garde-fous Python
Le code expose déjà les tolérances sur contrats et lignes :
- Check central :
- `lot.lot.assert_lines_quantity_consistency()`
- Blocage `lot.qt` orphelin non zéro :
- `lot.qt.validate`
- Appels après :
- modification `quantity_theorical` ;
- création / suppression de lots physiques ;
- matching / unmatching ;
- shipping / unshipping ;
- weighing.
- Achat : `purchase.purchase.tol_min`, `purchase.purchase.tol_max`,
`purchase.line.tol_min`, `purchase.line.tol_max`.
- Vente : `sale.sale.tol_min`, `sale.sale.tol_max`, `sale.line.tol_min`,
`sale.line.tol_max`.
- Fonctions d'affichage : `get_tol_min`, `get_tol_max`.
- Wizard d'ajout : `lot.add.line.tol_min`, `lot.add.line.tol_max`, avec
défauts depuis le contrat source.
### Diagnostic SQL
À confirmer / gap potentiel :
- Script :
- [sql/quantity_consistency_checks.sql](sql/quantity_consistency_checks.sql)
- Usage :
- audit des bases de test ;
- audit des données historiques ;
- qualification avant correction.
- Le script ignore totalement les `lot.qt = 0`.
- Dans `LotQt.add_physical_lots` et `LotQt.add_physical_lot`, je ne vois pas
encore de contrôle complet qui calcule une tolérance restante globale en
tenant compte des lots physiques déjà créés.
- La règle consultant ci-dessus de tolérance globale dynamique doit donc être
considérée comme cible métier à vérifier/implémenter avant de la marquer
`active`.
### Tests proches
## 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`
- `test_purchase_line_write_syncs_virtual_fee_quantity`
- `test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot`
À ajouter si cette règle devient sensible côté régression:
- vérifier que `lot_hist` est en lecture seule sur la fiche `lot.lot`;
- vérifier que `Do weighing` reste capable de créer ou mettre à jour un état de
quantité;
- vérifier qu'un lot virtuel ne permet pas la saisie directe de `lot_qt` et
`lot_unit`.
- Couverture existante :
- `quantity` readonly ;
- initialisation depuis `quantity_theorical` ;
- protection si lots physiques ;
- amount sur théorique / physique / Weight basis ;
- resynchronisation des lots virtuels ;
- blocages quand l'open ne suffit plus.
- Tests à ajouter :
- `lot_hist` readonly ;
- `Do weighing` crée ou met à jour un état ;
- lot virtuel sans saisie directe `lot_qt` / `lot_unit` ;
- contrôles SQL rejoués sur jeux de données incohérents.