326 lines
12 KiB
Markdown
326 lines
12 KiB
Markdown
# Lots and Quantities
|
|
|
|
Language: `en`
|
|
Mirror page: [lots-and-quantities.md](lots-and-quantities.md)
|
|
Status: `partial migration`
|
|
Last code verification: `2026-05-13`
|
|
|
|
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.
|
|
|
|
## Key Points
|
|
|
|
> **Operational Summary**
|
|
> A trade line has exactly one virtual lot. The virtual lot carries the
|
|
> global open balance, `lot.qt` carries the operational forecast, and
|
|
> physical lots consume that forecast.
|
|
>
|
|
| Topic | Short Rule |
|
|
| --- | --- |
|
|
| Entered quantity | `quantity_theorical` is the business quantity. |
|
|
| Standard quantity | `quantity` is a read-only technical counter. |
|
|
| Before physical lots | `quantity` follows `quantity_theorical`. |
|
|
| After physical lots | `quantity` reflects physical execution. |
|
|
| Open line | Line amount uses `quantity_theorical`. |
|
|
| Finished line | Line amount may switch back to physical execution. |
|
|
| Weight basis | Purchase and sale may read two different states of the same lot. |
|
|
| Invoicing | It chooses states from `lot.qt.hist`. |
|
|
| Controls | Invariants are blocked in Python and auditable with SQL. |
|
|
|
|
```text
|
|
quantity_theorical
|
|
|
|
|
v
|
|
virtual lot P1 ---> lot.qt forecast ---> physical lot
|
|
^ |
|
|
| v
|
|
+------ recalculation after consumption
|
|
```
|
|
|
|
## Consultant Rules
|
|
|
|
### BR-PT-LOT-001 - Virtual lot / forecast / physical life cycle
|
|
|
|
| Moment | Business Effect |
|
|
| --- | --- |
|
|
| Line creation | One unique virtual lot is created. |
|
|
| Initialization | The virtual lot takes `quantity_theorical`. |
|
|
| Forecast | One open line is created in `lot.qt`. |
|
|
| Planning | `lot.qt` may be split by sale, matching, transport, or shipment. |
|
|
| Physical add | The physical lot consumes a precise `lot.qt` line. |
|
|
| After add | `lot.qt` decreases and the virtual lot is recalculated. |
|
|
|
|
> **Split of an open P1 balance**
|
|
> `P1S1T1`, `P1S1T2`, `P1S2T3`, `P1S2T4`
|
|
>
|
|
> **Key Point**
|
|
> These splits do not create several virtual lots. They only describe the
|
|
> forecast allocation of the open balance.
|
|
>
|
|
### BR-PT-LOT-002 - Contractual quantity, counter quantity, finished line
|
|
|
|
| Situation | Reference Quantity |
|
|
| --- | --- |
|
|
| User entry | `quantity_theorical` |
|
|
| No physical lot | `quantity = quantity_theorical` |
|
|
| Physical lots exist | `quantity = sum of physical lots` |
|
|
| `finished = False` | Amount based on `quantity_theorical` |
|
|
| `finished = True` | Amount based on physical execution |
|
|
| Weight basis available | Amount based on the contract `wb.qt_type` state |
|
|
|
|
> **What `finished` Does Not Do**
|
|
> `finished` does not erase the contractual quantity, delete physical lots,
|
|
> or hide their PnL. It only means that the open balance may be ignored for
|
|
> execution calculations.
|
|
>
|
|
| Reading of the Same Physical Lot | Possible State |
|
|
| --- | --- |
|
|
| Purchase | BL through `purchase.purchase.wb.qt_type` |
|
|
| Sale | LR or Weight Report through `sale.sale.wb.qt_type` |
|
|
| Invoicing | Independent choice in `lot.qt.hist` |
|
|
|
|
### BR-PT-LOT-003 - Quantity Invariants
|
|
|
|
| Invariant | Formula |
|
|
| --- | --- |
|
|
| Conservation | `sum(physical lots) + virtual lot = quantity_theorical` |
|
|
| Minimum open forecast | `sum(non-zero lot.qt) >= max(virtual lot, 0)` |
|
|
|
|
| Case | Rule |
|
|
| --- | --- |
|
|
| Purchase virtual lot | Sum all `lot.qt` where `lot_p = virtual lot`, with or without `lot_s`. |
|
|
| Sale virtual lot | Sum all `lot.qt` where `lot_s = virtual lot`, with or without `lot_p`. |
|
|
| `lot.qt = 0` | Ignored by checks; possible memory of a consumed forecast. |
|
|
| Forecast overconsumption | Allowed after tolerance confirmation: `lot.qt` stops at zero, but the virtual lot absorbs the whole physical lot. |
|
|
| `lot.qt` greater than virtual lot | Accepted when remaining open forecast exceeds the positive virtual lot because of physical overconsumption. |
|
|
| Negative virtual lot | Allowed to compensate theoretical / executed gap; expected forecast = zero. |
|
|
| Non-zero orphan `lot.qt` | Forbidden if neither `lot_p` nor `lot_s` is set. |
|
|
|
|
### BR-PT-LOT-004 - Quantity history and weighing
|
|
|
|
| Element | Rule |
|
|
| --- | --- |
|
|
| `lot.qt.hist` | Carries lot quantity states. |
|
|
| `lot.lot` form | No direct state entry. |
|
|
| Update | Only through `Do weighing`. |
|
|
| Virtual lot | No manual packing. |
|
|
| Virtual packing fields | `lot_qt` and `lot_unit` are not editable. |
|
|
|
|
### Tolerances
|
|
|
|
| Point | Rule |
|
|
| --- | --- |
|
|
| Level | Tolerance is defined on the `purchase` / `sale` header. |
|
|
| Inheritance | Lines inherit the header tolerance by default. |
|
|
| Transport | No independent tolerance per transport. |
|
|
| Physical lot addition | The check is performed during `Add physical lots`. |
|
|
| Matched forecast | If `lot_qt` carries both `lot_p` and `lot_s`, the check applies to both purchase and sale sides. |
|
|
| Line excess | Confirmable warning in English when the projected physical quantity exceeds the current line tolerance. The message names the blocking side: `purchase`, `sale`, or both if both checks are triggered successively. |
|
|
| Global envelope | A line excess is accepted after confirmation and consumes the contract-level envelope. |
|
|
| Remaining tolerance | Contract lines are recalculated with `inherit_tol = False`. |
|
|
| Over-execution | A line exceeding the header tolerance keeps a `+` tolerance at least equal to its actual excess. |
|
|
| Other lines | Their `+` tolerance is reduced according to the remaining envelope. |
|
|
| Under-execution | Mechanically restores tolerance available to other lines on the next recalculation. |
|
|
| Open matching | `Go to matching` may match above the strict open balance if the projected quantity remains within `Qt max`. |
|
|
| Line gauge | On a line with physical lots, the gauge uses the sum of physical lots; without physical lots, it uses the sum of linked `lot.qt`. |
|
|
| Header gauge | The purchase/sale gauge is the weighted average of line gauges, weighted by `quantity_theorical`. |
|
|
| Matching gauge | In `Go to matching`, the gauge projects `already matched + Qt to match` against `quantity_theorical`, with `-tol_min` / `tol_max` bounds. |
|
|
| Line gauge layout | On `purchase.line` / `sale.line` forms, place the gauge and `targeted_qt` directly in the main grid, without a `colspan="4"` subgroup, and use `xalign="0"` to avoid a double right shift. |
|
|
|
|
## Developer Section
|
|
|
|
### Key Fields
|
|
|
|
- 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`, `tolerance_used`, `tolerance_min`,
|
|
`tolerance_max`
|
|
|
|
### Line / Virtual Lot Creation
|
|
|
|
- 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`.
|
|
- If the line is `created_by_code`:
|
|
- it comes from a business workflow (`Create contracts`, matching, etc.);
|
|
- `Lot.validate` does not create an automatic open `lot.qt`;
|
|
- the workflow attaches or splits its source `lot.qt`;
|
|
- the blocking check applies only after the workflow is stable.
|
|
|
|
### Updating `quantity_theorical`
|
|
|
|
- Purchase: `purchase.py`, `Line.write`
|
|
- Sale: `sale.py`, `SaleLine.write`
|
|
- Virtual lot target:
|
|
|
|
```text
|
|
target_quantity = quantity_theorical - sum(converted physical lots)
|
|
```
|
|
|
|
- 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 < 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.
|
|
|
|
### Adding Physical Lots
|
|
|
|
- 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.
|
|
|
|
### Removing Physical Lots
|
|
|
|
- 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.
|
|
|
|
### Weighing / Quantity States
|
|
|
|
- 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.
|
|
|
|
### Quantity Counter `quantity`
|
|
|
|
- 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.
|
|
|
|
### Line Amount
|
|
|
|
- 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
|
|
|
|
### Python Guards
|
|
|
|
- Central check:
|
|
- `lot.lot.assert_lines_quantity_consistency()`
|
|
- Trigger rule:
|
|
- block inconsistent final states;
|
|
- do not block internal transient workflow states;
|
|
- use `Lot.skip_quantity_consistency()` only around a sequence that restores the invariants afterwards;
|
|
- call an explicit final check after the sequence.
|
|
- Non-zero orphan `lot.qt` block:
|
|
- `lot.qt.validate`
|
|
- Called after:
|
|
- `quantity_theorical` update;
|
|
- physical lot creation / deletion;
|
|
- matching / unmatching;
|
|
- shipping / unshipping;
|
|
- matched `Create contracts`;
|
|
- weighing.
|
|
- The `created_by_code` case is intentionally excluded from the immediate `Lot.validate` check:
|
|
- the virtual lot is saved before the matched `lot.qt` is attached;
|
|
- the invariant is checked by the final workflow guard.
|
|
|
|
### SQL Diagnostic
|
|
|
|
- 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`.
|
|
|
|
## Nearby Tests
|
|
|
|
- `modules/purchase_trade/tests/test_module.py`
|
|
- 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.
|