feat: dashboard web FastAPI Sprint 4

Ajout d'un dashboard lecture seule par-dessus la DB SQLite existante.

Fichiers créés :
  - tickettracker/web/queries.py   : 7 fonctions SQL (stats, compare, historique...)
  - tickettracker/web/api.py       : router /api/* JSON (FastAPI)
  - tickettracker/web/app.py       : routes HTML + Jinja2 + point d'entrée uvicorn
  - tickettracker/web/templates/   : base.html, index.html, compare.html, product.html, receipt.html
  - tickettracker/web/static/style.css : personnalisations Pico CSS
  - tests/test_web.py              : 19 tests (96 passent, 1 xfail OCR)

Fichiers modifiés :
  - requirements.txt : +fastapi, uvicorn[standard], jinja2, python-multipart, httpx
  - config.py        : +DB_PATH (lu depuis TICKETTRACKER_DB_PATH)

Lancement : python -m tickettracker.web.app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 20:04:55 +01:00
parent 1e5fc97bb7
commit 30e4b3e144
13 changed files with 1334 additions and 0 deletions

View File

@@ -13,6 +13,16 @@ Variables d'environnement disponibles :
"""
import os
from pathlib import Path
# ---------------------------------------------------------------------------
# Base de données
# ---------------------------------------------------------------------------
from tickettracker.db.schema import DEFAULT_DB_PATH as _DEFAULT_DB_PATH
# Chemin vers la base SQLite (surchargeable par variable d'environnement)
DB_PATH: Path = Path(os.environ.get("TICKETTRACKER_DB_PATH", str(_DEFAULT_DB_PATH)))
# ---------------------------------------------------------------------------
# LLM

View File

87
tickettracker/web/api.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Router FastAPI pour les endpoints JSON /api/*.
Chaque endpoint ouvre sa propre connexion SQLite (via config.DB_PATH),
appelle la fonction de queries.py correspondante, puis ferme la connexion.
"""
import sqlite3
from fastapi import APIRouter, HTTPException
import tickettracker.config as config
from tickettracker.db.schema import get_connection
from tickettracker.web.queries import (
get_all_receipts,
get_compare_prices,
get_dashboard_stats,
get_product_history,
get_receipt_detail,
)
router = APIRouter(prefix="/api")
@router.get("/stats")
def api_stats():
"""Statistiques globales (nb tickets, total dépensé, etc.)."""
conn = get_connection(config.DB_PATH)
try:
return get_dashboard_stats(conn)
finally:
conn.close()
@router.get("/compare")
def api_compare():
"""Comparaison de prix Picnic vs Leclerc pour les produits communs."""
conn = get_connection(config.DB_PATH)
try:
return get_compare_prices(conn)
finally:
conn.close()
@router.get("/product/{name:path}/history")
def api_product_history(name: str):
"""Historique des prix d'un produit normalisé.
Retourne 404 si le produit est inconnu.
Le paramètre {name:path} autorise les '/' dans le nom normalisé.
"""
conn = get_connection(config.DB_PATH)
try:
data = get_product_history(conn, name)
finally:
conn.close()
if data is None:
raise HTTPException(status_code=404, detail="Produit introuvable")
return data
@router.get("/receipts")
def api_receipts():
"""Liste tous les tickets avec leur nombre d'articles."""
conn = get_connection(config.DB_PATH)
try:
return get_all_receipts(conn)
finally:
conn.close()
@router.get("/receipt/{receipt_id}")
def api_receipt_detail(receipt_id: int):
"""Détail d'un ticket et de ses articles.
Retourne 404 si l'id est inconnu.
"""
conn = get_connection(config.DB_PATH)
try:
data = get_receipt_detail(conn, receipt_id)
finally:
conn.close()
if data is None:
raise HTTPException(status_code=404, detail="Ticket introuvable")
return data

206
tickettracker/web/app.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Application web FastAPI pour le dashboard TicketTracker.
Routes HTML (lecture seule) :
GET / → index.html (stats + graphique + derniers tickets)
GET /compare → compare.html (comparaison Picnic vs Leclerc)
GET /product/{name:path} → product.html (historique d'un produit)
GET /receipt/{id} → receipt.html (détail d'un ticket)
Lancement :
python -m tickettracker.web.app
ou
TICKETTRACKER_DB_PATH=/autre/chemin.db python -m tickettracker.web.app
"""
import json
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import tickettracker.config as config
from tickettracker.db.schema import get_connection, init_db
from tickettracker.web.api import router as api_router
from tickettracker.web.queries import (
get_all_receipts,
get_compare_prices,
get_dashboard_stats,
get_monthly_spending,
get_product_history,
get_product_list,
get_receipt_detail,
)
# ---------------------------------------------------------------------------
# Initialisation de l'application
# ---------------------------------------------------------------------------
app = FastAPI(title="TicketTracker Dashboard", docs_url="/api/docs", redoc_url=None)
# Répertoires statiques et templates (relatifs à ce fichier)
_WEB_DIR = Path(__file__).parent
_STATIC_DIR = _WEB_DIR / "static"
_TEMPLATES_DIR = _WEB_DIR / "templates"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
# Filtre Jinja2 pour encoder les noms de produits dans les URLs
templates.env.filters["urlquote"] = lambda s: quote(str(s), safe="")
# Router API JSON
app.include_router(api_router)
# ---------------------------------------------------------------------------
# Helper : transforme la liste plate monthly en structure Chart.js
# ---------------------------------------------------------------------------
def _build_monthly_chart_data(monthly: list[dict]) -> dict:
"""Convertit [{month, store, total}] en structure datasets Chart.js stacked bar.
Retourne un dict sérialisable en JSON :
{
"labels": ["2026-01", ...],
"datasets": [
{"label": "picnic", "data": [...], "backgroundColor": "#4a9eff"},
{"label": "leclerc", "data": [...], "backgroundColor": "#ff6b35"},
]
}
Les totaux manquants (enseigne absente pour un mois) sont mis à 0.
"""
# Extraire tous les mois et enseignes distincts (ordonnés)
labels = sorted({row["month"] for row in monthly})
stores = sorted({row["store"] for row in monthly})
# Construire un index {(month, store): total} pour lookup rapide
index = {(row["month"], row["store"]): row["total"] for row in monthly}
# Couleurs associées aux enseignes
colors = {"picnic": "#4a9eff", "leclerc": "#ff6b35"}
datasets = [
{
"label": store,
"data": [index.get((month, store), 0) for month in labels],
"backgroundColor": colors.get(store, "#888888"),
}
for store in stores
]
return {"labels": labels, "datasets": datasets}
# ---------------------------------------------------------------------------
# Routes HTML
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def page_index(request: Request):
"""Page d'accueil : statistiques globales + graphique + liste des tickets."""
conn = get_connection(config.DB_PATH)
try:
stats = get_dashboard_stats(conn)
monthly = get_monthly_spending(conn)
receipts = get_all_receipts(conn)
finally:
conn.close()
chart_data = _build_monthly_chart_data(monthly)
empty = stats["total_receipts"] == 0
return templates.TemplateResponse(
request,
"index.html",
{
"stats": stats,
"chart_data": chart_data,
"receipts": receipts,
"empty": empty,
},
)
@app.get("/compare", response_class=HTMLResponse)
async def page_compare(request: Request):
"""Page de comparaison des prix Picnic vs Leclerc."""
conn = get_connection(config.DB_PATH)
try:
products = get_compare_prices(conn)
finally:
conn.close()
return templates.TemplateResponse(
request,
"compare.html",
{
"products": products,
"empty": len(products) == 0,
},
)
@app.get("/product/{name:path}", response_class=HTMLResponse)
async def page_product(request: Request, name: str):
"""Page historique d'un produit normalisé."""
conn = get_connection(config.DB_PATH)
try:
data = get_product_history(conn, name)
all_products = get_product_list(conn)
finally:
conn.close()
return templates.TemplateResponse(
request,
"product.html",
{
"data": data,
"name": name,
"all_products": all_products,
"empty": data is None,
},
)
@app.get("/receipt/{receipt_id}", response_class=HTMLResponse)
async def page_receipt(request: Request, receipt_id: int):
"""Page détail d'un ticket."""
conn = get_connection(config.DB_PATH)
try:
data = get_receipt_detail(conn, receipt_id)
finally:
conn.close()
if data is None:
return templates.TemplateResponse(
request,
"receipt.html",
{"data": None, "receipt_id": receipt_id},
status_code=404,
)
return templates.TemplateResponse(
request,
"receipt.html",
{"data": data},
)
# ---------------------------------------------------------------------------
# Point d'entrée : python -m tickettracker.web.app
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
# S'assurer que la DB existe (idempotent)
init_db(config.DB_PATH)
print(f"Base de données : {config.DB_PATH}")
print("Dashboard disponible sur http://localhost:8000")
uvicorn.run("tickettracker.web.app:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -0,0 +1,297 @@
"""
Requêtes SQL en lecture seule pour le dashboard web.
Toutes les fonctions reçoivent une connexion SQLite ouverte (pattern identique
à repository.py) et retournent des structures Python simples (dict, list).
L'appelant est responsable de l'ouverture et fermeture de la connexion.
"""
import sqlite3
def get_dashboard_stats(conn: sqlite3.Connection) -> dict:
"""Statistiques globales pour la page d'accueil.
Returns:
dict avec les clés :
- total_receipts : int
- total_spent : float
- total_items : int
- distinct_products : int
- receipts_by_store : dict[str, int]
- spent_by_store : dict[str, float]
- date_range : dict {min, max} ou {min: None, max: None}
"""
# Statistiques par enseigne
rows = conn.execute(
"SELECT store, COUNT(*) AS nb, SUM(total) AS spent FROM receipts GROUP BY store"
).fetchall()
receipts_by_store = {row["store"]: row["nb"] for row in rows}
spent_by_store = {row["store"]: round(row["spent"], 2) for row in rows}
total_receipts = sum(receipts_by_store.values())
total_spent = round(sum(row["spent"] for row in rows), 2) if rows else 0.0
# Statistiques articles
item_stats = conn.execute(
"""
SELECT
COUNT(*) AS total_items,
COUNT(DISTINCT name_normalized) AS distinct_products
FROM items
"""
).fetchone()
# Plage de dates
date_row = conn.execute(
"SELECT MIN(date) AS d_min, MAX(date) AS d_max FROM receipts"
).fetchone()
return {
"total_receipts": total_receipts,
"total_spent": total_spent,
"total_items": item_stats["total_items"],
"distinct_products": item_stats["distinct_products"],
"receipts_by_store": receipts_by_store,
"spent_by_store": spent_by_store,
"date_range": {"min": date_row["d_min"], "max": date_row["d_max"]},
}
def get_monthly_spending(conn: sqlite3.Connection) -> list[dict]:
"""Dépenses mensuelles par enseigne, pour le graphique Chart.js.
Returns:
Liste de dicts {month: "2026-01", store: "picnic", total: 45.20},
triée par mois puis enseigne.
"""
rows = conn.execute(
"""
SELECT
substr(date, 1, 7) AS month,
store,
ROUND(SUM(total), 2) AS total
FROM receipts
GROUP BY month, store
ORDER BY month, store
"""
).fetchall()
return [{"month": r["month"], "store": r["store"], "total": r["total"]} for r in rows]
def get_compare_prices(conn: sqlite3.Connection) -> list[dict]:
"""Comparaison de prix entre Picnic et Leclerc pour les produits communs.
Utilise la vue price_history. Ne retourne que les produits présents
dans les deux enseignes. Trié par écart décroissant (le plus cher en premier).
Returns:
Liste de dicts {name, price_picnic, price_leclerc, diff, diff_pct}.
diff = price_leclerc - price_picnic (positif = Leclerc plus cher)
diff_pct = diff / MIN(price_picnic, price_leclerc) * 100
"""
rows = conn.execute(
"""
WITH avg_by_store AS (
SELECT
name_normalized,
store,
ROUND(AVG(unit_price), 2) AS avg_price
FROM price_history
WHERE name_normalized IS NOT NULL
GROUP BY name_normalized, store
)
SELECT
a.name_normalized AS name,
a.avg_price AS price_picnic,
b.avg_price AS price_leclerc,
ROUND(b.avg_price - a.avg_price, 2) AS diff,
ROUND(
(b.avg_price - a.avg_price)
/ MIN(a.avg_price, b.avg_price) * 100
, 1) AS diff_pct
FROM avg_by_store a
JOIN avg_by_store b
ON a.name_normalized = b.name_normalized
AND a.store = 'picnic'
AND b.store = 'leclerc'
ORDER BY ABS(b.avg_price - a.avg_price) DESC
"""
).fetchall()
return [
{
"name": r["name"],
"price_picnic": r["price_picnic"],
"price_leclerc": r["price_leclerc"],
"diff": r["diff"],
"diff_pct": r["diff_pct"],
}
for r in rows
]
def get_product_history(conn: sqlite3.Connection, name: str) -> dict | None:
"""Historique des prix d'un produit normalisé.
Args:
conn: Connexion SQLite ouverte.
name: Valeur de name_normalized à rechercher (sensible à la casse).
Returns:
dict {name, min_price, max_price, avg_price, history: list[dict]}
ou None si le produit est inconnu.
Chaque entrée de history : {date, store, unit_price, quantity, unit}.
"""
# Statistiques globales
stats = conn.execute(
"""
SELECT
name_normalized,
ROUND(MIN(unit_price), 2) AS min_price,
ROUND(MAX(unit_price), 2) AS max_price,
ROUND(AVG(unit_price), 2) AS avg_price
FROM price_history
WHERE name_normalized = ?
""",
(name,),
).fetchone()
if stats is None or stats["name_normalized"] is None:
return None
# Historique chronologique
rows = conn.execute(
"""
SELECT date, store, unit_price, quantity, unit
FROM price_history
WHERE name_normalized = ?
ORDER BY date
""",
(name,),
).fetchall()
return {
"name": stats["name_normalized"],
"min_price": stats["min_price"],
"max_price": stats["max_price"],
"avg_price": stats["avg_price"],
"history": [
{
"date": r["date"],
"store": r["store"],
"unit_price": r["unit_price"],
"quantity": r["quantity"],
"unit": r["unit"],
}
for r in rows
],
}
def get_all_receipts(conn: sqlite3.Connection) -> list[dict]:
"""Liste tous les tickets avec le nombre d'articles associés.
Returns:
Liste de dicts {id, store, date, total, delivery_fee, order_id, nb_items},
triée par date décroissante (le plus récent en premier).
"""
rows = conn.execute(
"""
SELECT
r.id,
r.store,
r.date,
r.total,
r.delivery_fee,
r.order_id,
COUNT(i.id) AS nb_items
FROM receipts r
LEFT JOIN items i ON i.receipt_id = r.id
GROUP BY r.id
ORDER BY r.date DESC, r.id DESC
"""
).fetchall()
return [
{
"id": r["id"],
"store": r["store"],
"date": r["date"],
"total": r["total"],
"delivery_fee": r["delivery_fee"],
"order_id": r["order_id"],
"nb_items": r["nb_items"],
}
for r in rows
]
def get_receipt_detail(conn: sqlite3.Connection, receipt_id: int) -> dict | None:
"""Détail complet d'un ticket et de ses articles.
Args:
conn: Connexion SQLite ouverte.
receipt_id: Id du ticket à récupérer.
Returns:
dict avec les champs du ticket + items: list[dict], ou None si introuvable.
"""
receipt = conn.execute(
"SELECT id, store, date, total, delivery_fee, order_id FROM receipts WHERE id = ?",
(receipt_id,),
).fetchone()
if receipt is None:
return None
items = conn.execute(
"""
SELECT id, name_raw, name_normalized, category, quantity, unit, unit_price, total_price
FROM items
WHERE receipt_id = ?
ORDER BY id
""",
(receipt_id,),
).fetchall()
return {
"id": receipt["id"],
"store": receipt["store"],
"date": receipt["date"],
"total": receipt["total"],
"delivery_fee": receipt["delivery_fee"],
"order_id": receipt["order_id"],
"items": [
{
"id": i["id"],
"name_raw": i["name_raw"],
"name_normalized": i["name_normalized"],
"category": i["category"],
"quantity": i["quantity"],
"unit": i["unit"],
"unit_price": i["unit_price"],
"total_price": i["total_price"],
}
for i in items
],
}
def get_product_list(conn: sqlite3.Connection) -> list[str]:
"""Liste tous les noms normalisés distincts (non NULL) pour le sélecteur.
Returns:
Liste de str triée alphabétiquement.
"""
rows = conn.execute(
"""
SELECT DISTINCT name_normalized
FROM items
WHERE name_normalized IS NOT NULL
ORDER BY name_normalized
"""
).fetchall()
return [r["name_normalized"] for r in rows]

View File

@@ -0,0 +1,48 @@
/* Personnalisations légères par-dessus Pico CSS */
/* Grille de cartes statistiques : 2 colonnes min, 4 max */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
/* Cartes avec le grand chiffre mis en avant */
.stat-card {
text-align: center;
padding: 1.25rem 1rem;
}
.stat-card h3 {
font-size: 2rem;
margin-bottom: 0.25rem;
color: var(--pico-primary);
}
.stat-card p {
margin: 0;
font-size: 0.9rem;
color: var(--pico-muted-color);
}
/* Contraindre la hauteur des canvas Chart.js */
.chart-container {
position: relative;
max-height: 350px;
margin: 1rem 0;
}
/* Couleurs pour les écarts de prix dans la table compare */
.diff-positive {
color: #c0392b; /* rouge = Leclerc plus cher */
}
.diff-negative {
color: #27ae60; /* vert = Picnic plus cher (économie) */
}
/* Débordement horizontal pour les grandes tables */
.overflow-auto {
overflow-x: auto;
}

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}TicketTracker{% endblock %}</title>
<!-- Pico CSS : framework CSS minimaliste sans JavaScript -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Chart.js : graphiques -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<!-- Style personnalisé -->
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="container">
<nav>
<ul>
<li><strong>🛒 TicketTracker</strong></li>
</ul>
<ul>
<li><a href="/">Accueil</a></li>
<li><a href="/compare">Comparer</a></li>
<li><a href="/api/docs" target="_blank">API docs</a></li>
</ul>
</nav>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="container">
<small>TicketTracker — dashboard lecture seule</small>
</footer>
</body>
</html>

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Comparer les prix — TicketTracker{% endblock %}
{% block content %}
<h1>Comparaison Picnic vs Leclerc</h1>
{% if empty %}
<article>
<p>
Aucun produit commun trouvé entre Picnic et Leclerc.
</p>
<p>
Pour voir une comparaison, vous devez :
</p>
<ol>
<li>Importer des tickets des deux enseignes</li>
<li>Normaliser les noms d'articles avec la CLI</li>
</ol>
<pre><code>python -m tickettracker.cli normalize</code></pre>
</article>
{% else %}
<p>Produits présents chez les deux enseignes, triés par écart de prix décroissant.</p>
<div class="overflow-auto">
<table>
<thead>
<tr>
<th>Produit</th>
<th>Picnic moy.</th>
<th>Leclerc moy.</th>
<th>Écart €</th>
<th>Écart %</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td>{{ p.name }}</td>
<td>{{ "%.2f"|format(p.price_picnic) }} €</td>
<td>{{ "%.2f"|format(p.price_leclerc) }} €</td>
<td class="{% if p.diff > 0 %}diff-positive{% elif p.diff < 0 %}diff-negative{% endif %}">
{{ "%+.2f"|format(p.diff) }} €
</td>
<td class="{% if p.diff > 0 %}diff-positive{% elif p.diff < 0 %}diff-negative{% endif %}">
{{ "%+.1f"|format(p.diff_pct) }} %
</td>
<td>
<a href="/product/{{ p.name | urlquote }}">Historique</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p><small>Positif = Leclerc plus cher, négatif = Picnic plus cher.</small></p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Accueil — TicketTracker{% endblock %}
{% block content %}
<h1>Tableau de bord</h1>
{% if empty %}
<article>
<p>
Aucun ticket importé. Utilisez la CLI pour importer vos tickets :
</p>
<pre><code>python -m tickettracker.cli import chemin/vers/ticket.html # Picnic
python -m tickettracker.cli import chemin/vers/ticket.pdf # Leclerc</code></pre>
</article>
{% else %}
<!-- Cartes de statistiques -->
<div class="stat-grid">
<article class="stat-card">
<h3>{{ stats.total_receipts }}</h3>
<p>Tickets importés</p>
</article>
<article class="stat-card">
<h3>{{ "%.2f"|format(stats.total_spent) }} €</h3>
<p>Total dépensé</p>
</article>
<article class="stat-card">
<h3>{{ stats.distinct_products }}</h3>
<p>Produits distincts</p>
</article>
<article class="stat-card">
<h3>{{ stats.total_items }}</h3>
<p>Articles scannés</p>
</article>
</div>
<!-- Graphique dépenses par mois -->
<article>
<h2>Dépenses par mois</h2>
<div class="chart-container">
<canvas id="monthlyChart"></canvas>
</div>
</article>
<script>
(function () {
const data = {{ chart_data | tojson }};
const ctx = document.getElementById("monthlyChart").getContext("2d");
new Chart(ctx, {
type: "bar",
data: data,
options: {
responsive: true,
plugins: {
legend: { position: "top" }
},
scales: {
x: { stacked: true },
y: {
stacked: true,
ticks: {
callback: function(value) { return value + " €"; }
}
}
}
}
});
})();
</script>
<!-- Tableau des derniers tickets -->
<article>
<h2>Derniers tickets</h2>
<div class="overflow-auto">
<table>
<thead>
<tr>
<th>#</th>
<th>Enseigne</th>
<th>Date</th>
<th>Total</th>
<th>Articles</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in receipts %}
<tr>
<td>{{ r.id }}</td>
<td>{{ r.store }}</td>
<td>{{ r.date }}</td>
<td>{{ "%.2f"|format(r.total) }} €</td>
<td>{{ r.nb_items }}</td>
<td><a href="/receipt/{{ r.id }}">Voir</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}
{% if data %}{{ data.name }}{% else %}Produit inconnu{% endif %} — TicketTracker
{% endblock %}
{% block content %}
<h1>Historique produit</h1>
<!-- Sélecteur de produit : navigation JS vers /product/<nom encodé> -->
<label for="product-select">Choisir un produit :</label>
<select id="product-select" onchange="window.location='/product/' + encodeURIComponent(this.value)">
<option value="">— sélectionner —</option>
{% for p in all_products %}
<option value="{{ p }}" {% if p == name %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
{% if empty %}
<article>
<p>Produit <strong>{{ name }}</strong> introuvable dans la base.</p>
<p>Le nom doit correspondre exactement à un <code>name_normalized</code> existant.</p>
</article>
{% else %}
<!-- Cartes statistiques -->
<div class="stat-grid">
<article class="stat-card">
<h3>{{ "%.2f"|format(data.min_price) }} €</h3>
<p>Prix minimum</p>
</article>
<article class="stat-card">
<h3>{{ "%.2f"|format(data.avg_price) }} €</h3>
<p>Prix moyen</p>
</article>
<article class="stat-card">
<h3>{{ "%.2f"|format(data.max_price) }} €</h3>
<p>Prix maximum</p>
</article>
</div>
<!-- Graphique d'historique des prix -->
<article>
<h2>Évolution du prix unitaire</h2>
<div class="chart-container">
<canvas id="priceChart"></canvas>
</div>
</article>
<script>
(function () {
const history = {{ data.history | tojson }};
// Couleurs par enseigne
const colors = { picnic: "#4a9eff", leclerc: "#ff6b35" };
const stores = [...new Set(history.map(h => h.store))].sort();
const datasets = stores.map(store => {
const points = history.filter(h => h.store === store);
return {
label: store,
data: points.map(h => ({ x: h.date, y: h.unit_price })),
borderColor: colors[store] || "#888",
backgroundColor: (colors[store] || "#888") + "33",
tension: 0.2,
fill: false,
};
});
new Chart(document.getElementById("priceChart").getContext("2d"), {
type: "line",
data: { datasets },
options: {
responsive: true,
scales: {
x: { type: "category", title: { display: true, text: "Date" } },
y: {
title: { display: true, text: "Prix unitaire (€)" },
ticks: { callback: v => v + " €" }
}
},
plugins: { legend: { position: "top" } }
}
});
})();
</script>
<!-- Tableau des occurrences -->
<article>
<h2>Toutes les occurrences</h2>
<div class="overflow-auto">
<table>
<thead>
<tr>
<th>Date</th>
<th>Enseigne</th>
<th>Prix unitaire</th>
<th>Quantité</th>
<th>Unité</th>
</tr>
</thead>
<tbody>
{% for h in data.history %}
<tr>
<td>{{ h.date }}</td>
<td>{{ h.store }}</td>
<td>{{ "%.2f"|format(h.unit_price) }} €</td>
<td>{{ h.quantity }}</td>
<td>{{ h.unit }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}{% if data %}Ticket #{{ data.id }}{% else %}Ticket introuvable{% endif %}{% endblock %}
{% block content %}
{% if data is none %}
<article>
<h2>Ticket introuvable</h2>
<p>Le ticket #{{ receipt_id }} n'existe pas dans la base.</p>
<a href="/">← Retour à l'accueil</a>
</article>
{% else %}
<!-- En-tête ticket -->
<hgroup>
<h1>Ticket #{{ data.id }}</h1>
<p>{{ data.store | capitalize }} — {{ data.date }}</p>
</hgroup>
<!-- Champs du ticket -->
<article>
<dl>
<dt>Enseigne</dt>
<dd>{{ data.store }}</dd>
<dt>Date</dt>
<dd>{{ data.date }}</dd>
<dt>Total</dt>
<dd><strong>{{ "%.2f"|format(data.total) }} €</strong></dd>
{% if data.delivery_fee is not none %}
<dt>Frais de livraison</dt>
<dd>{{ "%.2f"|format(data.delivery_fee) }} €</dd>
{% endif %}
{% if data.order_id %}
<dt>Référence commande</dt>
<dd><code>{{ data.order_id }}</code></dd>
{% endif %}
</dl>
</article>
<!-- Articles -->
<article>
<h2>Articles ({{ data['items'] | length }})</h2>
<div class="overflow-auto">
<table>
<thead>
<tr>
<th>Nom brut</th>
<th>Nom normalisé</th>
<th>Catégorie</th>
<th>Qté</th>
<th>Unité</th>
<th>Prix unit.</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for item in data['items'] %}
<tr>
<td>{{ item.name_raw }}</td>
<td>
{% if item.name_normalized %}
<a href="/product/{{ item.name_normalized | urlquote }}">
{{ item.name_normalized }}
</a>
{% else %}
<em></em>
{% endif %}
</td>
<td>{{ item.category or "—" }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unit }}</td>
<td>{{ "%.2f"|format(item.unit_price) }} €</td>
<td>{{ "%.2f"|format(item.total_price) }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
<a href="/">← Retour à l'accueil</a>
{% endif %}
{% endblock %}