71 lines
4.7 KiB
Python
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}
|