This commit is contained in:
2026-05-13 20:28:40 +02:00
parent 14ba0361d2
commit a85ab97d46
5 changed files with 53 additions and 30 deletions

View File

@@ -221,15 +221,18 @@ free_quantity = target_quantity - sum(already matched or shipped lot.qt)
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`.
- `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 accepted if it still keeps at least one `lot_p` or
`lot_s` link: it is then the memory of a consumed forecast.
- A `lot.qt` row without both `lot_p` and `lot_s` is blocked by
- 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

View File

@@ -226,16 +226,19 @@ free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés)
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`.
- `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 acceptée si elle garde au moins un lien
`lot_p` ou `lot_s` : elle sert alors de mémoire d'une prévision vidée.
- Une ligne `lot.qt` sans `lot_p` ni `lot_s` est bloquée par
- 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

View File

@@ -7,10 +7,12 @@ These scripts are read-only diagnostics for a PostgreSQL test database.
Checks the two core lot quantity invariants documented in
`lots-and-quantities.md` and `lots-and-quantities.en.md`.
Zero `lot_qt` rows are ignored by the quantity sums when they are still linked
to at least one virtual lot through `lot_p` or `lot_s`; they are treated as
legitimate memory of an open quantity consumed by a physical lot. A `lot_qt`
row without both `lot_p` and `lot_s` is reported as anomalous.
Zero `lot_qt` rows are ignored completely. They are treated as legitimate
memory of an open quantity consumed by a physical lot. This is required because
`lot_qt` represents usable open forecast and stops at zero, while the virtual
lot may become negative to compensate the difference between theoretical and
executed quantity. A non-zero `lot_qt` row without both `lot_p` and `lot_s` is
reported as anomalous.
Run it on a restored test database:

View File

@@ -21,9 +21,11 @@ Rules checked:
Rows linked to a purchase lot and rows without purchase lot are both
included.
Zero lot_qt rows are allowed when they keep the memory of an open quantity
consumed by a physical lot, as long as at least lot_p or lot_s is set. Unlinked
lot_qt rows are reported as anomalies.
Zero lot_qt rows are completely ignored by the checks. They may be the memory
of an open quantity consumed by a physical lot. This also matters when the
virtual lot becomes negative to compensate the difference between theoretical
and executed quantity: lot_qt represents usable open forecast and stops at
zero.
Notes:
- The script uses the lot current state, i.e. lot_lot.lot_state joined to
@@ -354,13 +356,17 @@ FROM (
b.line_id,
b.virtual_lot_id,
ROUND(b.lot_qt_quantity, 5) AS observed_value,
ROUND(COALESCE(b.virtual_quantity, 0), 5) AS expected_value,
ROUND(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0), 5) AS diff,
'sum lot_qt where lot_p = virtual lot must equal virtual lot quantity'
ROUND(GREATEST(COALESCE(b.virtual_quantity, 0), 0), 5) AS expected_value,
ROUND(
b.lot_qt_quantity
- GREATEST(COALESCE(b.virtual_quantity, 0), 0), 5) AS diff,
'sum non-zero lot_qt where lot_p = virtual lot must equal positive virtual lot quantity'
AS detail
FROM purchase_lot_qt_balance b, params p
WHERE b.lot_qt_cross_category_count = 0
AND ABS(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0)) > p.epsilon
AND ABS(
b.lot_qt_quantity
- GREATEST(COALESCE(b.virtual_quantity, 0), 0)) > p.epsilon
UNION ALL
@@ -372,13 +378,17 @@ FROM (
b.line_id,
b.virtual_lot_id,
ROUND(b.lot_qt_quantity, 5) AS observed_value,
ROUND(COALESCE(b.virtual_quantity, 0), 5) AS expected_value,
ROUND(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0), 5) AS diff,
'sum lot_qt where lot_s = virtual lot must equal virtual lot quantity'
ROUND(GREATEST(COALESCE(b.virtual_quantity, 0), 0), 5) AS expected_value,
ROUND(
b.lot_qt_quantity
- GREATEST(COALESCE(b.virtual_quantity, 0), 0), 5) AS diff,
'sum non-zero lot_qt where lot_s = virtual lot must equal positive virtual lot quantity'
AS detail
FROM sale_lot_qt_balance b, params p
WHERE b.lot_qt_cross_category_count = 0
AND ABS(b.lot_qt_quantity - COALESCE(b.virtual_quantity, 0)) > p.epsilon
AND ABS(
b.lot_qt_quantity
- GREATEST(COALESCE(b.virtual_quantity, 0), 0)) > p.epsilon
UNION ALL
@@ -460,10 +470,11 @@ FROM (
COALESCE(lqt.lot_quantity, 0)::numeric AS observed_value,
0::numeric AS expected_value,
COALESCE(lqt.lot_quantity, 0)::numeric AS diff,
'lot_qt row has neither lot_p nor lot_s; zero rows are allowed only when linked to at least one virtual lot'
'non-zero lot_qt row has neither lot_p nor lot_s'
AS detail
FROM lot_qt lqt
WHERE lqt.lot_p IS NULL
AND lqt.lot_s IS NULL
AND COALESCE(lqt.lot_quantity, 0) <> 0
) issues
ORDER BY check_name, contract_number, line_id, virtual_lot_id;

View File

@@ -174,21 +174,24 @@ class Lot(metaclass=PoolMeta):
open_quantity = sum(
cls._get_lot_qt_quantity_for_consistency(lqt, unit)
for lqt in lotqts)
for lqt in lotqts
if Decimal(str(getattr(lqt, 'lot_quantity', 0) or 0)) != 0)
virtual_quantity = cls._get_lot_current_quantity_for_consistency(
vlot, unit)
expected_open_quantity = max(virtual_quantity, Decimal(0))
diff = (
cls._round_consistency_quantity(open_quantity)
- cls._round_consistency_quantity(virtual_quantity))
- cls._round_consistency_quantity(expected_open_quantity))
if diff:
raise UserError(
cls._format_consistency_error(
line,
"lot.qt forecast quantity must equal virtual lot "
"current quantity",
"positive current quantity",
[
('lot_qt', open_quantity),
('virtual', virtual_quantity),
('expected_open', expected_open_quantity),
('diff', diff),
]))
@@ -1396,7 +1399,8 @@ class LotQt(
Date = Pool().get('ir.date')
#Update Move
for lqt in lotqts:
if not lqt.lot_p and not lqt.lot_s:
if (not lqt.lot_p and not lqt.lot_s
and Decimal(str(lqt.lot_quantity or 0)) != 0):
raise UserError(
"Lot quantity forecast must be linked to a purchase "
"virtual lot or a sale virtual lot.")