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.
-
| Point |
-Target Rule |
+Rule |
| Level |
-Global tolerance on line or contract. |
+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. |
+
+
+| 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 |
-Consumes remaining tolerance. |
+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 |
-Restores 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.
-
| Point |
-Règle cible |
+Règle |
| Niveau |
-Tolérance globale sur ligne ou contrat. |
+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. |
+| 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 |
-Consomme 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 lignes |
+Leur tolérance + est réduite selon l'enveloppe restante. |
| Sous-consommation |
-Restitue 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"