From a85ab97d46bd5795bf4b56b10802038f7977cd33 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Wed, 13 May 2026 20:28:40 +0200 Subject: [PATCH] check qt --- .../docs/business/lots-and-quantities.en.md | 13 ++++--- .../docs/business/lots-and-quantities.md | 13 ++++--- .../docs/business/sql/README.md | 10 +++--- .../sql/quantity_consistency_checks.sql | 35 ++++++++++++------- modules/purchase_trade/lot.py | 12 ++++--- 5 files changed, 53 insertions(+), 30 deletions(-) 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 bf6a291..8281e59 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.en.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.en.md @@ -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 diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.md b/modules/purchase_trade/docs/business/lots-and-quantities.md index b75503e..b44231d 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.md @@ -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 diff --git a/modules/purchase_trade/docs/business/sql/README.md b/modules/purchase_trade/docs/business/sql/README.md index 2952d5e..24ba628 100644 --- a/modules/purchase_trade/docs/business/sql/README.md +++ b/modules/purchase_trade/docs/business/sql/README.md @@ -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: diff --git a/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql b/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql index 1627e61..4bb3205 100644 --- a/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql +++ b/modules/purchase_trade/docs/business/sql/quantity_consistency_checks.sql @@ -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; diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 783c22f..730774a 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -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.")