From 30b9da1a7801e6add57b69090f0786c85f22a781 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 14 May 2026 16:06:12 +0200 Subject: [PATCH] =?UTF-8?q?Tol=C3=A9rance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/business/lots-and-quantities.en.md | 34 +++- .../docs/business/lots-and-quantities.md | 34 +++- .../business/lots-and-quantities.en.md | 18 +- .../business/lots-and-quantities.md | 18 +- modules/purchase_trade/lot.py | 180 ++++++++++++++++++ 5 files changed, 256 insertions(+), 28 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 dcf4608..f2eb23f 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.en.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.en.md @@ -263,31 +263,53 @@ them with Python guards and SQL diagnostics. ### Tolerances -
Gap to ConfirmFull remaining-tolerance control in LotQt.add_physical_lots / LotQt.add_physical_lot still needs confirmation.
- - + - + + + + + + + + + + + + + + + + + + + + + - + + + + + - +
PointTarget RuleRule
LevelGlobal tolerance on line or contract.Tolerance is defined on the purchase / sale header.
InheritanceLines inherit the header tolerance by default.
Transport No independent tolerance per transport.
Physical lot additionThe check is performed during Add physical lots.
Line excessConfirmable warning in English when the projected physical quantity exceeds the current line tolerance.
Global envelopeA line excess is accepted after confirmation and consumes the contract-level envelope.
Remaining toleranceContract lines are recalculated with inherit_tol = False.
Over-executionConsumes remaining tolerance.A line exceeding the header tolerance keeps a + tolerance at least equal to its actual excess.
Other linesTheir + tolerance is reduced according to the remaining envelope.
Under-executionRestores remaining tolerance.Mechanically restores tolerance available to other lines on the next recalculation.
diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.md b/modules/purchase_trade/docs/business/lots-and-quantities.md index 50cedf2..8468e7f 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.md @@ -262,31 +262,53 @@ puis les sécuriser par des checks Python et des diagnostics SQL. ### Tolérances -
Gap à confirmerLe contrôle complet de tolérance restante dans LotQt.add_physical_lots / LotQt.add_physical_lot reste à confirmer.
- - + - + + + + + + + + + + + + + + + + + + + + + - + + + + + - +
PointRègle cibleRègle
NiveauTolérance globale sur ligne ou contrat.La tolérance est définie au header purchase / sale.
HéritageLes lignes héritent de la tolérance header par défaut.
Transport Pas de tolérance indépendante par transport.
Ajout physiqueLe contrôle se fait au moment de Add physical lots.
Dépassement ligneWarning confirmable en anglais si la quantité physique projetée dépasse la tolérance courante de la ligne.
Enveloppe globaleLe dépassement ponctuel d'une ligne est accepté après confirmation et consomme l'enveloppe globale du contrat.
Tolérance restanteLes lignes du contrat sont recalculées avec inherit_tol = False.
SurconsommationConsomme la tolérance restante.Une ligne qui dépasse la tolérance header garde une tolérance + au moins égale à son dépassement réel.
Autres lignesLeur tolérance + est réduite selon l'enveloppe restante.
Sous-consommationRestitue de la tolérance restante.Restitue mécaniquement de la tolérance disponible aux autres lignes lors du recalcul suivant.
diff --git a/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md b/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md index 1cad37d..6218d1d 100644 --- a/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md +++ b/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md @@ -108,16 +108,18 @@ quantity_theorical ### Tolerances -> **Gap to Confirm** -> Full remaining-tolerance control in `LotQt.add_physical_lots` / -> `LotQt.add_physical_lot` still needs confirmation. -> -| Point | Target Rule | +| Point | Rule | | --- | --- | -| Level | Global tolerance on line or contract. | +| Level | Tolerance is defined on the `purchase` / `sale` header. | +| Inheritance | Lines inherit the header tolerance by default. | | Transport | No independent tolerance per transport. | -| Over-execution | Consumes remaining tolerance. | -| Under-execution | Restores remaining tolerance. | +| Physical lot addition | The check is performed during `Add physical lots`. | +| Line excess | Confirmable warning in English when the projected physical quantity exceeds the current line tolerance. | +| 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. | ## Developer Section diff --git a/modules/purchase_trade/docs_source/business/lots-and-quantities.md b/modules/purchase_trade/docs_source/business/lots-and-quantities.md index 7850668..61f5f14 100644 --- a/modules/purchase_trade/docs_source/business/lots-and-quantities.md +++ b/modules/purchase_trade/docs_source/business/lots-and-quantities.md @@ -107,16 +107,18 @@ quantity_theorical ### Tolérances -> **Gap à confirmer** -> Le contrôle complet de tolérance restante dans `LotQt.add_physical_lots` / -> `LotQt.add_physical_lot` reste à confirmer. -> -| Point | Règle cible | +| Point | Règle | | --- | --- | -| Niveau | Tolérance globale sur ligne ou contrat. | +| Niveau | La tolérance est définie au header `purchase` / `sale`. | +| Héritage | Les lignes héritent de la tolérance header par défaut. | | Transport | Pas de tolérance indépendante par transport. | -| Surconsommation | Consomme la tolérance restante. | -| Sous-consommation | Restitue de la tolérance restante. | +| Ajout physique | Le contrôle se fait au moment de `Add physical lots`. | +| Dépassement ligne | Warning confirmable en anglais si la quantité physique projetée dépasse la tolérance courante de la ligne. | +| Enveloppe globale | Le dépassement ponctuel d'une ligne est accepté après confirmation et consomme l'enveloppe globale du contrat. | +| Tolérance restante | Les lignes du contrat sont recalculées avec `inherit_tol = False`. | +| Surconsommation | Une ligne qui dépasse la tolérance header garde une tolérance `+` au moins égale à son dépassement réel. | +| Autres lignes | Leur tolérance `+` est réduite selon l'enveloppe restante. | +| Sous-consommation | Restitue mécaniquement de la tolérance disponible aux autres lignes lors du recalcul suivant. | ## Section développeur diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 6d4d7e9..17f79c9 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -1307,6 +1307,7 @@ class LotQt( l.lot_unit_line, float(l.lot_quantity or 0), lqt.lot_p.line.unit))) tot_qt = round(tot_qt, 5) + cls._warn_physical_lot_tolerance(lqt, vlots) with Transaction().set_context( _purchase_trade_skip_quantity_consistency=True): # Consume the forecast first. Physical lot saves recompute the @@ -1329,9 +1330,184 @@ class LotQt( if lqt.lot_s and lqt.lot_s.sale_line: affected_lines.append(lqt.lot_s.sale_line) Lot.assert_lines_quantity_consistency(affected_lines) + cls._sync_contract_tolerances_after_physical_lots(affected_lines) if lots: return lots[0].id + + @classmethod + def _line_contract(cls, line): + return getattr(line, 'purchase', None) or getattr(line, 'sale', None) + + @classmethod + def _line_model_name(cls, line): + if getattr(line, 'purchase', None): + return 'purchase.line' + if getattr(line, 'sale', None): + return 'sale.line' + + @classmethod + def _line_theoretical_quantity(cls, line): + return Decimal(str(getattr(line, 'quantity_theorical', None) or 0)) + + @classmethod + def _line_tolerance_percent(cls, line, field): + contract = cls._line_contract(line) + if getattr(line, 'inherit_tol', False) and contract: + value = getattr(contract, field, None) + else: + value = getattr(line, field, None) + if value is None and contract: + value = getattr(contract, field, None) + return Decimal(str(value or 0)) + + @classmethod + def _line_physical_quantity(cls, line): + quantity = Decimal(0) + unit = getattr(line, 'unit', None) + for lot in getattr(line, 'lots', None) or []: + if getattr(lot, 'lot_type', None) == 'physic': + quantity += Decimal(str( + lot.get_current_quantity_converted(unit=unit) or 0)) + return quantity + + @classmethod + def _affected_lines_for_lqt(cls, lqt): + lines = [] + if lqt.lot_p and lqt.lot_p.line: + lines.append(lqt.lot_p.line) + if lqt.lot_s and lqt.lot_s.sale_line: + lines.append(lqt.lot_s.sale_line) + return lines + + @classmethod + def _added_quantity_for_line(cls, line, vlots): + Uom = Pool().get('product.uom') + quantity = Decimal(0) + for lot in vlots: + quantity += Decimal(str(Uom.compute_qty( + lot.lot_unit_line, float(lot.lot_quantity or 0), line.unit))) + return round(quantity, 5) + + @classmethod + def _warn_physical_lot_tolerance(cls, lqt, vlots): + Warning = Pool().get('res.user.warning') + for line in cls._affected_lines_for_lqt(lqt): + theoretical = cls._line_theoretical_quantity(line) + if theoretical <= 0: + continue + tolerance = cls._line_tolerance_percent(line, 'tol_max') + max_quantity = theoretical * (Decimal(1) + tolerance / Decimal(100)) + projected = ( + cls._line_physical_quantity(line) + + cls._added_quantity_for_line(line, vlots)) + if projected <= max_quantity: + continue + warning_name = Warning.format( + "Physical lot tolerance exceeded", + [line, projected, max_quantity]) + if Warning.check(warning_name): + over_percent = ( + (projected - theoretical) / theoretical * Decimal(100)) + raise LotToleranceWarning( + warning_name, + "The physical lot quantity exceeds the current tolerance " + "for this line (projected quantity: %s, maximum with " + "current tolerance: %s, projected tolerance: %s%%). " + "Do you want to continue and consume the contract " + "tolerance envelope?" + % ( + round(projected, 5), + round(max_quantity, 5), + round(over_percent, 2), + )) + + @classmethod + def _contract_lines(cls, contract): + return [ + line for line in getattr(contract, 'lines', None) or [] + if getattr(line, 'type', 'line') == 'line'] + + @classmethod + def _sync_contract_tolerances_after_physical_lots(cls, lines): + contracts = {} + for line in lines: + contract = cls._line_contract(line) + model_name = cls._line_model_name(line) + if contract and model_name: + contracts[(model_name, contract.id)] = (model_name, contract) + + for model_name, contract in contracts.values(): + if model_name == 'purchase.line': + contract = Pool().get('purchase.purchase')(contract.id) + elif model_name == 'sale.line': + contract = Pool().get('sale.sale')(contract.id) + contract_lines = cls._contract_lines(contract) + cls._sync_contract_tolerance_lines(contract, contract_lines) + + @classmethod + def _sync_contract_tolerance_lines(cls, contract, lines): + if not lines: + return + + header_tol_max = Decimal(str(getattr(contract, 'tol_max', None) or 0)) + header_tol_min = Decimal(str(getattr(contract, 'tol_min', None) or 0)) + theoretical_by_line = { + line.id: cls._line_theoretical_quantity(line) for line in lines} + total_theoretical = sum(theoretical_by_line.values(), Decimal(0)) + if total_theoretical <= 0: + return + + total_allowed_over = ( + total_theoretical * header_tol_max / Decimal(100)) + over_by_line = {} + any_over_header = False + for line in lines: + theoretical = theoretical_by_line[line.id] + if theoretical <= 0: + over_by_line[line.id] = Decimal(0) + continue + physical = cls._line_physical_quantity(line) + over = max(physical - theoretical, Decimal(0)) + over_by_line[line.id] = over + header_line_over = theoretical * header_tol_max / Decimal(100) + if over > header_line_over: + any_over_header = True + + if not any_over_header: + return + + line_models = {} + for line in lines: + model_name = cls._line_model_name(line) + if not model_name: + continue + theoretical = theoretical_by_line[line.id] + if theoretical <= 0: + continue + consumed_by_others = sum( + over for line_id, over in over_by_line.items() + if line_id != line.id) + remaining_for_line = max( + total_allowed_over - consumed_by_others, Decimal(0)) + actual_over = over_by_line[line.id] + header_line_over = theoretical * header_tol_max / Decimal(100) + if actual_over > header_line_over: + allowed_over = actual_over + else: + allowed_over = remaining_for_line + allowed_percent = allowed_over / theoretical * Decimal(100) + line.inherit_tol = False + line.tol_min = header_tol_min + line.tol_max = round(allowed_percent, 5) + line.tol_min_qt = round( + theoretical * header_tol_min / Decimal(100), 5) + line.tol_max_qt = round(allowed_over, 5) + line_models.setdefault(model_name, []).append(line) + + for model_name, model_lines in line_models.items(): + if model_lines: + Pool().get(model_name).save(model_lines) def add_physical_lot(self,l): Lot = Pool().get('lot.lot') @@ -2583,6 +2759,10 @@ class LotRemoveWarning(UserWarning): pass +class LotToleranceWarning(UserWarning): + pass + + class LotMatching(Wizard): "Matching" __name__ = "lot.matching"