"""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}