276 lines
18 KiB
Python
276 lines
18 KiB
Python
"""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/<int:id>/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/<int:product_id>")
|
|
@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/<int:profile_id>")
|
|
@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"))
|