gmao/app_new/cleaning/services/forecast.py
root 374e5e5a54
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Ajouter le module matériel et produits d'entretien
2026-08-21 14:13:00 +00:00

71 lines
4.7 KiB
Python

"""Prévision déterministe tenant compte du calendrier réel du collège."""
from datetime import date, timedelta
from decimal import Decimal
from ...core.models import ProductGeneric, ProductPackaging, StockLot, StockLotBalance, CollegeClosure, ClosureWorkDay, CleaningForecastConfig
def _day_kind(day, closures, permanence_dates):
if day in permanence_dates:
return "permanence"
for closure in closures:
if closure.start_date <= day <= closure.end_date:
return "ferme"
if day.weekday() >= 5:
return "ferme"
return "scolaire"
def forecast_product(product, target_date, *, start_date=None, config=None):
"""Retourne un dictionnaire explicable de besoin et de conditionnement."""
start_date = start_date or date.today()
if target_date < start_date:
raise ValueError("La date cible doit être postérieure au début")
config = config or CleaningForecastConfig.query.filter_by(is_active=True).first() or CleaningForecastConfig()
# Les valeurs de colonne SQLAlchemy ne sont appliquées qu'à l'insertion ;
# conserver les mêmes valeurs par défaut pour un calcul sans configuration.
normal_coefficient = config.normal_coefficient if config.normal_coefficient is not None else Decimal("1")
permanence_coefficient = config.permanence_coefficient if config.permanence_coefficient is not None else Decimal("0.4")
closed_coefficient = config.closed_coefficient if config.closed_coefficient is not None else Decimal("0")
closures = CollegeClosure.query.filter(CollegeClosure.end_date >= start_date,
CollegeClosure.start_date <= target_date).all()
closure_ids = [c.id for c in closures]
permanence_dates = {d.work_date for d in ClosureWorkDay.query.filter(
ClosureWorkDay.work_date >= start_date, ClosureWorkDay.work_date <= target_date,
ClosureWorkDay.closure_id.in_(closure_ids)).all()} if closure_ids else set()
normal = Decimal(str(product.forecast_daily_quantity or 0)) * Decimal(str(normal_coefficient))
permanence = Decimal(str(product.forecast_daily_quantity or 0)) * Decimal(str(permanence_coefficient))
closed = Decimal(str(product.forecast_daily_quantity or 0)) * Decimal(str(closed_coefficient))
quantities = {"scolaire": Decimal("0"), "permanence": Decimal("0"), "ferme": Decimal("0")}
day = start_date
while day <= target_date:
kind = _day_kind(day, closures, permanence_dates)
quantities[kind] += {"scolaire": normal, "permanence": permanence, "ferme": closed}[kind]
day += timedelta(days=1)
total_need = sum(quantities.values(), Decimal("0"))
# Stock réellement utile selon la chronologie FEFO : un lot expiré avant
# la fin de période n'est retenu qu'à concurrence de la consommation avant DLU.
usable = Decimal("0")
expiring = Decimal("0")
for lot in (StockLot.query.filter_by(generic_product_id=product.id)
.filter(StockLot.status.notin_(("bloqué", "detruit", "expire", "epuise")))
.order_by(StockLot.expiry_date.is_(None), StockLot.expiry_date, StockLot.received_at).all()):
quantity = sum((Decimal(str(b.quantity or 0)) for b in lot.balances), Decimal("0"))
if not quantity:
continue
if lot.expiry_date and lot.expiry_date < target_date:
# consommation estimée jusqu'à la DLU (approximation jour par jour)
before = forecast_product(product, lot.expiry_date, start_date=start_date, config=config)["consumption_total"] if lot.expiry_date >= start_date else Decimal("0")
usable += min(quantity, before)
expiring += max(quantity - before, Decimal("0"))
else:
usable += quantity
net = max(total_need + Decimal(str(product.stock_security or 0)) - usable, Decimal("0"))
packaging = ProductPackaging.query.filter_by(generic_product_id=product.id, is_active=True, is_current=True).order_by(ProductPackaging.reference_quantity_per_package).first()
packages = (net / Decimal(str(packaging.reference_quantity_per_package))).to_integral_value(rounding="ROUND_CEILING") if packaging and net else Decimal("0")
proposed = packages * Decimal(str(packaging.reference_quantity_per_package)) if packaging else net
return {"start_date": start_date, "target_date": target_date, "school_days_consumption": quantities["scolaire"],
"permanence_consumption": quantities["permanence"], "closed_consumption": quantities["ferme"],
"consumption_total": total_need, "usable_stock": usable, "expiring_stock": expiring,
"safety_stock": Decimal(str(product.stock_security or 0)), "net_need": net,
"packaging": packaging, "packages_to_order": packages, "proposed_quantity": proposed}