v1.0.0 — GSPARC Mezzouna API initiale
This commit is contained in:
commit
cc143fd321
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
app/__pycache__/\n*.pyc\n.env\n*.log\n__pycache__/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
|
|
@ -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"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
"""GSPARC Mezzouna — Suivi consommation carburant Garde Nationale"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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}/")
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue