v1.0.0 — GSPARC Mezzouna API initiale

This commit is contained in:
Nabil Derouiche 2026-05-28 18:48:40 +00:00
commit cc143fd321
30 changed files with 1241 additions and 0 deletions

17
.env.example Normal file
View File

@ -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

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
app/__pycache__/\n*.pyc\n.env\n*.log\n__pycache__/

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

18
Dockerfile Normal file
View File

@ -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"]

BIN
Recu-test.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
Tableau-sortie-1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

BIN
Tableau-sortie-2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
"""GSPARC Mezzouna — Suivi consommation carburant Garde Nationale"""

36
app/auth.py Normal file
View File

@ -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

94
app/baserow.py Normal file
View File

@ -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}/")

86
app/business.py Normal file
View File

@ -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),
}

22
app/config.py Normal file
View File

@ -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

47
app/main.py Normal file
View File

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

67
app/ocr.py Normal file
View File

@ -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

14
app/routes/__init__.py Normal file
View File

@ -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)

34
app/routes/anomalies.py Normal file
View File

@ -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)

25
app/routes/auth_routes.py Normal file
View File

@ -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")

44
app/routes/dashboard.py Normal file
View File

@ -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,
})

88
app/routes/fuel.py Normal file
View File

@ -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,
})

62
app/routes/vehicles.py Normal file
View File

@ -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)

6
app/templates.py Normal file
View File

@ -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))

View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}الشذوذ — GSPARC Mezzouna{% endblock %}
{% block content %}
<h2 style="margin-bottom:1rem;">⚠️ قائمة الشذوذات</h2>
<div class="card">
{% if anomalies %}
<table>
<thead>
<tr>
<th>التاريخ</th>
<th>الماتريكول</th>
<th>النوع</th>
<th>نوع الشذوذ</th>
<th>رقم الإيصال</th>
<th>الحالة</th>
<th>إجراء</th>
</tr>
</thead>
<tbody>
{% for a in anomalies %}
<tr>
<td>{{ a.التاريخ }}</td>
<td>{{ a.رقم_الماتريكول }}</td>
<td>{{ vehicules.get(a.رقم_الماتريكول,'') }}</td>
<td><span class="badge badge-danger">{{ a.نوع_الشذوذ }}</span></td>
<td>{{ a.رقم_الايصال if a.رقم_الايصال else "-" }}</td>
<td>
{% if a.تمت_المعالجة %}
<span class="badge badge-success">✅ تمت المعالجة</span>
{% else %}
<span class="badge badge-warning">⏳ قيد الانتظار</span>
{% endif %}
</td>
<td>
{% if not a.تمت_المعالجة %}
<form method="POST" action="/anomalies/{{ a.id }}/resolve" style="display:inline;">
<input type="text" name="action" placeholder="إجراء التصحيح" style="width:120px; padding:0.3rem; margin-left:0.3rem;">
<button class="btn btn-success btn-sm">حل</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:var(--text-light); text-align:center; padding:2rem;">✅ لا توجد شذوذات. كل العمليات طبيعية.</p>
{% endif %}
</div>
{% endblock %}

203
app/templates/base.html Normal file
View File

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GSPARC Mezzouna{% endblock %}</title>
<style>
:root {
--bg: #f5f5f5;
--card: #ffffff;
--primary: #1a3a5c;
--accent: #d4a574;
--text: #333;
--text-light: #666;
--border: #e0e0e0;
--danger: #d32f2f;
--success: #2e7d32;
--warning: #f57c00;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
header {
background: var(--primary);
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
header h1 { font-size: 1.3rem; }
header nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: var(--radius);
transition: background 0.2s;
}
header nav a:hover { background: rgba(255,255,255,0.15); }
header nav a.active { background: var(--accent); }
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.card {
background: var(--card);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.card h2 {
color: var(--primary);
margin-bottom: 1rem;
font-size: 1.2rem;
border-bottom: 2px solid var(--accent);
padding-bottom: 0.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card);
border-radius: var(--radius);
padding: 1.2rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-top: 3px solid var(--primary);
}
.stat-card .number { font-size: 2rem; font-weight: bold; color: var(--primary); }
.stat-card .label { color: var(--text-light); font-size: 0.9rem; margin-top: 0.3rem; }
.stat-card.warning { border-top-color: var(--warning); }
.stat-card.warning .number { color: var(--warning); }
.stat-card.success { border-top-color: var(--success); }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: right;
border-bottom: 1px solid var(--border);
}
th {
background: var(--primary);
color: white;
font-weight: 600;
}
tr:hover { background: rgba(26,58,92,0.03); }
.btn {
display: inline-block;
padding: 0.6rem 1.2rem;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.95rem;
text-decoration: none;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: var(--primary); color: white; }
.btn-danger { background: var(--danger); color: white; }
.btn-success { background: var(--success); color: white; }
.btn-warning { background: var(--warning); color: white; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.85rem; }
form {
max-width: 600px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.3rem;
font-weight: 600;
color: var(--text);
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.95rem;
font-family: inherit;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.alert {
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.alert-error { background: #ffebee; color: var(--danger); border: 1px solid #ef9a9a; }
.alert-info { background: #e3f2fd; color: var(--primary); border: 1px solid #90caf9; }
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-danger { background: #ffebee; color: var(--danger); }
.badge-success { background: #e8f5e9; color: var(--success); }
.badge-warning { background: #fff3e0; color: var(--warning); }
.ocr-box {
background: #fafafa;
border: 1px dashed var(--border);
padding: 1rem;
border-radius: var(--radius);
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 768px) {
.form-row { grid-template-columns: 1fr; }
header { flex-direction: column; gap: 0.5rem; }
}
</style>
</head>
<body>
{% if request.session.get("gsparc_user") %}
<header>
<h1>⛽ GSPARC Mezzouna — متابعة الوقود</h1>
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">📊 الرئيسية</a>
<a href="/fuel" class="{% if request.url.path.startswith('/fuel') %}active{% endif %}">⛽ تموين</a>
<a href="/vehicules" class="{% if request.url.path.startswith('/vehicules') %}active{% endif %}">🚛 العربات</a>
<a href="/anomalies" class="{% if request.url.path.startswith('/anomalies') %}active{% endif %}">⚠️ الشذوذ</a>
<a href="/logout" style="opacity:0.7;">🚪 خروج</a>
</nav>
</header>
{% endif %}
<div class="container">
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}الرئيسية — GSPARC Mezzouna{% endblock %}
{% block content %}
<h2 style="margin-bottom:1rem;">📊 لوحة القيادة</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="number">{{ total_v }}</div>
<div class="label">عدد العربات</div>
</div>
<div class="stat-card success">
<div class="number">{{ total_appro }}</div>
<div class="label">عمليات التموين</div>
</div>
<div class="stat-card {% if total_anomalies > 0 %}warning{% endif %}">
<div class="number">{{ total_anomalies }}</div>
<div class="label">شذوذ غير معالج</div>
</div>
</div>
<div class="card">
<h2>🚛 نظرة عامة على العربات</h2>
{% if stats_vehicules %}
<table>
<thead>
<tr>
<th>الماتريكول</th>
<th>النوع</th>
<th>عدد التموين</th>
<th>متوسط الاستهلاك (ل/100كم)</th>
<th>شذوذ</th>
</tr>
</thead>
<tbody>
{% for v in stats_vehicules %}
<tr>
<td><strong>{{ v.matricule }}</strong></td>
<td>{{ v.type }}</td>
<td>{{ v.nb_appros }}</td>
<td>{{ v.conso_moyenne }}</td>
<td>
{% if v.nb_anomalies > 0 %}
<span class="badge badge-danger">{{ v.nb_anomalies }}</span>
{% else %}
<span class="badge badge-success"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:var(--text-light);">لا توجد بيانات بعد. ابدأ بإدخال عملية تموين.</p>
{% endif %}
</div>
<div style="display:flex; gap:1rem; margin-top:1rem;">
<a href="/fuel" class="btn btn-primary">⛽ إضافة تموين جديد</a>
<a href="/fuel?ocr=1" class="btn btn-warning">📷 مسح إيصال</a>
</div>
{% endblock %}

View File

@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}تموين — GSPARC Mezzouna{% endblock %}
{% block content %}
<div style="display:flex; justify-content:space-between; margin-bottom:1rem;">
<h2>⛽ تسجيل تموين جديد</h2>
<a href="/fuel?ocr=1" class="btn btn-warning">📷 مسح إيصال</a>
</div>
{% if ocr_text %}
<!-- Résultat OCR -->
<div class="card">
<h2>نتيجة المسح الضوئي</h2>
<div class="ocr-box">{{ ocr_text }}</div>
{% if ocr_parsed %}
<div style="margin-top:1rem;">
<span class="badge badge-success">تم استخراج: {{ ocr_parsed.الكمية }}L {{ ocr_parsed.المنتج }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="card">
<h2>بيانات التموين</h2>
<form method="POST" action="/fuel/add">
<div class="form-row">
<div class="form-group">
<label>العربة *</label>
<select name="matricule" required>
<option value="">-- اختر العربة --</option>
{% for v in vehicules %}
<option value="{{ v.رقم_الماتريكول }}"
{% if ocr_parsed and ocr_parsed.رقم_الماتريكول == v.رقم_الماتريكول %}selected{% endif %}>
{{ v.رقم_الماتريكول }} — {{ v.النوع }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>التاريخ *</label>
<input type="date" name="date" required
value="{{ ocr_parsed.التاريخ if ocr_parsed else '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>المنتج</label>
<select name="produit">
<option value="Gasoual Normal" selected>Gasoual Normal</option>
<option value="Gasoual 50">Gasoual 50</option>
<option value="Essence Sans Plomb">Essence Sans Plomb</option>
</select>
</div>
<div class="form-group">
<label>الكمية (لتر) *</label>
<input type="number" name="quantite" step="0.01" required
value="{{ ocr_parsed.الكمية if ocr_parsed else '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>سعر اللتر (د.ت) *</label>
<input type="number" name="prix_litre" step="0.001" required
value="{{ ocr_parsed.السعر if ocr_parsed else '' }}">
</div>
<div class="form-group">
<label>القيمة الجملية (د.ت) *</label>
<input type="number" name="valeur" step="0.001" required
value="{{ ocr_parsed.القيمة if ocr_parsed else '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>العداد قبل (كم)</label>
<input type="number" name="compteur_avant" step="1">
</div>
<div class="form-group">
<label>العداد بعد (كم)</label>
<input type="number" name="compteur_apres" step="1">
</div>
</div>
<div class="form-group">
<label>رقم الإيصال</label>
<input type="text" name="no_recu"
value="{{ ocr_parsed.رقم_الايصال if ocr_parsed else '' }}">
</div>
<button type="submit" class="btn btn-success" style="width:100%; margin-top:0.5rem;">💾 حفظ التموين</button>
</form>
</div>
<!-- Historique -->
<div class="card">
<h2>📋 آخر عمليات التموين</h2>
{% if appros %}
<table>
<thead>
<tr>
<th>التاريخ</th>
<th>الماتريكول</th>
<th>الكمية</th>
<th>الاستهلاك</th>
<th>شذوذ</th>
</tr>
</thead>
<tbody>
{% for a in appros[:20] %}
<tr>
<td>{{ a.التاريخ }}</td>
<td>{{ a.رقم_الماتريكول }}</td>
<td>{{ a.الكمية_باللتر }} ل</td>
<td>{{ a.الاستهلاك_100كم if a.الاستهلاك_100كم else "-" }}</td>
<td>
{% if a.شذوذ %}
<span class="badge badge-danger">{{ a.نوع_الشذوذ }}</span>
{% else %}
<span class="badge badge-success"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:var(--text-light);">لا توجد عمليات تموين بعد.</p>
{% endif %}
</div>
{% endblock %}

26
app/templates/login.html Normal file
View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}تسجيل الدخول{% endblock %}
{% block content %}
<div style="max-width:400px; margin:80px auto;">
<div class="card">
<h2 style="text-align:center;">⛽ تسجيل الدخول</h2>
<p style="text-align:center; color:var(--text-light); margin-bottom:1.5rem;">GSPARC Mezzouna — متابعة الوقود</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="POST" action="/login">
<div class="form-group">
<label>اسم المستخدم</label>
<input type="text" name="username" required autocomplete="username" placeholder="المستخدم">
</div>
<div class="form-group">
<label>كلمة المرور</label>
<input type="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div>
<button type="submit" class="btn btn-primary" style="width:100%; margin-top:0.5rem;">دخول</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}العربات — GSPARC Mezzouna{% endblock %}
{% block content %}
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h2>🚛 قائمة العربات</h2>
<button class="btn btn-primary" onclick="document.getElementById('addForm').style.display='block'">+ إضافة</button>
</div>
<!-- Formulaire ajout -->
<div id="addForm" class="card" style="display:none;">
<h2>إضافة عربة جديدة</h2>
<form method="POST" action="/vehicules/add">
<div class="form-row">
<div class="form-group">
<label>رقم الماتريكول *</label>
<input type="text" name="matricule" required>
</div>
<div class="form-group">
<label>النوع *</label>
<input type="text" name="type_v" required placeholder="شاحنة إطفاء، سيارة إسعاف...">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>العلامة</label>
<input type="text" name="marque">
</div>
<div class="form-group">
<label>حمولة الخزان (لتر)</label>
<input type="number" name="capacite" step="0.1">
</div>
</div>
<div class="form-group">
<label>ملاحظات</label>
<textarea name="notes" rows="2"></textarea>
</div>
<button type="submit" class="btn btn-success">حفظ</button>
<button type="button" class="btn" onclick="document.getElementById('addForm').style.display='none'" style="background:#999;color:white;">إلغاء</button>
</form>
</div>
<!-- Liste -->
<div class="card">
{% if vehicules %}
<table>
<thead>
<tr>
<th>الماتريكول</th>
<th>النوع</th>
<th>العلامة</th>
<th>حمولة الخزان</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
{% for v in vehicules %}
<tr>
<td><strong>{{ v.رقم_الماتريكول }}</strong></td>
<td>{{ v.النوع }}</td>
<td>{{ v.العلامة if v.العلامة else "-" }}</td>
<td>{{ v.حمولة_الخزان if v.حمولة_الخزان else "-" }}</td>
<td>
<form method="POST" action="/vehicules/{{ v.id }}/delete" style="display:inline;" onsubmit="return confirm('هل أنت متأكد من الحذف؟')">
<button class="btn btn-danger btn-sm">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:var(--text-light);">لا توجد عربات مسجلة.</p>
{% endif %}
</div>
{% endblock %}

23
docker-compose.yml Normal file
View File

@ -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

6
requirements.txt Normal file
View File

@ -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