commit cc143fd3211965c0190db873ff811475d7645d33 Author: Nabil Derouiche Date: Thu May 28 18:48:40 2026 +0000 v1.0.0 — GSPARC Mezzouna API initiale diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3b443d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# GSPARC Mezzouna — Variables d'environnement +# Copier ce fichier en .env et remplir les valeurs + +# Baserow (obligatoire) +BASEROW_USER=admin@example.com +BASEROW_PASSWORD=motdepasse +BASEROW_URL=http://baserow:80 +BASEROW_HOST=baserow.bolbol.tn + +# Services NAS +TIKA_URL=http://tika:9998 +GOTENBERG_URL=http://gotenberg:3000 + +# Application +GSPARC_USER=admin +GSPARC_PASSWORD=gsparc2026 +GSPARC_SECRET=change-me-in-production diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a807e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +app/__pycache__/\n*.pyc\n.env\n*.log\n__pycache__/ diff --git a/Cartes-AGILIS-par-vehicule.jpeg b/Cartes-AGILIS-par-vehicule.jpeg new file mode 100644 index 0000000..71cfd6f Binary files /dev/null and b/Cartes-AGILIS-par-vehicule.jpeg differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b8b87a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Installer les dépendances +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le code +COPY app/ ./app/ + +# Variables d'environnement par défaut (surchargées par docker-compose) +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Recu-test.jpeg b/Recu-test.jpeg new file mode 100644 index 0000000..8cdab4a Binary files /dev/null and b/Recu-test.jpeg differ diff --git a/Tableau-sortie-1.jpeg b/Tableau-sortie-1.jpeg new file mode 100644 index 0000000..46afb0a Binary files /dev/null and b/Tableau-sortie-1.jpeg differ diff --git a/Tableau-sortie-2.jpeg b/Tableau-sortie-2.jpeg new file mode 100644 index 0000000..b75da36 Binary files /dev/null and b/Tableau-sortie-2.jpeg differ diff --git a/Tableau_de_suivie_mensuel.xlsx b/Tableau_de_suivie_mensuel.xlsx new file mode 100644 index 0000000..4ba60ed Binary files /dev/null and b/Tableau_de_suivie_mensuel.xlsx differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2f92ca4 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""GSPARC Mezzouna — Suivi consommation carburant Garde Nationale""" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..ec49903 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,36 @@ +"""Authentification simple — session cookie.""" +from fastapi import Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from starlette.middleware.sessions import SessionMiddleware +from app.config import APP_USER, APP_PASSWORD, SECRET_KEY + +SESSION_KEY = "gsparc_user" + + +def init_auth(app): + """Initialise le middleware de session sur l'app FastAPI.""" + app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="gsparc_session") + + +def login_user(request: Request, username: str, password: str) -> bool: + """Vérifie les credentials et crée une session.""" + if username == APP_USER and password == APP_PASSWORD: + request.session[SESSION_KEY] = username + return True + return False + + +def logout_user(request: Request): + """Détruit la session.""" + request.session.clear() + + +async def require_auth(request: Request): + """Dépendance FastAPI qui protège les routes.""" + user = request.session.get(SESSION_KEY) + if not user: + # Si c'est une API, retourner 401 ; si c'est une page, rediriger vers login + if request.headers.get("accept", "").startswith("application/json"): + raise HTTPException(status_code=401, detail="Authentification requise") + raise HTTPException(status_code=303, headers={"Location": "/login"}) + return user \ No newline at end of file diff --git a/app/baserow.py b/app/baserow.py new file mode 100644 index 0000000..a2093ef --- /dev/null +++ b/app/baserow.py @@ -0,0 +1,94 @@ +"""Client Baserow — authentification JWT et opérations CRUD.""" +import json +import logging +import httpx +from app.config import BASEROW_URL, BASEROW_HOST, BASEROW_USER, BASEROW_PASSWORD, BASEROW_TOKEN + +logger = logging.getLogger(__name__) + +_jwt_cache: str | None = None + + +async def get_jwt() -> str: + """Retourne un JWT valide, avec cache et refresh automatique.""" + global _jwt_cache + if _jwt_cache: + # Tester si le JWT est encore valide + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get( + f"{BASEROW_URL}/api/_health/", + headers={"Host": BASEROW_HOST, "Authorization": f"JWT {_jwt_cache}"}, + ) + if r.status_code == 200 and "healthy" in r.text: + return _jwt_cache + except Exception: + pass + + # Login + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{BASEROW_URL}/api/user/token-auth/", + headers={"Host": BASEROW_HOST, "Content-Type": "application/json"}, + json={"email": BASEROW_USER, "password": BASEROW_PASSWORD}, + ) + r.raise_for_status() + data = r.json() + _jwt_cache = data.get("access_token") or data.get("token") + logger.info("JWT Baserow régénéré") + return _jwt_cache + + +async def baserow_request(method: str, path: str, json_data: dict | None = None) -> dict: + """Appel générique à l'API Baserow avec JWT.""" + jwt = await get_jwt() + headers = {"Host": BASEROW_HOST, "Authorization": f"JWT {jwt}"} + if json_data: + headers["Content-Type"] = "application/json" + + async with httpx.AsyncClient(timeout=30) as client: + r = await client.request( + method=method, + url=f"{BASEROW_URL}{path}", + headers=headers, + json=json_data, + ) + if r.status_code == 401: + # JWT expiré, on reset et on retry une fois + global _jwt_cache + _jwt_cache = None + jwt = await get_jwt() + headers["Authorization"] = f"JWT {jwt}" + r = await client.request(method=method, url=f"{BASEROW_URL}{path}", headers=headers, json=json_data) + + r.raise_for_status() + return r.json() if r.text else {} + + +# ── Opérations CRUD simplifiées ────────────────────────────────────────────── + +async def list_rows(table_id: int, filters: dict | None = None, size: int = 100) -> list[dict]: + """Lister les lignes d'une table avec filtres optionnels.""" + path = f"/api/database/rows/table/{table_id}/?user_field_names=true&size={size}" + result = await baserow_request("GET", path) + return result.get("results", []) + + +async def get_row(table_id: int, row_id: int) -> dict: + """Récupérer une ligne par son ID.""" + return await baserow_request("GET", f"/api/database/rows/table/{table_id}/{row_id}/?user_field_names=true") + + +async def create_row(table_id: int, data: dict) -> dict: + """Créer une nouvelle ligne.""" + return await baserow_request("POST", f"/api/database/rows/table/{table_id}/?user_field_names=true", data) + + +async def update_row(table_id: int, row_id: int, data: dict) -> dict: + """Mettre à jour une ligne.""" + return await baserow_request("PATCH", f"/api/database/rows/table/{table_id}/{row_id}/?user_field_names=true", data) + + +async def delete_row(table_id: int, row_id: int) -> None: + """Supprimer une ligne.""" + await baserow_request("DELETE", f"/api/database/rows/table/{table_id}/{row_id}/") diff --git a/app/business.py b/app/business.py new file mode 100644 index 0000000..cb032f0 --- /dev/null +++ b/app/business.py @@ -0,0 +1,86 @@ +"""Logique métier GSPARC — kilométrage, consommation, anomalies.""" +import logging + +logger = logging.getLogger(__name__) + +PRODUIT_ATTENDU = "Gasoual Normal" + + +def calculer_kilometrage(compteur_avant: float, compteur_apres: float) -> float: + """Calcule la distance parcourue entre deux approvisionnements.""" + if compteur_avant is None or compteur_apres is None: + return 0.0 + distance = compteur_apres - compteur_avant + return max(0, distance) + + +def calculer_consommation(quantite_litres: float, km_parcourus: float) -> float | None: + """Calcule la consommation en L/100km.""" + if km_parcourus <= 0 or quantite_litres <= 0: + return None + return round((quantite_litres / km_parcourus) * 100, 2) + + +def detecter_anomalies(approvisionnement: dict, dernier_appro: dict | None = None) -> list[str]: + """Détecte les anomalies sur un approvisionnement. + + Retourne une liste de codes d'anomalie : + - KM_NEGATIF : kilométrage nul ou négatif + - PRODUIT_INVALIDE : produit différent de Gasoual Normal + - DOUBLON : même numéro de reçu + - KM_INCOHERENT : kilométrage en baisse ou bond suspect + - VEHICULE_INCONNU : matricule non reconnu (à vérifier en amont) + - CARTE_INCONNUE : carte non reconnue (à vérifier en amont) + """ + anomalies = [] + + km_parcouru = approvisionnement.get("الكلم_المقطوع", 0) or 0 + produit = approvisionnement.get("المنتج", "") + + # Anomalie 1 : kilométrage négatif + if km_parcouru <= 0: + anomalies.append("KM_NEGATIF") + + # Anomalie 2 : produit non conforme + if produit and produit.strip() != PRODUIT_ATTENDU: + anomalies.append("PRODUIT_INVALIDE") + + # Anomalie 3 : incohérence de kilométrage avec approvisionnement précédent + if dernier_appro: + km_precedent = dernier_appro.get("الكلم_المقطوع", 0) or 0 + compteur_avant = approvisionnement.get("العداد_قبل", 0) or 0 + compteur_precedent = dernier_appro.get("العداد_بعد", 0) or 0 + + if compteur_avant < compteur_precedent: + anomalies.append("KM_INCOHERENT") + + return anomalies + + +def analyser_tendance(valeurs: list[float]) -> dict: + """Analyse la tendance de consommation.""" + if not valeurs or len(valeurs) < 2: + return {"tendance": "stable", "variation_pct": 0} + + moyenne = sum(valeurs) / len(valeurs) + dernieres = valeurs[-3:] if len(valeurs) >= 3 else valeurs + moyenne_recente = sum(dernieres) / len(dernieres) + + if moyenne > 0: + variation = ((moyenne_recente - moyenne) / moyenne) * 100 + else: + variation = 0 + + if variation > 10: + tendance = "hausse" + elif variation < -10: + tendance = "baisse" + else: + tendance = "stable" + + return { + "tendance": tendance, + "variation_pct": round(variation, 1), + "moyenne_generale": round(moyenne, 2), + "moyenne_recente": round(moyenne_recente, 2), + } \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c3f70c6 --- /dev/null +++ b/app/config.py @@ -0,0 +1,22 @@ +"""Configuration GSPARC Mezzouna API.""" +import os + +BASEROW_URL = os.getenv("BASEROW_URL", "http://baserow:80") +BASEROW_HOST = os.getenv("BASEROW_HOST", "baserow.bolbol.tn") +BASEROW_USER = os.getenv("BASEROW_USER", "") +BASEROW_PASSWORD = os.getenv("BASEROW_PASSWORD", "") +BASEROW_TOKEN = os.getenv("BASEROW_TOKEN", "") # fallback + +TIKA_URL = os.getenv("TIKA_URL", "http://tika:9998") +GOTENBERG_URL = os.getenv("GOTENBERG_URL", "http://gotenberg:3000") + +APP_USER = os.getenv("GSPARC_USER", "admin") +APP_PASSWORD = os.getenv("GSPARC_PASSWORD", "gsparc2026") +SECRET_KEY = os.getenv("GSPARC_SECRET", "change-me-in-production-gsparc-mezzouna") + +# Baserow table IDs +TABLE_VEHICULES = 1012 +TABLE_CARTES = 1013 +TABLE_APPROVISIONNEMENTS = 1014 +TABLE_RECUS = 1015 +TABLE_ANOMALIES = 1016 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ffc43b0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,47 @@ +"""Application principale — GSPARC Mezzouna API.""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from app.auth import init_auth +from app.routes import api_router +from app.routes.auth_routes import router as auth_router + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") +logger = logging.getLogger("gsparc") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Au démarrage : préchauffer le JWT Baserow.""" + logger.info("🚀 Démarrage GSPARC Mezzouna API") + try: + from app.baserow import get_jwt + await get_jwt() + logger.info("✅ Connexion Baserow établie") + except Exception as e: + logger.warning(f"⚠️ Connexion Baserow différée : {e}") + yield + logger.info("👋 Arrêt GSPARC Mezzouna API") + + +app = FastAPI( + title="GSPARC Mezzouna — متابعة استهلاك الوقود", + version="1.0.0", + lifespan=lifespan, +) + +# Auth (session middleware) +init_auth(app) + +# Routes +app.include_router(auth_router) +app.include_router(api_router) + +# Redirection racine +@app.get("/health") +async def health(): + return {"status": "ok", "app": "gsparc-mezzouna", "version": "1.0.0"} diff --git a/app/ocr.py b/app/ocr.py new file mode 100644 index 0000000..61abadc --- /dev/null +++ b/app/ocr.py @@ -0,0 +1,67 @@ +"""OCR via Apache Tika.""" +import logging +import httpx +from app.config import TIKA_URL + +logger = logging.getLogger(__name__) + + +async def extract_text(file_content: bytes, filename: str = "receipt.jpg") -> str: + """Extrait le texte d'une image via Tika OCR.""" + async with httpx.AsyncClient(timeout=30) as client: + r = await client.put( + f"{TIKA_URL}/tika", + content=file_content, + headers={"Content-Type": "application/octet-stream", "X-Tika-OCRskipOcr": "false"}, + ) + r.raise_for_status() + text = r.text.strip() + logger.info(f"OCR extrait {len(text)} caractères de {filename}") + return text + + +def parse_receipt_ocr(text: str) -> dict: + """Parse le texte OCR d'un reçu AGILIS pour extraire les champs clés.""" + import re + + result = { + "رقم_الايصال": "", + "التاريخ": "", + "المحطة": "", + "المنتج": "", + "الكمية": 0.0, + "السعر": 0.0, + "القيمة": 0.0, + } + + for line in text.split("\n"): + line = line.strip() + if not line: + continue + + # Recherche du numéro de reçu (souvent formaté comme 123456-789) + if m := re.search(r"(\d{4,6}[\s\-/]\d{3,6})", line): + result["رقم_الايصال"] = m.group(1).replace(" ", "-") + + # Date marocaine (JJ/MM/AAAA) + if m := re.search(r"(\d{2}/\d{2}/\d{4})", line): + result["التاريخ"] = m.group(1) + + # Quantité en litres + if m := re.search(r"(\d+[.,]\d+)\s*(?:L|ltrs?|litres?)", line): + result["الكمية"] = float(m.group(1).replace(",", ".")) + + # Prix (Dhs ou DT) + if m := re.search(r"(\d+[.,]\d{2})\s*(?:DHS?|DT|د\.م|DH)", line): + val = float(m.group(1).replace(",", ".")) + # On essaie de distinguer prix unitaire vs total + if val < 30: + result["السعر"] = val + else: + result["القيمة"] = val + + # Station + if any(w in line.upper() for w in ["STATION", "AFRIQUIA", "TOTAL", "SHELL", "WIN", "OIL", "PETRO"]): + result["المحطة"] = line + + return result \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..9542193 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,14 @@ +"""Routes de l'application GSPARC Mezzouna.""" +from fastapi import APIRouter +from app.routes.auth_routes import router as auth_router +from app.routes.vehicles import router as vehicles_router +from app.routes.fuel import router as fuel_router +from app.routes.anomalies import router as anomalies_router +from app.routes.dashboard import router as dashboard_router + +# Router principal qui regroupe tout +api_router = APIRouter() +api_router.include_router(dashboard_router) +api_router.include_router(vehicles_router) +api_router.include_router(fuel_router) +api_router.include_router(anomalies_router) diff --git a/app/routes/anomalies.py b/app/routes/anomalies.py new file mode 100644 index 0000000..e773d12 --- /dev/null +++ b/app/routes/anomalies.py @@ -0,0 +1,34 @@ +"""Routes — anomalies.""" +from fastapi import APIRouter, Depends, Request, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from app.auth import require_auth +from app.baserow import list_rows, update_row +from app.templates import templates +from app.config import TABLE_ANOMALIES, TABLE_VEHICULES + +router = APIRouter(prefix="/anomalies") + + +@router.get("/", response_class=HTMLResponse) +async def liste_anomalies(request: Request, user=Depends(require_auth)): + anomalies = await list_rows(TABLE_ANOMALIES, size=100) + vehicules = await list_rows(TABLE_VEHICULES) + return templates.TemplateResponse("anomalies.html", { + "request": request, + "anomalies": anomalies, + "vehicules": {v["رقم_الماتريكول"]: v["النوع"] for v in vehicules}, + }) + + +@router.post("/{row_id}/resolve") +async def resoudre_anomalie( + request: Request, + row_id: int, + action: str = Form(""), + user=Depends(require_auth), +): + await update_row(TABLE_ANOMALIES, row_id, { + "تمت_المعالجة": True, + "إجراء_التصحيح": action, + }) + return RedirectResponse(url="/anomalies", status_code=303) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py new file mode 100644 index 0000000..1056c8d --- /dev/null +++ b/app/routes/auth_routes.py @@ -0,0 +1,25 @@ +"""Routes — authentification (login/logout pages).""" +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse, HTMLResponse +from app.auth import login_user, logout_user +from app.templates import templates + +router = APIRouter() + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: str = ""): + return templates.TemplateResponse("login.html", {"request": request, "error": error}) + + +@router.post("/login") +async def login(request: Request, username: str = Form(...), password: str = Form(...)): + if login_user(request, username, password): + return RedirectResponse(url="/", status_code=303) + return templates.TemplateResponse("login.html", {"request": request, "error": "اسم المستخدم أو كلمة المرور غير صحيحة"}, status_code=401) + + +@router.get("/logout") +async def logout(request: Request): + logout_user(request) + return RedirectResponse(url="/login") diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..e5cdc3c --- /dev/null +++ b/app/routes/dashboard.py @@ -0,0 +1,44 @@ +"""Routes — tableau de bord principal.""" +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from app.auth import require_auth +from app.baserow import list_rows +from app.templates import templates +from app.config import TABLE_VEHICULES, TABLE_APPROVISIONNEMENTS, TABLE_ANOMALIES + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, user=Depends(require_auth)): + vehicules = await list_rows(TABLE_VEHICULES) + appros = await list_rows(TABLE_APPROVISIONNEMENTS, size=50) + anomalies = await list_rows(TABLE_ANOMALIES, size=20) + + # Stats globales + total_appros = len(appros) + total_anomalies = len([a for a in anomalies if not a.get("تمت_المعالجة")]) + total_vehicules = len(vehicules) + + # Consommation moyenne par véhicule + stats_vehicules = [] + for v in vehicules: + matricule = v.get("رقم_الماتريكول", "") + appros_v = [a for a in appros if a.get("رقم_الماتريكول") == matricule] + conso_values = [a.get("الاستهلاك_100كم") for a in appros_v if a.get("الاستهلاك_100كم")] + conso_moy = round(sum(conso_values) / len(conso_values), 2) if conso_values else 0 + stats_vehicules.append({ + "matricule": matricule, + "type": v.get("النوع", ""), + "nb_appros": len(appros_v), + "conso_moyenne": conso_moy, + "nb_anomalies": len([a for a in anomalies if a.get("رقم_الماتريكول") == matricule and not a.get("تمت_المعالجة")]), + }) + + return templates.TemplateResponse("dashboard.html", { + "request": request, + "total_v": total_vehicules, + "total_appro": total_appros, + "total_anomalies": total_anomalies, + "stats_vehicules": stats_vehicules, + }) \ No newline at end of file diff --git a/app/routes/fuel.py b/app/routes/fuel.py new file mode 100644 index 0000000..9d8dbf5 --- /dev/null +++ b/app/routes/fuel.py @@ -0,0 +1,88 @@ +"""Routes — approvisionnements en carburant.""" +from fastapi import APIRouter, Depends, Request, Form, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse +from app.auth import require_auth +from app.baserow import list_rows, create_row, update_row, get_row +from app.templates import templates +from app.config import TABLE_VEHICULES, TABLE_APPROVISIONNEMENTS, TABLE_ANOMALIES +from app.business import calculer_kilometrage, calculer_consommation, detecter_anomalies +from app.ocr import extract_text, parse_receipt_ocr + +router = APIRouter(prefix="/fuel") + + +@router.get("/", response_class=HTMLResponse) +async def formulaire(request: Request, user=Depends(require_auth)): + vehicules = await list_rows(TABLE_VEHICULES) + appros = await list_rows(TABLE_APPROVISIONNEMENTS, size=50) + return templates.TemplateResponse("fuel_form.html", { + "request": request, + "vehicules": vehicules, + "appros": appros, + }) + + +@router.post("/add") +async def ajouter_approvisionnement( + request: Request, + matricule: str = Form(...), + date: str = Form(...), + produit: str = Form("Gasoual Normal"), + quantite: float = Form(...), + prix_litre: float = Form(...), + valeur: float = Form(...), + compteur_avant: float = Form(None), + compteur_apres: float = Form(None), + no_recu: str = Form(""), + user=Depends(require_auth), +): + # Calcul automatique + km_parcouru = calculer_kilometrage(compteur_avant or 0, compteur_apres or 0) + conso_100 = calculer_consommation(quantite, km_parcouru) if km_parcouru > 0 else None + + data = { + "التاريخ": date, + "رقم_الماتريكول": matricule, + "المنتج": produit, + "الكمية_باللتر": quantite, + "السعر_الليتر": prix_litre, + "القيمة_الجملية": valeur, + "العداد_قبل": compteur_avant, + "العداد_بعد": compteur_apres, + "الكلم_المقطوع": km_parcouru, + "الاستهلاك_100كم": conso_100, + "رقم_الايصال": no_recu, + } + + # Détection d'anomalies + appro_precedents = await list_rows(TABLE_APPROVISIONNEMENTS, size=100) + dernier = next((a for a in appro_precedents if a.get("رقم_الماتريكول") == matricule), None) + codes = detecter_anomalies(data, dernier) + + if codes: + data["شذوذ"] = True + data["نوع_الشذوذ"] = ", ".join(codes) + + await create_row(TABLE_APPROVISIONNEMENTS, data) + return RedirectResponse(url="/fuel", status_code=303) + + +@router.post("/ocr-upload") +async def upload_recu_ocr( + request: Request, + file: UploadFile = File(...), + user=Depends(require_auth), +): + """Upload d'un reçu + OCR automatique.""" + content = await file.read() + text = await extract_text(content, file.filename or "receipt.jpg") + parsed = parse_receipt_ocr(text) + + vehicules = await list_rows(TABLE_VEHICULES) + return templates.TemplateResponse("fuel_form.html", { + "request": request, + "vehicules": vehicules, + "appros": [], + "ocr_text": text, + "ocr_parsed": parsed, + }) \ No newline at end of file diff --git a/app/routes/vehicles.py b/app/routes/vehicles.py new file mode 100644 index 0000000..081d52c --- /dev/null +++ b/app/routes/vehicles.py @@ -0,0 +1,62 @@ +"""Routes — véhicules.""" +from fastapi import APIRouter, Depends, Request, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from app.auth import require_auth +from app.baserow import list_rows, create_row, update_row, delete_row, get_row +from app.templates import templates +from app.config import TABLE_VEHICULES + +router = APIRouter(prefix="/vehicules") + + +@router.get("/", response_class=HTMLResponse) +async def liste_vehicules(request: Request, user=Depends(require_auth)): + vehicules = await list_rows(TABLE_VEHICULES) + return templates.TemplateResponse("vehicules.html", {"request": request, "vehicules": vehicules}) + + +@router.post("/add") +async def ajouter_vehicule( + request: Request, + matricule: str = Form(...), + type_v: str = Form(...), + marque: str = Form(""), + capacite: float = Form(None), + notes: str = Form(""), + user=Depends(require_auth), +): + await create_row(TABLE_VEHICULES, { + "رقم_الماتريكول": matricule, + "النوع": type_v, + "العلامة": marque, + "حمولة_الخزان": capacite, + "ملاحظات": notes, + }) + return RedirectResponse(url="/vehicules", status_code=303) + + +@router.post("/{row_id}/delete") +async def supprimer_vehicule(request: Request, row_id: int, user=Depends(require_auth)): + await delete_row(TABLE_VEHICULES, row_id) + return RedirectResponse(url="/vehicules", status_code=303) + + +@router.post("/{row_id}/edit") +async def modifier_vehicule( + request: Request, + row_id: int, + matricule: str = Form(...), + type_v: str = Form(...), + marque: str = Form(""), + capacite: float = Form(None), + notes: str = Form(""), + user=Depends(require_auth), +): + await update_row(TABLE_VEHICULES, row_id, { + "رقم_الماتريكول": matricule, + "النوع": type_v, + "العلامة": marque, + "حمولة_الخزان": capacite, + "ملاحظات": notes, + }) + return RedirectResponse(url="/vehicules", status_code=303) diff --git a/app/templates.py b/app/templates.py new file mode 100644 index 0000000..8868fb1 --- /dev/null +++ b/app/templates.py @@ -0,0 +1,6 @@ +"""L'initialisation des templates Jinja2.""" +from pathlib import Path +from fastapi.templating import Jinja2Templates + +TEMPLATES_DIR = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) diff --git a/app/templates/anomalies.html b/app/templates/anomalies.html new file mode 100644 index 0000000..f281e13 --- /dev/null +++ b/app/templates/anomalies.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}الشذوذ — GSPARC Mezzouna{% endblock %} +{% block content %} + +

⚠️ قائمة الشذوذات

+ +
+ {% if anomalies %} + + + + + + + + + + + + + + {% for a in anomalies %} + + + + + + + + + + {% endfor %} + +
التاريخالماتريكولالنوعنوع الشذوذرقم الإيصالالحالةإجراء
{{ a.التاريخ }}{{ a.رقم_الماتريكول }}{{ vehicules.get(a.رقم_الماتريكول,'') }}{{ a.نوع_الشذوذ }}{{ a.رقم_الايصال if a.رقم_الايصال else "-" }} + {% if a.تمت_المعالجة %} + ✅ تمت المعالجة + {% else %} + ⏳ قيد الانتظار + {% endif %} + + {% if not a.تمت_المعالجة %} +
+ + +
+ {% endif %} +
+ {% else %} +

✅ لا توجد شذوذات. كل العمليات طبيعية.

+ {% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..405e650 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,203 @@ + + + + + + {% block title %}GSPARC Mezzouna{% endblock %} + + + + {% if request.session.get("gsparc_user") %} +
+

⛽ GSPARC Mezzouna — متابعة الوقود

+ +
+ {% endif %} + +
+ {% if error %} +
{{ error }}
+ {% endif %} + {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..d4e08d7 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}الرئيسية — GSPARC Mezzouna{% endblock %} +{% block content %} + +

📊 لوحة القيادة

+ +
+
+
{{ total_v }}
+
عدد العربات
+
+
+
{{ total_appro }}
+
عمليات التموين
+
+
+
{{ total_anomalies }}
+
شذوذ غير معالج
+
+
+ +
+

🚛 نظرة عامة على العربات

+ {% if stats_vehicules %} + + + + + + + + + + + + {% for v in stats_vehicules %} + + + + + + + + {% endfor %} + +
الماتريكولالنوععدد التموينمتوسط الاستهلاك (ل/100كم)شذوذ
{{ v.matricule }}{{ v.type }}{{ v.nb_appros }}{{ v.conso_moyenne }} + {% if v.nb_anomalies > 0 %} + {{ v.nb_anomalies }} + {% else %} + + {% endif %} +
+ {% else %} +

لا توجد بيانات بعد. ابدأ بإدخال عملية تموين.

+ {% endif %} +
+ +
+ ⛽ إضافة تموين جديد + 📷 مسح إيصال +
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/fuel_form.html b/app/templates/fuel_form.html new file mode 100644 index 0000000..b66a5b4 --- /dev/null +++ b/app/templates/fuel_form.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}تموين — GSPARC Mezzouna{% endblock %} +{% block content %} + +
+

⛽ تسجيل تموين جديد

+ 📷 مسح إيصال +
+ +{% if ocr_text %} + +
+

نتيجة المسح الضوئي

+
{{ ocr_text }}
+ {% if ocr_parsed %} +
+ تم استخراج: {{ ocr_parsed.الكمية }}L {{ ocr_parsed.المنتج }} +
+ {% endif %} +
+{% endif %} + +
+

بيانات التموين

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+

📋 آخر عمليات التموين

+ {% if appros %} + + + + + + + + + + + + {% for a in appros[:20] %} + + + + + + + + {% endfor %} + +
التاريخالماتريكولالكميةالاستهلاكشذوذ
{{ a.التاريخ }}{{ a.رقم_الماتريكول }}{{ a.الكمية_باللتر }} ل{{ a.الاستهلاك_100كم if a.الاستهلاك_100كم else "-" }} + {% if a.شذوذ %} + {{ a.نوع_الشذوذ }} + {% else %} + + {% endif %} +
+ {% else %} +

لا توجد عمليات تموين بعد.

+ {% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..c6f3a44 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}تسجيل الدخول{% endblock %} +{% block content %} +
+
+

⛽ تسجيل الدخول

+

GSPARC Mezzouna — متابعة الوقود

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+
+ + +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/vehicules.html b/app/templates/vehicules.html new file mode 100644 index 0000000..ea80284 --- /dev/null +++ b/app/templates/vehicules.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}العربات — GSPARC Mezzouna{% endblock %} +{% block content %} + +
+

🚛 قائمة العربات

+ +
+ + + + + +
+ {% if vehicules %} + + + + + + + + + + + + {% for v in vehicules %} + + + + + + + + {% endfor %} + +
الماتريكولالنوعالعلامةحمولة الخزانإجراءات
{{ v.رقم_الماتريكول }}{{ v.النوع }}{{ v.العلامة if v.العلامة else "-" }}{{ v.حمولة_الخزان if v.حمولة_الخزان else "-" }} +
+ +
+
+ {% else %} +

لا توجد عربات مسجلة.

+ {% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0a11a9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + gsparc-api: + build: . + container_name: gsparc-mezzouna-api + ports: + - "3040:8000" + environment: + - BASEROW_URL=http://baserow:80 + - BASEROW_HOST=baserow.bolbol.tn + - BASEROW_USER=${BASEROW_USER} + - BASEROW_PASSWORD=${BASEROW_PASSWORD} + - TIKA_URL=http://tika:9998 + - GOTENBERG_URL=http://gotenberg:3000 + - GSPARC_USER=admin + - GSPARC_PASSWORD=gsparc2026_dev + - GSPARC_SECRET=gsparc-mezzouna-secret-key-2026 + restart: unless-stopped + networks: + - hermes_net + +networks: + hermes_net: + external: true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e33f42 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.110.0 +httpx>=0.27.0 +python-multipart>=0.0.9 +uvicorn[standard]>=0.29.0 +jinja2>=3.1.0 +itsdangerous>=2.0.0