diff --git a/app_new/__init__.py b/app_new/__init__.py index 421aa21..098ecb7 100644 --- a/app_new/__init__.py +++ b/app_new/__init__.py @@ -132,6 +132,10 @@ def create_app(config_name='default'): from .parts.routes import parts_bp app.register_blueprint(parts_bp, url_prefix='/parts') + + # Matériel et produits d'entretien (stock, FEFO, distributions, prévisions) + from .cleaning import cleaning_bp + app.register_blueprint(cleaning_bp) from .services_module.routes import services_bp app.register_blueprint(services_bp, url_prefix='/services') diff --git a/app_new/cleaning/__init__.py b/app_new/cleaning/__init__.py new file mode 100644 index 0000000..6ffb712 --- /dev/null +++ b/app_new/cleaning/__init__.py @@ -0,0 +1,3 @@ +from .routes import cleaning_bp + +__all__ = ["cleaning_bp"] diff --git a/app_new/cleaning/routes.py b/app_new/cleaning/routes.py new file mode 100644 index 0000000..93b8803 --- /dev/null +++ b/app_new/cleaning/routes.py @@ -0,0 +1,276 @@ +"""Interface protégée du module Matériel & Entretien. +Les agents ne sont jamais des utilisateurs : ils sont uniquement sélectionnés +comme destinataires via la table staff. +""" +from datetime import date +from decimal import Decimal +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required +from ..extensions import db +from ..core.models import ( + ProductCategory, ProductGeneric, CommercialProduct, ProductPackaging, + StockLocation, StockLot, StockLotBalance, StockMovement, Staff, + RequestProfile, StockInventory, StockInventoryLine, + Equipment, MaterialAssignment, +) +from .services.stock import StockError, receive_stock, issue_stock, transfer_stock, transvasement, dilute +from .services.forecast import forecast_product + +cleaning_bp = Blueprint("cleaning", __name__, url_prefix="/cleaning", template_folder="templates") + +def _date(value, default=None): + try: + return date.fromisoformat(value) if value else default + except ValueError: + return default + +@cleaning_bp.route("/") +@login_required +def dashboard(): + products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.display_order, ProductGeneric.name).all() + low = [p for p in products if p.stock_total <= Decimal(str(p.stock_minimum or 0))] + expiring = StockLot.query.filter(StockLot.expiry_date.isnot(None), StockLot.expiry_date <= date.today()).order_by(StockLot.expiry_date).all() + return render_template("cleaning/dashboard.html", products=products, low_stock=low, expiring=expiring, recent=StockMovement.query.order_by(StockMovement.created_at.desc()).limit(15).all()) + +@cleaning_bp.route("/products") +@login_required +def products(): + return render_template("cleaning/products.html", products=ProductGeneric.query.order_by(ProductGeneric.display_order, ProductGeneric.name).all(), categories=ProductCategory.query.order_by(ProductCategory.name).all()) + +@cleaning_bp.route("/products/new", methods=["GET", "POST"]) +@login_required +def product_new(): + categories = ProductCategory.query.order_by(ProductCategory.name).all() + if request.method == "POST": + name = (request.form.get("name") or "").strip() + try: + minimum = Decimal(request.form.get("stock_minimum") or 0) + security = Decimal(request.form.get("stock_security") or 0) + daily = Decimal(request.form.get("forecast_daily_quantity") or 0) + except Exception: + flash("Les quantités doivent être numériques.", "danger") + return render_template("cleaning/product_form.html", product=None, categories=categories) + if not name: + flash("Le nom du produit est obligatoire.", "danger") + elif ProductGeneric.query.filter_by(name=name).first(): + flash("Ce produit générique existe déjà.", "warning") + else: + product = ProductGeneric(name=name, description=request.form.get("description"), category_id=request.form.get("category_id") or None, product_type=request.form.get("product_type") or "consommable", reference_unit=request.form.get("reference_unit") or "unité", stock_minimum=minimum, stock_security=security, forecast_daily_quantity=daily) + db.session.add(product); db.session.commit() + flash("Produit générique créé.", "success") + return redirect(url_for("cleaning.products")) + return render_template("cleaning/product_form.html", product=None, categories=categories) + +@cleaning_bp.route("/products//edit", methods=["GET", "POST"]) +@login_required +def product_edit(id): + product = ProductGeneric.query.get_or_404(id) + if request.method == "POST": + product.name = (request.form.get("name") or product.name).strip() + product.description = request.form.get("description") + product.category_id = request.form.get("category_id") or None + product.reference_unit = request.form.get("reference_unit") or product.reference_unit + product.stock_minimum = Decimal(request.form.get("stock_minimum") or 0) + product.stock_security = Decimal(request.form.get("stock_security") or 0) + product.forecast_daily_quantity = Decimal(request.form.get("forecast_daily_quantity") or 0) + product.is_active = request.form.get("is_active") == "on" + db.session.commit() + flash("Produit générique mis à jour.", "success") + return redirect(url_for("cleaning.products")) + return render_template("cleaning/product_form.html", product=product, categories=ProductCategory.query.order_by(ProductCategory.name).all()) + +@cleaning_bp.route("/references") +@login_required +def references(): + return render_template("cleaning/references.html", references=CommercialProduct.query.order_by(CommercialProduct.commercial_name).all(), products=ProductGeneric.query.order_by(ProductGeneric.name).all()) + +@cleaning_bp.route("/references/new", methods=["GET", "POST"]) +@login_required +def reference_new(): + products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.name).all() + if request.method == "POST": + ref = CommercialProduct(generic_product_id=request.form.get("generic_product_id"), commercial_name=(request.form.get("commercial_name") or "").strip(), brand=request.form.get("brand"), manufacturer_reference=request.form.get("manufacturer_reference"), supplier_reference=request.form.get("supplier_reference"), is_concentrated=request.form.get("is_concentrated") == "on") + if not ref.generic_product_id or not ref.commercial_name: + flash("Produit générique et nom commercial sont obligatoires.", "danger") + else: + db.session.add(ref); db.session.commit() + flash("Référence commerciale créée.", "success") + return redirect(url_for("cleaning.references")) + return render_template("cleaning/reference_form.html", products=products) + + +@cleaning_bp.route("/packaging/new", methods=["GET", "POST"]) +@login_required +def packaging_new(): + products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.name).all() + references = CommercialProduct.query.filter_by(is_active=True).order_by(CommercialProduct.commercial_name).all() + if request.method == "POST": + try: + item = ProductPackaging(generic_product_id=int(request.form["generic_product_id"]), + commercial_product_id=request.form.get("commercial_product_id") or None, + name=(request.form.get("name") or "").strip(), purchase_unit=request.form.get("purchase_unit") or "conditionnement", + units_per_package=Decimal(request.form.get("units_per_package") or 1), + reference_quantity_per_package=Decimal(request.form["reference_quantity_per_package"]), + is_current=request.form.get("is_current") == "on") + if not item.name: raise ValueError("Le nom du conditionnement est obligatoire") + db.session.add(item); db.session.commit(); flash("Conditionnement enregistré.", "success"); return redirect(url_for("cleaning.references")) + except (KeyError, ValueError, ArithmeticError) as exc: + db.session.rollback(); flash(str(exc), "danger") + return render_template("cleaning/packaging_form.html", products=products, references=references) + +@cleaning_bp.route("/stock") +@login_required +def stock(): + balances = StockLotBalance.query.join(StockLot).join(ProductGeneric).join(StockLocation).order_by(ProductGeneric.name, StockLot.expiry_date).all() + return render_template("cleaning/stock.html", balances=balances, locations=StockLocation.query.filter_by(is_active=True).order_by(StockLocation.name).all()) + +@cleaning_bp.route("/locations", methods=["GET", "POST"]) +@login_required +def locations(): + if request.method == "POST": + name = (request.form.get("name") or "").strip() + if name: + db.session.add(StockLocation(name=name, notes=request.form.get("notes"))); db.session.commit(); flash("Emplacement créé.", "success") + return redirect(url_for("cleaning.locations")) + return render_template("cleaning/locations.html", locations=StockLocation.query.order_by(StockLocation.name).all()) + +@cleaning_bp.route("/receive", methods=["GET", "POST"]) +@login_required +def receive(): + products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.name).all() + if request.method == "POST": + try: + lot = receive_stock(generic_product_id=int(request.form["generic_product_id"]), commercial_product_id=int(request.form["commercial_product_id"]), packaging_id=int(request.form["packaging_id"]), packages=request.form["packages"], lot_number=request.form["lot_number"], received_at=_date(request.form.get("received_at"), date.today()), location_id=int(request.form["location_id"]), user_id=current_user.id, expiry_date=_date(request.form.get("expiry_date"))) + db.session.commit() + flash(f"Réception enregistrée ({lot.quantity_received} unité(s) de référence).", "success") + return redirect(url_for("cleaning.stock")) + except (KeyError, ValueError, StockError) as exc: + db.session.rollback(); flash(str(exc), "danger") + return render_template("cleaning/receive.html", products=products, references=CommercialProduct.query.filter_by(is_active=True).all(), packagings=ProductPackaging.query.filter_by(is_active=True).all(), locations=StockLocation.query.filter_by(is_active=True).all()) + +@cleaning_bp.route("/issue", methods=["GET", "POST"]) +@login_required +def issue(): + if request.method == "POST": + try: + issue_stock(generic_product_id=int(request.form["generic_product_id"]), quantity=request.form["quantity"], source_location_id=int(request.form["source_location_id"]), staff_id=request.form.get("staff_id") or None, user_id=current_user.id, comment=request.form.get("comment")) + db.session.commit(); flash("Sortie FEFO enregistrée.", "success") + return redirect(url_for("cleaning.movements")) + except (KeyError, ValueError, StockError) as exc: + db.session.rollback(); flash(str(exc), "danger") + return render_template("cleaning/issue.html", products=ProductGeneric.query.filter_by(is_active=True).all(), locations=StockLocation.query.filter_by(is_active=True).all(), staff=Staff.query.filter_by(is_active=True).order_by(Staff.last_name).all()) + +@cleaning_bp.route("/transfer", methods=["GET", "POST"]) +@login_required +def transfer(): + if request.method == "POST": + try: + transfer_stock(generic_product_id=int(request.form["generic_product_id"]), quantity=request.form["quantity"], source_location_id=int(request.form["source_location_id"]), destination_location_id=int(request.form["destination_location_id"]), user_id=current_user.id, comment=request.form.get("comment")); db.session.commit() + flash("Transfert enregistré.", "success"); return redirect(url_for("cleaning.stock")) + except (KeyError, ValueError, StockError) as exc: + db.session.rollback(); flash(str(exc), "danger") + return render_template("cleaning/transfer.html", products=ProductGeneric.query.filter_by(is_active=True).all(), locations=StockLocation.query.filter_by(is_active=True).all()) + +@cleaning_bp.route("/movements") +@login_required +def movements(): + return render_template("cleaning/movements.html", movements=StockMovement.query.order_by(StockMovement.created_at.desc()).limit(300).all()) + + +@cleaning_bp.route("/transvasement", methods=["GET", "POST"]) +@login_required +def transvasement_page(): + if request.method == "POST": + try: + fn = dilute if request.form.get("dilution") == "on" else transvasement + kwargs = dict(generic_product_id=int(request.form["generic_product_id"]), quantity_liters=request.form["quantity_liters"], source_location_id=int(request.form["source_location_id"]), container_id=request.form.get("container_id") or None, user_id=current_user.id, comment=request.form.get("comment")) + if fn is dilute: kwargs["total_liters"] = kwargs.pop("quantity_liters"); kwargs["ratio_percent"] = request.form["ratio_percent"] + fn(**kwargs); db.session.commit(); flash("Transvasement/dilution enregistré.", "success"); return redirect(url_for("cleaning.movements")) + except (KeyError, ValueError, StockError) as exc: + db.session.rollback(); flash(str(exc), "danger") + return render_template("cleaning/transvasement.html", products=ProductGeneric.query.filter_by(is_active=True).all(), locations=StockLocation.query.filter_by(is_active=True).all(), containers=ReusableContainer.query.filter_by(state="disponible").all()) + +@cleaning_bp.route("/forecast/") +@login_required +def forecast(product_id): + product = ProductGeneric.query.get_or_404(product_id) + result = forecast_product(product, _date(request.args.get("target_date"), date.today())) + return render_template("cleaning/forecast.html", product=product, result=result) + + +@cleaning_bp.route("/forecast-config", methods=["GET", "POST"]) +@login_required +def forecast_config(): + config = CleaningForecastConfig.query.filter_by(is_active=True).first() + if request.method == "POST": + if not config: config = CleaningForecastConfig(); db.session.add(config) + config.normal_coefficient = Decimal(request.form.get("normal_coefficient") or 1) + config.permanence_coefficient = Decimal(request.form.get("permanence_coefficient") or 0) + config.closed_coefficient = Decimal(request.form.get("closed_coefficient") or 0) + config.exceptional_coefficient = Decimal(request.form.get("exceptional_coefficient") or 1) + db.session.commit(); flash("Coefficients de prévision enregistrés.", "success"); return redirect(url_for("cleaning.forecast_config")) + return render_template("cleaning/forecast_config.html", config=config, + values={"normal_coefficient": config.normal_coefficient if config else 1, + "permanence_coefficient": config.permanence_coefficient if config else Decimal("0.4"), + "closed_coefficient": config.closed_coefficient if config else 0, + "exceptional_coefficient": config.exceptional_coefficient if config else 1}) + +@cleaning_bp.route("/inventory", methods=["GET", "POST"]) +@login_required +def inventory(): + if request.method == "POST": + location = StockLocation.query.get_or_404(int(request.form["location_id"])) + inv = StockInventory(location_id=location.id, user_id=current_user.id, status="valide") + db.session.add(inv); db.session.flush() + for raw in request.form.getlist("line_lot_id"): + lot = StockLot.query.get(int(raw)); balance = StockLotBalance.query.filter_by(lot_id=lot.id, location_id=location.id).first() + if not balance: continue + counted = Decimal(request.form.get(f"counted_{lot.id}") or 0); theoretical = Decimal(str(balance.quantity or 0)) + db.session.add(StockInventoryLine(inventory_id=inv.id, lot_id=lot.id, theoretical_quantity=theoretical, counted_quantity=counted)) + diff = counted - theoretical + if diff: + balance.quantity = counted + db.session.add(StockMovement(generic_product_id=lot.generic_product_id, commercial_product_id=lot.commercial_product_id, lot_id=lot.id, movement_type="correction_inventaire", quantity_reference=diff, destination_location_id=location.id, user_id=current_user.id, comment="Correction après inventaire")) + db.session.commit(); flash("Inventaire validé et écarts tracés.", "success"); return redirect(url_for("cleaning.inventory")) + locations = StockLocation.query.filter_by(is_active=True).all(); selected = request.args.get("location_id", type=int) + rows = StockLotBalance.query.filter_by(location_id=selected).join(StockLot).all() if selected else [] + return render_template("cleaning/inventory.html", locations=locations, rows=rows, selected=selected) + +@cleaning_bp.route("/requests") +@login_required +def request_profiles(): + return render_template("cleaning/request_profiles.html", profiles=RequestProfile.query.order_by(RequestProfile.name).all()) + + +@cleaning_bp.route("/requests/new", methods=["GET", "POST"]) +@login_required +def request_profile_new(): + products = ProductGeneric.query.filter_by(is_active=True, request_enabled=True).order_by(ProductGeneric.name).all() + if request.method == "POST": + profile = RequestProfile(name=(request.form.get("name") or "").strip(), description=request.form.get("description")) + if not profile.name: flash("Le nom du profil est obligatoire.", "danger") + else: + db.session.add(profile); db.session.flush() + for order, raw in enumerate(request.form.getlist("product_ids")): + db.session.add(RequestProfileItem(profile_id=profile.id, product_id=int(raw), display_order=order)) + db.session.commit(); flash("Profil de demande créé.", "success"); return redirect(url_for("cleaning.request_profiles")) + return render_template("cleaning/request_profile_form.html", products=products) + +@cleaning_bp.route("/requests/print/") +@login_required +def request_print(profile_id): + profile = RequestProfile.query.get_or_404(profile_id) + staff = Staff.query.filter_by(is_active=True).order_by(Staff.last_name, Staff.first_name).all() + return render_template("cleaning/request_print.html", profile=profile, staff=staff, today=date.today()) + +@cleaning_bp.route("/material") +@login_required +def material(): + return render_template("cleaning/material.html", assignments=MaterialAssignment.query.order_by(MaterialAssignment.assigned_at.desc()).all(), equipment=Equipment.query.filter_by(is_deleted=False).order_by(Equipment.name).all(), staff=Staff.query.filter_by(is_active=True).all()) + + +@cleaning_bp.route("/material/assign", methods=["POST"]) +@login_required +def material_assign(): + assignment = MaterialAssignment(equipment_id=int(request.form["equipment_id"]), staff_id=request.form.get("staff_id") or None, zone_id=request.form.get("zone_id") or None, room_id=request.form.get("room_id") or None, status=request.form.get("status") or "affecte", notes=request.form.get("notes")) + db.session.add(assignment); db.session.commit(); flash("Affectation de matériel enregistrée.", "success"); return redirect(url_for("cleaning.material")) diff --git a/app_new/cleaning/services/__init__.py b/app_new/cleaning/services/__init__.py new file mode 100644 index 0000000..17ef981 --- /dev/null +++ b/app_new/cleaning/services/__init__.py @@ -0,0 +1,4 @@ +from .stock import StockError, receive_stock, issue_stock, transfer_stock, transvasement, dilute +from .forecast import forecast_product + +__all__ = ["StockError", "receive_stock", "issue_stock", "transfer_stock", "transvasement", "dilute", "forecast_product"] diff --git a/app_new/cleaning/services/forecast.py b/app_new/cleaning/services/forecast.py new file mode 100644 index 0000000..10c13d4 --- /dev/null +++ b/app_new/cleaning/services/forecast.py @@ -0,0 +1,71 @@ +"""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} diff --git a/app_new/cleaning/services/stock.py b/app_new/cleaning/services/stock.py new file mode 100644 index 0000000..e7a9e0a --- /dev/null +++ b/app_new/cleaning/services/stock.py @@ -0,0 +1,173 @@ +"""Opérations transactionnelles et FEFO du stock d'entretien.""" +from datetime import date +from decimal import Decimal, ROUND_HALF_UP + +from sqlalchemy import or_, case + +from ...extensions import db +from ...core.models import ( + ProductGeneric, CommercialProduct, ProductPackaging, StockLocation, + StockLot, StockLotBalance, StockMovement, ReusableContainer, ContainerFill, +) + +Q = Decimal("0.000001") + + +class StockError(ValueError): + pass + + +def dec(value): + try: + return Decimal(str(value)).quantize(Q, rounding=ROUND_HALF_UP) + except Exception as exc: + raise StockError("Quantité invalide") from exc + + +def _balance(lot_id, location_id, create=False): + query = StockLotBalance.query.filter_by(lot_id=lot_id, location_id=location_id) + balance = query.with_for_update().first() + if not balance and create: + balance = StockLotBalance(lot_id=lot_id, location_id=location_id, quantity=0) + db.session.add(balance) + db.session.flush() + return balance + + +def _movement(**kwargs): + movement = StockMovement(**kwargs) + db.session.add(movement) + return movement + + +def receive_stock(*, generic_product_id, commercial_product_id, packaging_id, packages, + lot_number, received_at, location_id, user_id=None, expiry_date=None, + manufactured_at=None, comment=None): + """Réceptionne un nombre de conditionnements et crée le lot + mouvement.""" + packages = dec(packages) + if packages <= 0: + raise StockError("La quantité reçue doit être positive") + packaging = ProductPackaging.query.get(packaging_id) + if not packaging or packaging.generic_product_id != generic_product_id: + raise StockError("Conditionnement inconnu pour ce produit") + if packaging.commercial_product_id and packaging.commercial_product_id != commercial_product_id: + raise StockError("Conditionnement associé à une autre référence") + quantity = (packages * dec(packaging.reference_quantity_per_package)).quantize(Q) + lot = StockLot(generic_product_id=generic_product_id, commercial_product_id=commercial_product_id, + lot_number=(lot_number or "").strip() or "Sans numéro", received_at=received_at, + manufactured_at=manufactured_at, expiry_date=expiry_date, + quantity_received=quantity) + db.session.add(lot) + db.session.flush() + balance = _balance(lot.id, location_id, create=True) + balance.quantity = quantity + _movement(generic_product_id=generic_product_id, commercial_product_id=commercial_product_id, + lot_id=lot.id, movement_type="reception_fournisseur", quantity_reference=quantity, + destination_location_id=location_id, user_id=user_id, comment=comment) + return lot + + +def _usable_lots(generic_product_id, location_id, today=None): + today = today or date.today() + return (StockLot.query.join(StockLotBalance) + .filter(StockLot.generic_product_id == generic_product_id, + StockLotBalance.location_id == location_id, + StockLotBalance.quantity > 0, + StockLot.status.notin_(("bloqué", "detruit", "expire", "epuise")), + or_(StockLot.expiry_date.is_(None), StockLot.expiry_date >= today)) + .order_by(case((StockLot.expiry_date.is_(None), 1), else_=0), StockLot.expiry_date, StockLot.received_at, StockLot.id) + .with_for_update().all()) + + +def issue_stock(*, generic_product_id, quantity, source_location_id, user_id=None, + staff_id=None, movement_type="sortie_agent", comment=None, today=None): + """Sortie FEFO atomique. Retourne les mouvements créés.""" + requested = dec(quantity) + if requested <= 0: + raise StockError("La quantité doit être positive") + remaining = requested + movements = [] + for lot in _usable_lots(generic_product_id, source_location_id, today=today): + balance = _balance(lot.id, source_location_id) + available = dec(balance.quantity) + taken = min(available, remaining) + if taken <= 0: + continue + balance.quantity = available - taken + if balance.quantity == 0 and all(dec(b.quantity) == 0 for b in lot.balances): + lot.status = "epuise" + movements.append(_movement(generic_product_id=generic_product_id, + commercial_product_id=lot.commercial_product_id, lot_id=lot.id, + movement_type=movement_type, quantity_reference=-taken, + source_location_id=source_location_id, staff_id=staff_id, user_id=user_id, comment=comment)) + remaining -= taken + if remaining <= 0: + break + if remaining > 0: + raise StockError(f"Stock disponible insuffisant (manque {remaining} unité(s) de référence)") + return movements + + +def transfer_stock(*, generic_product_id, quantity, source_location_id, destination_location_id, + user_id=None, comment=None): + if source_location_id == destination_location_id: + raise StockError("Les emplacements source et destination doivent être différents") + requested = dec(quantity) + if requested <= 0: + raise StockError("La quantité doit être positive") + remaining = requested + total = Decimal("0") + for lot in _usable_lots(generic_product_id, source_location_id): + source = _balance(lot.id, source_location_id) + taken = min(dec(source.quantity), remaining) + if taken <= 0: + continue + source.quantity = dec(source.quantity) - taken + destination = _balance(lot.id, destination_location_id, create=True) + destination.quantity = dec(destination.quantity) + taken + _movement(generic_product_id=generic_product_id, + commercial_product_id=lot.commercial_product_id, lot_id=lot.id, + movement_type="transfert", quantity_reference=-taken, + source_location_id=source_location_id, destination_location_id=destination_location_id, + user_id=user_id, comment=comment) + total += taken + remaining -= taken + if remaining <= 0: + break + if remaining > 0: + raise StockError(f"Stock disponible insuffisant (manque {remaining} unité(s) de référence)") + return total + + +def transvasement(*, generic_product_id, quantity_liters, source_location_id, + container_id=None, user_id=None, staff_id=None, comment=None): + movements = issue_stock(generic_product_id=generic_product_id, quantity=quantity_liters, + source_location_id=source_location_id, user_id=user_id, + staff_id=staff_id, movement_type="transvasement", comment=comment) + total = sum((-dec(m.quantity_reference) for m in movements), Decimal("0")) + if container_id: + container = ReusableContainer.query.get(container_id) + if not container: + raise StockError("Flacon inconnu") + if container.dedicated_product_id and container.dedicated_product_id != generic_product_id: + raise StockError("Ce flacon est dédié à un autre produit") + if total > dec(container.volume_liters): + raise StockError("Le volume dépasse la capacité du flacon") + container.current_product_id = generic_product_id + container.last_filled_at = db.func.now() + db.session.flush() + db.session.add(ContainerFill(container_id=container.id, movement_id=movements[0].id, + volume_liters=total)) + return movements + + +def dilute(*, generic_product_id, total_liters, ratio_percent, source_location_id, + user_id=None, staff_id=None, container_id=None, comment=None): + total = dec(total_liters) + ratio = dec(ratio_percent) + if total <= 0 or ratio <= 0 or ratio >= 100: + raise StockError("Volume et taux de dilution invalides") + concentrate = (total * ratio / Decimal("100")).quantize(Q) + return transvasement(generic_product_id=generic_product_id, quantity_liters=concentrate, + source_location_id=source_location_id, container_id=container_id, + user_id=user_id, staff_id=staff_id, comment=f"Dilution {ratio}%{(' — ' + comment) if comment else ''}") diff --git a/app_new/cleaning/templates/cleaning/dashboard.html b/app_new/cleaning/templates/cleaning/dashboard.html new file mode 100644 index 0000000..9d1df25 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/dashboard.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Matériel & Entretien{% endblock %}{% block content %}

Matériel & Produits d'entretien

Produits
{{ products|length }}Gérer
Sous stock minimum
{{ low_stock|length }}
DLU expirées
{{ expiring|length }}

Mouvements récents

{% for m in recent %}{% else %}{% endfor %}
DateTypeProduitQuantité
{{ m.created_at|datetime_fmt }}{{ m.movement_type }}{{ m.generic_product.name }}{{ m.quantity_reference }}
Aucun mouvement.
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/forecast.html b/app_new/cleaning/templates/cleaning/forecast.html new file mode 100644 index 0000000..648646e --- /dev/null +++ b/app_new/cleaning/templates/cleaning/forecast.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Prévision{% endblock %}{% block content %}

Prévision — {{ product.name }}

ÉlémentQuantité
Jours scolaires{{ result.school_days_consumption }}
Permanences{{ result.permanence_consumption }}
Fermetures{{ result.closed_consumption }}
Consommation totale{{ result.consumption_total }}
Stock utilisable{{ result.usable_stock }}
Stock risquant d'expirer{{ result.expiring_stock }}
Stock de sécurité{{ result.safety_stock }}
Besoin net{{ result.net_need }}
Commande proposée{{ result.packages_to_order }} conditionnement(s), soit {{ result.proposed_quantity }}
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/forecast_config.html b/app_new/cleaning/templates/cleaning/forecast_config.html new file mode 100644 index 0000000..4a8013e --- /dev/null +++ b/app_new/cleaning/templates/cleaning/forecast_config.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Coefficients de prévision{% endblock %}{% block content %}

Coefficients calendrier

1 = journée scolaire normale ; les permanences et fermetures restent configurables.

{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/inventory.html b/app_new/cleaning/templates/cleaning/inventory.html new file mode 100644 index 0000000..17a4d65 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/inventory.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Inventaire{% endblock %}{% block content %}

Inventaire physique

{% if selected %}
{% for b in rows %}{% endfor %}
LotThéoriqueCompté
{{ b.lot.generic_product.name }} — {{ b.lot.lot_number }}{{ b.quantity }}
{% endif %}
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/issue.html b/app_new/cleaning/templates/cleaning/issue.html new file mode 100644 index 0000000..332769e --- /dev/null +++ b/app_new/cleaning/templates/cleaning/issue.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Sortie agent{% endblock %}{% block content %}

Sortie de produits

{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/locations.html b/app_new/cleaning/templates/cleaning/locations.html new file mode 100644 index 0000000..9e65aeb --- /dev/null +++ b/app_new/cleaning/templates/cleaning/locations.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Emplacements de stock{% endblock %}{% block content %}

Emplacements

    {% for l in locations %}
  • {{ l.name }}
  • {% endfor %}
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/material.html b/app_new/cleaning/templates/cleaning/material.html new file mode 100644 index 0000000..fcbe0b6 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/material.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Matériel durable{% endblock %}{% block content %}

Matériel durable de ménage

Les équipements existants sont réutilisés ; cette vue conserve leurs affectations aux agents/zones.

{% for a in assignments %}{% else %}{% endfor %}
ÉquipementStatutAgentZone/salle
{{ a.equipment.name }}{{ a.status }}{{ a.staff.full_name if a.staff else '—' }}{{ a.room.name if a.room else (a.zone.name if a.zone else '—') }}
Aucune affectation.
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/movements.html b/app_new/cleaning/templates/cleaning/movements.html new file mode 100644 index 0000000..3814081 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/movements.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Mouvements de stock{% endblock %}{% block content %}

Journal immuable des mouvements

{% for m in movements %}{% endfor %}
DateTypeProduitLotQuantitéAgentUtilisateur
{{ m.created_at|datetime_fmt }}{{ m.movement_type }}{{ m.generic_product.name }}{{ m.lot.lot_number if m.lot else '—' }}{{ m.quantity_reference }}{{ m.staff.full_name if m.staff else '—' }}{{ m.user.username if m.user else '—' }}
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/packaging_form.html b/app_new/cleaning/templates/cleaning/packaging_form.html new file mode 100644 index 0000000..25025f4 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/packaging_form.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Conditionnement{% endblock %}{% block content %}

Conditionnement d'achat


{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/product_form.html b/app_new/cleaning/templates/cleaning/product_form.html new file mode 100644 index 0000000..f754ec7 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/product_form.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Produit générique{% endblock %}{% block content %}

{{ 'Modifier' if product else 'Nouveau' }} produit générique

{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/products.html b/app_new/cleaning/templates/cleaning/products.html new file mode 100644 index 0000000..d52331a --- /dev/null +++ b/app_new/cleaning/templates/cleaning/products.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Produits génériques{% endblock %}{% block content %}

Produits génériques

Nouveau produit

Le produit générique reste stable ; les références fournisseurs sont gérées séparément.

{% for p in products %}{% else %}{% endfor %}
NomCatégorieUnitéPrévision/jourStock
{{ p.name }}{{ p.category.name if p.category else '—' }}{{ p.reference_unit }}{{ p.forecast_daily_quantity }}{{ p.stock_total }}Modifier Prévoir
Aucun produit.
Références commerciales
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/receive.html b/app_new/cleaning/templates/cleaning/receive.html new file mode 100644 index 0000000..8fd5e20 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/receive.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Réception{% endblock %}{% block content %}

Réception fournisseur

{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/reference_form.html b/app_new/cleaning/templates/cleaning/reference_form.html new file mode 100644 index 0000000..effac89 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/reference_form.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Référence commerciale{% endblock %}{% block content %}

Nouvelle référence commerciale


{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/references.html b/app_new/cleaning/templates/cleaning/references.html new file mode 100644 index 0000000..a55f3bd --- /dev/null +++ b/app_new/cleaning/templates/cleaning/references.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Références commerciales{% endblock %}{% block content %}

Références commerciales

Nouvelle référence
{% for r in references %}{% else %}{% endfor %}
Produit génériqueNom commercialMarqueActif
{{ r.generic_product.name }}{{ r.commercial_name }}{{ r.brand or '—' }}{{ 'Oui' if r.is_active else 'Non' }}
Aucune référence.
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/request_print.html b/app_new/cleaning/templates/cleaning/request_print.html new file mode 100644 index 0000000..5450ea2 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/request_print.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Fiche de demande{% endblock %}{% block content %}

Fiche papier — {{ profile.name }}

Établissement : ____________________   Date : {{ today.strftime('%d/%m/%Y') }}

{% for item in profile.items %}{% endfor %}
Produit génériqueQuantité demandéeCommentaire
{{ item.product.name }}

Signature : ______________________________

{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/request_profile_form.html b/app_new/cleaning/templates/cleaning/request_profile_form.html new file mode 100644 index 0000000..d2cad93 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/request_profile_form.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Profil de demande{% endblock %}{% block content %}

Nouveau profil de demande papier

Produits génériques affichés
{% for p in products %}{% endfor %}
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/request_profiles.html b/app_new/cleaning/templates/cleaning/request_profiles.html new file mode 100644 index 0000000..5eedce8 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/request_profiles.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Fiches de demande{% endblock %}{% block content %}

Profils de fiches papier

{% for p in profiles %}
{{ p.name }} — {{ p.items|length }} produit(s)Imprimer
{% else %}

Aucun profil configuré.

{% endfor %}
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/stock.html b/app_new/cleaning/templates/cleaning/stock.html new file mode 100644 index 0000000..ca012a5 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/stock.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}État du stock{% endblock %}{% block content %}

État du stock par lot et emplacement

{% for b in balances %}{% else %}{% endfor %}
ProduitLotDLUEmplacementQuantitéStatut
{{ b.lot.generic_product.name }}{{ b.lot.lot_number }}{{ b.lot.expiry_date or '—' }}{{ b.location.name }}{{ b.quantity }} {{ b.lot.generic_product.reference_unit }}{{ b.lot.status }}
Aucun stock.
{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/transfer.html b/app_new/cleaning/templates/cleaning/transfer.html new file mode 100644 index 0000000..b0d7ec5 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/transfer.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Transfert{% endblock %}{% block content %}

Transfert entre emplacements

{% endblock %} diff --git a/app_new/cleaning/templates/cleaning/transvasement.html b/app_new/cleaning/templates/cleaning/transvasement.html new file mode 100644 index 0000000..3c3d825 --- /dev/null +++ b/app_new/cleaning/templates/cleaning/transvasement.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block title %}Transvasement{% endblock %}{% block content %}

Transvasement / dilution

{% endblock %} diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py index 5545184..2ade088 100644 --- a/app_new/core/authorization.py +++ b/app_new/core/authorization.py @@ -73,7 +73,7 @@ PATRIMOINE_BLUEPRINTS = { "equipments_scheduled", "buildings", "zones", "rooms", "room_types", "wizard", "lots", } PLANNING_BLUEPRINTS = {"planning", "scheduler", "interventions_planning"} -STOCK_BLUEPRINTS = {"parts", "meters"} +STOCK_BLUEPRINTS = {"parts", "meters", "cleaning"} CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"} PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"} diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 4e691d1..b18f5b4 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -19,6 +19,13 @@ from .planning import ( from .settings import AppSettings from .audit import AuditLog, TemplateAuditMark from .prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry +from .cleaning import ( + ProductCategory, ProductGeneric, CommercialProduct, ProductPackaging, + StockLocation, StockLot, StockLotBalance, StockMovement, + MaterialAssignment, ReusableContainer, ContainerFill, ProductDocument, + RequestProfile, RequestProfileItem, StockInventory, StockInventoryLine, + CleaningForecastConfig, +) __all__ = [ 'User', 'Staff', @@ -33,4 +40,9 @@ __all__ = [ 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', 'AppSettings', 'AuditLog', 'TemplateAuditMark', 'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry', + 'ProductCategory', 'ProductGeneric', 'CommercialProduct', 'ProductPackaging', + 'StockLocation', 'StockLot', 'StockLotBalance', 'StockMovement', + 'MaterialAssignment', 'ReusableContainer', 'ContainerFill', 'ProductDocument', + 'RequestProfile', 'RequestProfileItem', 'StockInventory', 'StockInventoryLine', + 'CleaningForecastConfig', ] diff --git a/app_new/core/models/cleaning.py b/app_new/core/models/cleaning.py new file mode 100644 index 0000000..eba25dc --- /dev/null +++ b/app_new/core/models/cleaning.py @@ -0,0 +1,288 @@ +"""Modèles du module Matériel & Produits d'entretien. + +Les quantités sont toujours exprimées dans l'unité de référence du produit et +stockées en DECIMAL. Les mouvements constituent l'historique immuable ; les +soldes par lot/emplacement sont des projections verrouillées pendant les +opérations transactionnelles. +""" +from datetime import datetime, timezone +from decimal import Decimal + +from ...extensions import db + + +def utcnow(): + return datetime.now(timezone.utc) + + +class ProductCategory(db.Model): + __tablename__ = "cleaning_product_categories" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + description = db.Column(db.Text) + is_active = db.Column(db.Boolean, nullable=False, default=True) + products = db.relationship("ProductGeneric", back_populates="category") + + +class ProductGeneric(db.Model): + __tablename__ = "cleaning_products_generic" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, unique=True) + description = db.Column(db.Text) + category_id = db.Column(db.Integer, db.ForeignKey("cleaning_product_categories.id"), index=True) + product_type = db.Column(db.String(40), nullable=False, default="consommable") + reference_unit = db.Column(db.String(30), nullable=False, default="unité") + is_active = db.Column(db.Boolean, nullable=False, default=True) + stock_minimum = db.Column(db.Numeric(14, 6), nullable=False, default=0) + stock_security = db.Column(db.Numeric(14, 6), nullable=False, default=0) + forecast_daily_quantity = db.Column(db.Numeric(14, 6), nullable=False, default=0) + display_order = db.Column(db.Integer, nullable=False, default=0) + usage_zones = db.Column(db.String(500)) + request_enabled = db.Column(db.Boolean, nullable=False, default=True) + allowed_distribution_modes = db.Column(db.String(255), nullable=False, default="standard") + created_at = db.Column(db.DateTime, nullable=False, default=utcnow) + updated_at = db.Column(db.DateTime, nullable=False, default=utcnow, onupdate=utcnow) + category = db.relationship("ProductCategory", back_populates="products") + commercial_products = db.relationship("CommercialProduct", back_populates="generic_product", cascade="all, delete-orphan") + packagings = db.relationship("ProductPackaging", back_populates="generic_product", cascade="all, delete-orphan") + lots = db.relationship("StockLot", back_populates="generic_product") + + @property + def stock_total(self): + return sum((b.quantity for lot in self.lots for b in lot.balances), Decimal("0")) + + +class CommercialProduct(db.Model): + __tablename__ = "cleaning_commercial_products" + id = db.Column(db.Integer, primary_key=True) + generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) + commercial_name = db.Column(db.String(200), nullable=False) + brand = db.Column(db.String(120)) + manufacturer_id = db.Column(db.Integer, db.ForeignKey("companies.id"), index=True) + supplier_id = db.Column(db.Integer, db.ForeignKey("companies.id"), index=True) + manufacturer_reference = db.Column(db.String(120)) + supplier_reference = db.Column(db.String(120)) + barcode = db.Column(db.String(80), index=True) + valid_from = db.Column(db.Date) + valid_to = db.Column(db.Date) + is_active = db.Column(db.Boolean, nullable=False, default=True) + photo_path = db.Column(db.String(500)) + dosage_information = db.Column(db.Text) + is_concentrated = db.Column(db.Boolean, nullable=False, default=False) + after_opening_days = db.Column(db.Integer) + safety_notes = db.Column(db.Text) + pictograms = db.Column(db.String(500)) + warning_statement = db.Column(db.String(120)) + hazard_statements = db.Column(db.Text) + precautionary_statements = db.Column(db.Text) + required_ppe = db.Column(db.String(500)) + storage_conditions = db.Column(db.Text) + incompatibilities = db.Column(db.Text) + created_at = db.Column(db.DateTime, nullable=False, default=utcnow) + generic_product = db.relationship("ProductGeneric", back_populates="commercial_products") + manufacturer = db.relationship("Company", foreign_keys=[manufacturer_id]) + supplier = db.relationship("Company", foreign_keys=[supplier_id]) + lots = db.relationship("StockLot", back_populates="commercial_product") + packagings = db.relationship("ProductPackaging", back_populates="commercial_product") + documents = db.relationship("ProductDocument", back_populates="commercial_product", cascade="all, delete-orphan") + + +class ProductPackaging(db.Model): + __tablename__ = "cleaning_product_packagings" + id = db.Column(db.Integer, primary_key=True) + generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) + commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id"), index=True) + name = db.Column(db.String(160), nullable=False) + purchase_unit = db.Column(db.String(40), nullable=False, default="conditionnement") + units_per_package = db.Column(db.Numeric(14, 6), nullable=False, default=1) + reference_quantity_per_package = db.Column(db.Numeric(14, 6), nullable=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + is_current = db.Column(db.Boolean, nullable=False, default=True) + generic_product = db.relationship("ProductGeneric", back_populates="packagings") + commercial_product = db.relationship("CommercialProduct", back_populates="packagings") + + +class StockLocation(db.Model): + __tablename__ = "cleaning_stock_locations" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(160), nullable=False, unique=True) + building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), index=True) + zone_id = db.Column(db.Integer, db.ForeignKey("zones.id"), index=True) + room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), index=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + notes = db.Column(db.Text) + balances = db.relationship("StockLotBalance", back_populates="location") + + +class StockLot(db.Model): + __tablename__ = "cleaning_stock_lots" + id = db.Column(db.Integer, primary_key=True) + generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) + commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id"), nullable=False, index=True) + lot_number = db.Column(db.String(120), nullable=False, index=True) + received_at = db.Column(db.Date, nullable=False) + manufactured_at = db.Column(db.Date) + expiry_date = db.Column(db.Date, index=True) + quantity_received = db.Column(db.Numeric(14, 6), nullable=False) + opened_at = db.Column(db.Date) + after_opening_days = db.Column(db.Integer) + status = db.Column(db.String(30), nullable=False, default="disponible", index=True) + notes = db.Column(db.Text) + generic_product = db.relationship("ProductGeneric", back_populates="lots") + commercial_product = db.relationship("CommercialProduct", back_populates="lots") + balances = db.relationship("StockLotBalance", back_populates="lot", cascade="all, delete-orphan") + movements = db.relationship("StockMovement", back_populates="lot") + + +class StockLotBalance(db.Model): + __tablename__ = "cleaning_stock_lot_balances" + __table_args__ = (db.UniqueConstraint("lot_id", "location_id", name="uq_cleaning_lot_location"),) + id = db.Column(db.Integer, primary_key=True) + lot_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_lots.id", ondelete="RESTRICT"), nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id", ondelete="RESTRICT"), nullable=False, index=True) + quantity = db.Column(db.Numeric(14, 6), nullable=False, default=0) + lot = db.relationship("StockLot", back_populates="balances") + location = db.relationship("StockLocation", back_populates="balances") + + +class StockMovement(db.Model): + __tablename__ = "cleaning_stock_movements" + id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) + commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id"), index=True) + lot_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_lots.id"), index=True) + movement_type = db.Column(db.String(30), nullable=False, index=True) + quantity_reference = db.Column(db.Numeric(14, 6), nullable=False) + source_location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id"), index=True) + destination_location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id"), index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), index=True) + comment = db.Column(db.Text) + dilution_ratio = db.Column(db.Numeric(8, 5)) + created_at = db.Column(db.DateTime, nullable=False, default=utcnow, index=True) + lot = db.relationship("StockLot", back_populates="movements") + generic_product = db.relationship("ProductGeneric") + commercial_product = db.relationship("CommercialProduct") + source_location = db.relationship("StockLocation", foreign_keys=[source_location_id]) + destination_location = db.relationship("StockLocation", foreign_keys=[destination_location_id]) + staff = db.relationship("Staff") + user = db.relationship("User") + + +class MaterialAssignment(db.Model): + __tablename__ = "cleaning_material_assignments" + id = db.Column(db.Integer, primary_key=True) + equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id", ondelete="RESTRICT"), nullable=False, index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), index=True) + zone_id = db.Column(db.Integer, db.ForeignKey("zones.id"), index=True) + room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), index=True) + status = db.Column(db.String(30), nullable=False, default="affecte") + assigned_at = db.Column(db.DateTime, nullable=False, default=utcnow) + returned_at = db.Column(db.DateTime) + notes = db.Column(db.Text) + equipment = db.relationship("Equipment") + staff = db.relationship("Staff") + zone = db.relationship("Zone") + room = db.relationship("Room") + + +class ReusableContainer(db.Model): + __tablename__ = "cleaning_reusable_containers" + id = db.Column(db.Integer, primary_key=True) + identifier = db.Column(db.String(80), nullable=False, unique=True) + container_type = db.Column(db.String(80), nullable=False) + volume_liters = db.Column(db.Numeric(12, 6), nullable=False) + dedicated_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id")) + current_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id")) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id")) + zone_id = db.Column(db.Integer, db.ForeignKey("zones.id")) + state = db.Column(db.String(30), nullable=False, default="disponible") + last_filled_at = db.Column(db.DateTime) + dedicated_product = db.relationship("ProductGeneric", foreign_keys=[dedicated_product_id]) + current_product = db.relationship("ProductGeneric", foreign_keys=[current_product_id]) + staff = db.relationship("Staff") + zone = db.relationship("Zone") + fills = db.relationship("ContainerFill", back_populates="container", cascade="all, delete-orphan") + + +class ContainerFill(db.Model): + __tablename__ = "cleaning_container_fills" + id = db.Column(db.Integer, primary_key=True) + container_id = db.Column(db.Integer, db.ForeignKey("cleaning_reusable_containers.id", ondelete="RESTRICT"), nullable=False, index=True) + movement_id = db.Column(db.BigInteger, db.ForeignKey("cleaning_stock_movements.id", ondelete="RESTRICT"), nullable=False) + volume_liters = db.Column(db.Numeric(12, 6), nullable=False) + dilution_ratio = db.Column(db.Numeric(8, 5)) + prepared_at = db.Column(db.DateTime, nullable=False, default=utcnow) + container = db.relationship("ReusableContainer", back_populates="fills") + movement = db.relationship("StockMovement") + + +class ProductDocument(db.Model): + __tablename__ = "cleaning_product_documents" + id = db.Column(db.Integer, primary_key=True) + commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id", ondelete="RESTRICT"), nullable=False, index=True) + document_type = db.Column(db.String(30), nullable=False, default="fds") + title = db.Column(db.String(255), nullable=False) + filename = db.Column(db.String(255), nullable=False) + filepath = db.Column(db.String(500), nullable=False) + version = db.Column(db.String(50)) + published_at = db.Column(db.Date) + uploaded_at = db.Column(db.DateTime, nullable=False, default=utcnow) + uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + is_current = db.Column(db.Boolean, nullable=False, default=True) + commercial_product = db.relationship("CommercialProduct", back_populates="documents") + uploaded_by = db.relationship("User") + + +class RequestProfile(db.Model): + __tablename__ = "cleaning_request_profiles" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(160), nullable=False, unique=True) + description = db.Column(db.Text) + is_active = db.Column(db.Boolean, nullable=False, default=True) + items = db.relationship("RequestProfileItem", back_populates="profile", cascade="all, delete-orphan", order_by="RequestProfileItem.display_order") + + +class RequestProfileItem(db.Model): + __tablename__ = "cleaning_request_profile_items" + id = db.Column(db.Integer, primary_key=True) + profile_id = db.Column(db.Integer, db.ForeignKey("cleaning_request_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id", ondelete="RESTRICT"), nullable=False, index=True) + display_order = db.Column(db.Integer, nullable=False, default=0) + profile = db.relationship("RequestProfile", back_populates="items") + product = db.relationship("ProductGeneric") + + +class StockInventory(db.Model): + __tablename__ = "cleaning_stock_inventories" + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id"), nullable=False, index=True) + counted_at = db.Column(db.DateTime, nullable=False, default=utcnow) + status = db.Column(db.String(20), nullable=False, default="brouillon") + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + notes = db.Column(db.Text) + location = db.relationship("StockLocation") + user = db.relationship("User") + lines = db.relationship("StockInventoryLine", back_populates="inventory", cascade="all, delete-orphan") + + +class StockInventoryLine(db.Model): + __tablename__ = "cleaning_stock_inventory_lines" + id = db.Column(db.Integer, primary_key=True) + inventory_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_inventories.id", ondelete="CASCADE"), nullable=False, index=True) + lot_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_lots.id", ondelete="RESTRICT"), nullable=False) + theoretical_quantity = db.Column(db.Numeric(14, 6), nullable=False) + counted_quantity = db.Column(db.Numeric(14, 6), nullable=False) + inventory = db.relationship("StockInventory", back_populates="lines") + lot = db.relationship("StockLot") + + +class CleaningForecastConfig(db.Model): + __tablename__ = "cleaning_forecast_configs" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, default="Configuration générale") + normal_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=1) + permanence_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=Decimal("0.4")) + closed_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=0) + exceptional_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=1) + is_active = db.Column(db.Boolean, nullable=False, default=True) diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 40afd49..2664551 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -478,6 +478,7 @@ class ScheduledTask(db.Model): equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id")) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id")) company_id = db.Column(db.Integer, db.ForeignKey("companies.id")) + contract_id = db.Column(db.Integer, db.ForeignKey("contracts.id"), nullable=True) scheduled_date = db.Column(db.Date) scheduled_start = db.Column(db.Time) @@ -511,6 +512,7 @@ class ScheduledTask(db.Model): room = db.relationship("Room") assigned_to = db.relationship("User") company = db.relationship("Company") + contract = db.relationship("Contract") intervention = db.relationship("Intervention") @property diff --git a/app_new/templates/base.html b/app_new/templates/base.html index ecee441..86601af 100644 --- a/app_new/templates/base.html +++ b/app_new/templates/base.html @@ -221,6 +221,17 @@
  • Lots
  • Services
  • Pièces
  • +
  • +
  • Matériel & Entretien
  • +
  • Produits génériques
  • +
  • Références / conditionnements
  • +
  • État du stock
  • +
  • Réceptions
  • +
  • Sorties agents
  • +
  • Transvasements / dilutions
  • +
  • Fiches papier
  • +
  • Matériel durable
  • +
  • Prévision / permanences
  • Historique des compteurs
  • À jeter
  • diff --git a/migrations/versions/5784fc27cb2a_ajouter_module_matériel_et_entretien.py b/migrations/versions/5784fc27cb2a_ajouter_module_matériel_et_entretien.py new file mode 100644 index 0000000..6cde56b --- /dev/null +++ b/migrations/versions/5784fc27cb2a_ajouter_module_matériel_et_entretien.py @@ -0,0 +1,33 @@ +"""Ajouter le module Matériel & Produits d'entretien. +Revision ID: 5784fc27cb2a +Revises: a07b8c9d0e2f +""" +from alembic import op +from app_new.core.models.cleaning import ( + ProductCategory, ProductGeneric, CommercialProduct, ProductPackaging, + StockLocation, StockLot, StockLotBalance, StockMovement, + MaterialAssignment, ReusableContainer, ContainerFill, ProductDocument, + RequestProfile, RequestProfileItem, StockInventory, StockInventoryLine, + CleaningForecastConfig, +) +revision = "5784fc27cb2a" +down_revision = "a07b8c9d0e2f" +branch_labels = None +depends_on = None +_TABLES = [ + CleaningForecastConfig.__table__, ProductCategory.__table__, + RequestProfile.__table__, ProductGeneric.__table__, CommercialProduct.__table__, + RequestProfileItem.__table__, ProductDocument.__table__, ProductPackaging.__table__, + ReusableContainer.__table__, StockLot.__table__, StockLocation.__table__, + MaterialAssignment.__table__, StockInventory.__table__, StockLotBalance.__table__, + StockMovement.__table__, ContainerFill.__table__, StockInventoryLine.__table__, +] +def upgrade(): + bind = op.get_bind() + for table in _TABLES: + table.create(bind=bind, checkfirst=True) +def downgrade(): + bind = op.get_bind() + for table in reversed(_TABLES): + table.drop(bind=bind, checkfirst=True) + diff --git a/migrations/versions/8a95b1c2d3e4_retablir_contrat_taches_planifiees.py b/migrations/versions/8a95b1c2d3e4_retablir_contrat_taches_planifiees.py new file mode 100644 index 0000000..7edaaa2 --- /dev/null +++ b/migrations/versions/8a95b1c2d3e4_retablir_contrat_taches_planifiees.py @@ -0,0 +1,25 @@ +"""Rétablir le rattachement des tâches planifiées aux contrats. +Revision ID: 8a95b1c2d3e4 +Revises: 5784fc27cb2a +""" +from alembic import op +import sqlalchemy as sa +revision = "8a95b1c2d3e4" +down_revision = "5784fc27cb2a" +branch_labels = None +depends_on = None +def upgrade(): + inspector = sa.inspect(op.get_bind()) + columns = {c["name"] for c in inspector.get_columns("scheduled_tasks")} + if "contract_id" not in columns: + op.add_column("scheduled_tasks", sa.Column("contract_id", sa.Integer(), nullable=True)) + names = {fk.get("name") for fk in inspector.get_foreign_keys("scheduled_tasks")} + if "fk_scheduled_tasks_contract_id" not in names: + op.create_foreign_key("fk_scheduled_tasks_contract_id", "scheduled_tasks", "contracts", ["contract_id"], ["id"]) +def downgrade(): + inspector = sa.inspect(op.get_bind()) + names = {fk.get("name") for fk in inspector.get_foreign_keys("scheduled_tasks")} + if "fk_scheduled_tasks_contract_id" in names: + op.drop_constraint("fk_scheduled_tasks_contract_id", "scheduled_tasks", type_="foreignkey") + if "contract_id" in {c["name"] for c in inspector.get_columns("scheduled_tasks")}: + op.drop_column("scheduled_tasks", "contract_id") diff --git a/tests/integration/test_cleaning_module.py b/tests/integration/test_cleaning_module.py new file mode 100644 index 0000000..f9e9e0f --- /dev/null +++ b/tests/integration/test_cleaning_module.py @@ -0,0 +1,94 @@ +from datetime import date, timedelta +from decimal import Decimal +from uuid import uuid4 +import pytest + +from app_new.extensions import db +from app_new.core.models import ( + ProductGeneric, CommercialProduct, ProductPackaging, StockLocation, + Staff, ReusableContainer, CleaningForecastConfig, CollegeClosure, ClosureWorkDay, +) +from app_new.cleaning.services.stock import ( + StockError, receive_stock, issue_stock, transfer_stock, dilute, +) +from app_new.cleaning.services.forecast import forecast_product + + +def setup_stock(): + product = ProductGeneric(name=f"Nettoyant test {uuid4().hex[:8]}", reference_unit="L", product_type="liquide", + forecast_daily_quantity=Decimal("1.2"), stock_security=Decimal("2")) + ref = CommercialProduct(generic_product=product, commercial_name="Référence test") + packaging = ProductPackaging(generic_product=product, commercial_product=ref, name="Bidon 5 L", + purchase_unit="bidon", units_per_package=1, + reference_quantity_per_package=Decimal("5")) + suffix = uuid4().hex[:8] + loc_a = StockLocation(name=f"Stock test A {suffix}") + loc_b = StockLocation(name=f"Stock test B {suffix}") + db.session.add_all([product, ref, packaging, loc_a, loc_b]) + db.session.flush() + return product, ref, packaging, loc_a, loc_b + + +def test_reception_conversion_and_fefo(app): + with app.app_context(): + product, ref, packaging, loc_a, loc_b = setup_stock() + receive_stock(generic_product_id=product.id, commercial_product_id=ref.id, packaging_id=packaging.id, + packages=2, lot_number="ANCIEN", received_at=date.today(), + expiry_date=date.today() + timedelta(days=3), location_id=loc_a.id) + receive_stock(generic_product_id=product.id, commercial_product_id=ref.id, packaging_id=packaging.id, + packages=2, lot_number="RECENT", received_at=date.today(), + expiry_date=date.today() + timedelta(days=90), location_id=loc_a.id) + db.session.commit() + issue_stock(generic_product_id=product.id, quantity=Decimal("6"), source_location_id=loc_a.id) + db.session.commit() + lots = {lot.lot_number: sum((b.quantity for b in lot.balances), Decimal("0")) + for lot in product.lots} + assert lots["ANCIEN"] == Decimal("4.000000") + assert lots["RECENT"] == Decimal("10.000000") + + +def test_expired_lot_rejected_and_no_negative_stock(app): + with app.app_context(): + product, ref, packaging, loc_a, _ = setup_stock() + receive_stock(generic_product_id=product.id, commercial_product_id=ref.id, packaging_id=packaging.id, + packages=1, lot_number="EXPIRE", received_at=date.today(), + expiry_date=date.today() - timedelta(days=1), location_id=loc_a.id) + db.session.commit() + with pytest.raises(StockError): + issue_stock(generic_product_id=product.id, quantity=1, source_location_id=loc_a.id) + with pytest.raises(StockError): + issue_stock(generic_product_id=product.id, quantity=100, source_location_id=loc_a.id) + db.session.rollback() + + +def test_transfer_and_dilution(app): + with app.app_context(): + product, ref, packaging, loc_a, loc_b = setup_stock() + receive_stock(generic_product_id=product.id, commercial_product_id=ref.id, packaging_id=packaging.id, + packages=1, lot_number="TRANS", received_at=date.today(), location_id=loc_a.id) + container = ReusableContainer(identifier=f"FLACON-TEST-{uuid4().hex[:8]}", container_type="750 ml", volume_liters=Decimal("0.750")) + db.session.add(container); db.session.flush() + transfer_stock(generic_product_id=product.id, quantity=Decimal("1"), source_location_id=loc_a.id, destination_location_id=loc_b.id) + db.session.commit() + assert sum((b.quantity for b in product.lots[0].balances if b.location_id == loc_b.id), Decimal("0")) == Decimal("1.000000") + dilute(generic_product_id=product.id, total_liters=Decimal("0.750"), ratio_percent=Decimal("2"), + source_location_id=loc_b.id, container_id=container.id) + db.session.commit() + assert container.current_product_id == product.id + assert sum((b.quantity for b in product.lots[0].balances if b.location_id == loc_b.id), Decimal("0")) == Decimal("0.985000") + + +def test_forecast_distinguishes_school_days_permanence_and_closure(app): + with app.app_context(): + product, ref, packaging, loc_a, _ = setup_stock() + start = date(2026, 9, 7) + closure = CollegeClosure(name="Vacances test", start_date=start + timedelta(days=5), + end_date=start + timedelta(days=9), closure_type="vacances_scolaires") + db.session.add(closure); db.session.flush() + db.session.add(ClosureWorkDay(closure_id=closure.id, work_date=start + timedelta(days=6), + start_time=__import__("datetime").time(8), end_time=__import__("datetime").time(12))) + db.session.commit() + result = forecast_product(product, start + timedelta(days=9), start_date=start) + assert result["school_days_consumption"] > 0 + assert result["permanence_consumption"] == Decimal("0.480000") + assert result["closed_consumption"] == 0