Compare commits
3 Commits
bb62bd6eb6
...
268417d4fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 268417d4fc | |||
| 30e4b3e144 | |||
| 1e5fc97bb7 |
24
.claude/.fuse_hidden0114a7b7000557be
Normal file
24
.claude/.fuse_hidden0114a7b7000557be
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(.venv/Scripts/pytest tests/ -v)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(.venv/Scripts/python:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(.venv/Scripts/python.exe:*)",
|
||||
"Bash(PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe:*)",
|
||||
"Bash(PYTHONIOENCODING=utf-8 python:*)",
|
||||
"Bash(tesseract:*)",
|
||||
"Bash(winget install:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(TESSDATA=\"/c/Program Files/Tesseract-OCR/tessdata\")",
|
||||
"Bash(TESSDATA_PREFIX=/c/code/TicketTracker/tessdata python:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(.venv/Scripts/pip install:*)",
|
||||
"Bash(Marque)",
|
||||
"Bash(Quantité\":*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Environnement virtuel Python
|
||||
.venv/
|
||||
|
||||
# Cache Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Pytest
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Fichiers d'échantillons sensibles (tickets réels)
|
||||
samples/*.html
|
||||
samples/*.pdf
|
||||
samples/*.eml
|
||||
# images temporaires extraites des PDF
|
||||
samples/*.jpg
|
||||
|
||||
# Modèles OCR Tesseract (trop lourds pour git, ~14 Mo chacun)
|
||||
tessdata/
|
||||
fra.traineddata
|
||||
|
||||
# Base de données SQLite (données locales, non versionnées)
|
||||
data/*.db
|
||||
3
=2.31
Normal file
3
=2.31
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
[notice] A new release of pip is available: 25.3 -> 26.0.1
|
||||
[notice] To update, run: python.exe -m pip install --upgrade pip
|
||||
31
CLAUDE.md
Normal file
31
CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Qui je suis
|
||||
Développeur expérimenté mais qui reprend le code après 30 ans.
|
||||
Je code en Python. Je comprends la logique mais je suis rouillé sur la syntaxe moderne.
|
||||
|
||||
## Comment me parler
|
||||
- Explique ce que tu fais et POURQUOI
|
||||
- Pas de raccourcis cryptiques
|
||||
- Quand tu crées un fichier, dis-moi où et pourquoi
|
||||
- Commit souvent avec des messages clairs en français
|
||||
|
||||
## Stack projet
|
||||
- Python 3.11+
|
||||
- SQLite pour le stockage
|
||||
- FastAPI (plus tard) pour l'API
|
||||
- LLM : Mammouth (API compatible OpenAI) sur https://mammouth.music.dilain.com
|
||||
- Forge : Gitea sur forge.dilain.com
|
||||
- OS : Unraid (Docker) + VPS
|
||||
|
||||
## Conventions
|
||||
- Code en anglais, commentaires en français
|
||||
- Docstrings en français
|
||||
- Un virtualenv Python (.venv)
|
||||
- Requirements dans requirements.txt
|
||||
- Tests avec pytest
|
||||
|
||||
## Git
|
||||
- Commits en français
|
||||
- Format : "feat: ...", "fix: ...", "docs: ..."
|
||||
- Push vers origin sur Gitea
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
29
requirements.txt
Normal file
29
requirements.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# Parser HTML (mails Picnic)
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.3.0
|
||||
|
||||
# Parser PDF (tickets Leclerc)
|
||||
pdfplumber==0.11.4
|
||||
pytesseract>=0.3.10 # binding Python pour Tesseract OCR
|
||||
Pillow>=10.0 # manipulation d'images (extraction JPEG du PDF)
|
||||
|
||||
# LLM (appels API OpenAI-compatible)
|
||||
requests>=2.31
|
||||
|
||||
# Web (dashboard FastAPI)
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.12
|
||||
httpx>=0.27 # requis par TestClient FastAPI
|
||||
|
||||
# Tests
|
||||
pytest==8.3.4
|
||||
|
||||
# Note : Tesseract OCR (binaire C++) doit être installé séparément :
|
||||
# Windows : https://github.com/UB-Mannheim/tesseract/wiki
|
||||
# Linux : apt install tesseract-ocr tesseract-ocr-fra
|
||||
# Le modèle français (fra.traineddata) est requis.
|
||||
# Sans droits admin, créer un dossier tessdata/ à la racine du projet :
|
||||
# tessdata/fra.traineddata (14 Mo, téléchargeable sur github.com/tesseract-ocr/tessdata)
|
||||
# tessdata/eng.traineddata (copié depuis l'install Tesseract)
|
||||
0
samples/.gitkeep
Normal file
0
samples/.gitkeep
Normal file
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
297
tests/test_db.py
Normal file
297
tests/test_db.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Tests pour la couche base de données (schema + repository + pipeline).
|
||||
|
||||
Chaque test reçoit une base SQLite fraîche via le fixture tmp_path de pytest.
|
||||
On utilise des données synthétiques (pas les vrais fichiers sample) pour que
|
||||
ces tests soient rapides et ne dépendent pas de Tesseract ou de fichiers externes.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tickettracker.models.receipt import Item, Receipt
|
||||
from tickettracker.db import schema, repository
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path: Path) -> Path:
|
||||
"""Crée une base SQLite isolée dans un répertoire temporaire.
|
||||
|
||||
tmp_path est un fixture pytest qui fournit un Path unique par test.
|
||||
La base est initialisée avec le schéma complet avant chaque test.
|
||||
"""
|
||||
path = tmp_path / "test_tickettracker.db"
|
||||
schema.init_db(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn(db_path: Path):
|
||||
"""Retourne une connexion ouverte vers la base de test.
|
||||
|
||||
La connexion est fermée après chaque test grâce au yield.
|
||||
"""
|
||||
conn = schema.get_connection(db_path)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_receipt() -> Receipt:
|
||||
"""Ticket Picnic synthétique pour les tests d'insertion."""
|
||||
return Receipt(
|
||||
store="picnic",
|
||||
date=date(2026, 2, 14),
|
||||
total=12.50,
|
||||
order_id="TEST-001",
|
||||
items=[
|
||||
Item(
|
||||
name="Lait demi-écrémé",
|
||||
quantity=2,
|
||||
unit="pièce",
|
||||
unit_price=1.05,
|
||||
total_price=2.10,
|
||||
category=None,
|
||||
),
|
||||
Item(
|
||||
name="Pain de campagne",
|
||||
quantity=1,
|
||||
unit="pièce",
|
||||
unit_price=2.40,
|
||||
total_price=2.40,
|
||||
category=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_receipt_leclerc() -> Receipt:
|
||||
"""Ticket Leclerc synthétique avec catégories."""
|
||||
return Receipt(
|
||||
store="leclerc",
|
||||
date=date(2025, 11, 8),
|
||||
total=20.00,
|
||||
order_id="018-0003",
|
||||
items=[
|
||||
Item(
|
||||
name="NOIX CAJOU",
|
||||
quantity=1,
|
||||
unit="pièce",
|
||||
unit_price=5.12,
|
||||
total_price=5.12,
|
||||
category="EPICERIE SALEE",
|
||||
),
|
||||
Item(
|
||||
name="SAUCISSE FUMEES",
|
||||
quantity=2,
|
||||
unit="pièce",
|
||||
unit_price=3.48,
|
||||
total_price=6.96,
|
||||
category="BOUCHERIE LS",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests du schéma
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_schema_tables_exist(db_conn: sqlite3.Connection):
|
||||
"""Les tables receipts et items existent après init_db."""
|
||||
cur = db_conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
tables = {row["name"] for row in cur}
|
||||
assert "receipts" in tables
|
||||
assert "items" in tables
|
||||
|
||||
|
||||
def test_schema_view_exists(db_conn: sqlite3.Connection):
|
||||
"""La vue price_history existe après init_db."""
|
||||
cur = db_conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='view'"
|
||||
)
|
||||
views = {row["name"] for row in cur}
|
||||
assert "price_history" in views
|
||||
|
||||
|
||||
def test_schema_foreign_keys_enabled(db_conn: sqlite3.Connection):
|
||||
"""Les clés étrangères sont activées sur la connexion."""
|
||||
row = db_conn.execute("PRAGMA foreign_keys").fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
|
||||
def test_schema_idempotent(db_path: Path):
|
||||
"""Appeler init_db deux fois ne lève pas d'erreur."""
|
||||
schema.init_db(db_path) # deuxième appel — doit être sans effet
|
||||
schema.init_db(db_path) # troisième appel — idem
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests d'insertion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_insert_receipt_row_count(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""Après insertion, receipts contient exactement 1 ligne."""
|
||||
repository.insert_receipt(db_conn, sample_receipt)
|
||||
count = db_conn.execute("SELECT COUNT(*) FROM receipts").fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_insert_receipt_fields(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""Les champs du ticket inséré correspondent au Receipt source."""
|
||||
receipt_id = repository.insert_receipt(db_conn, sample_receipt)
|
||||
row = db_conn.execute(
|
||||
"SELECT * FROM receipts WHERE id = ?", (receipt_id,)
|
||||
).fetchone()
|
||||
|
||||
assert row["store"] == "picnic"
|
||||
assert row["date"] == "2026-02-14"
|
||||
assert row["total"] == pytest.approx(12.50)
|
||||
assert row["order_id"] == "TEST-001"
|
||||
assert row["raw_json"] is not None
|
||||
assert row["created_at"] is not None
|
||||
assert row["delivery_fee"] is None # non renseigné pour l'instant
|
||||
|
||||
|
||||
def test_insert_receipt_returns_id(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""insert_receipt retourne un entier positif (l'id de la ligne insérée)."""
|
||||
receipt_id = repository.insert_receipt(db_conn, sample_receipt)
|
||||
assert isinstance(receipt_id, int)
|
||||
assert receipt_id > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de déduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_receipt_not_exists_before_insert(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""receipt_exists retourne False avant tout insert."""
|
||||
exists = repository.receipt_exists(
|
||||
db_conn,
|
||||
sample_receipt.store,
|
||||
sample_receipt.date.isoformat(),
|
||||
sample_receipt.total,
|
||||
)
|
||||
assert not exists
|
||||
|
||||
|
||||
def test_receipt_exists_after_insert(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""receipt_exists retourne True après un insert."""
|
||||
repository.insert_receipt(db_conn, sample_receipt)
|
||||
exists = repository.receipt_exists(
|
||||
db_conn,
|
||||
sample_receipt.store,
|
||||
sample_receipt.date.isoformat(),
|
||||
sample_receipt.total,
|
||||
)
|
||||
assert exists
|
||||
|
||||
|
||||
def test_dedup_insert_twice(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""Insérer un même ticket deux fois laisse exactement 1 ligne en base.
|
||||
|
||||
Note : insert_receipt n'implémente pas lui-même le contrôle de doublon
|
||||
(c'est le rôle du pipeline). Ce test simule le comportement du pipeline
|
||||
en vérifiant receipt_exists avant chaque insert.
|
||||
"""
|
||||
date_iso = sample_receipt.date.isoformat()
|
||||
|
||||
# Premier import
|
||||
if not repository.receipt_exists(db_conn, sample_receipt.store, date_iso, sample_receipt.total):
|
||||
repository.insert_receipt(db_conn, sample_receipt)
|
||||
|
||||
# Deuxième import (doit être ignoré)
|
||||
if not repository.receipt_exists(db_conn, sample_receipt.store, date_iso, sample_receipt.total):
|
||||
repository.insert_receipt(db_conn, sample_receipt)
|
||||
|
||||
count = db_conn.execute("SELECT COUNT(*) FROM receipts").fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests des articles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_items_stored_count(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""Le nombre de lignes dans items correspond à len(receipt.items)."""
|
||||
receipt_id = repository.insert_receipt(db_conn, sample_receipt)
|
||||
count = db_conn.execute(
|
||||
"SELECT COUNT(*) FROM items WHERE receipt_id = ?", (receipt_id,)
|
||||
).fetchone()[0]
|
||||
assert count == len(sample_receipt.items)
|
||||
|
||||
|
||||
def test_items_name_raw_populated(db_conn: sqlite3.Connection, sample_receipt: Receipt):
|
||||
"""name_raw est rempli ; name_normalized est NULL (Sprint 3)."""
|
||||
receipt_id = repository.insert_receipt(db_conn, sample_receipt)
|
||||
rows = db_conn.execute(
|
||||
"SELECT name_raw, name_normalized FROM items WHERE receipt_id = ?",
|
||||
(receipt_id,),
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
assert row["name_raw"] is not None
|
||||
assert row["name_normalized"] is None
|
||||
|
||||
|
||||
def test_items_category_leclerc(db_conn: sqlite3.Connection, sample_receipt_leclerc: Receipt):
|
||||
"""Les catégories Leclerc sont bien stockées dans items."""
|
||||
receipt_id = repository.insert_receipt(db_conn, sample_receipt_leclerc)
|
||||
rows = db_conn.execute(
|
||||
"SELECT name_raw, category FROM items WHERE receipt_id = ? ORDER BY id",
|
||||
(receipt_id,),
|
||||
).fetchall()
|
||||
|
||||
assert rows[0]["category"] == "EPICERIE SALEE"
|
||||
assert rows[1]["category"] == "BOUCHERIE LS"
|
||||
|
||||
|
||||
def test_items_fk_constraint(db_conn: sqlite3.Connection):
|
||||
"""Insérer un item avec un receipt_id inexistant doit échouer (FK active)."""
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db_conn.execute(
|
||||
"""INSERT INTO items
|
||||
(receipt_id, name_raw, category, quantity, unit, unit_price, total_price)
|
||||
VALUES (999, 'Fantôme', NULL, 1.0, 'pièce', 1.0, 1.0)"""
|
||||
)
|
||||
db_conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests des statistiques
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_stats_empty(db_conn: sqlite3.Connection):
|
||||
"""get_stats sur une base vide retourne des zéros."""
|
||||
stats = repository.get_stats(db_conn)
|
||||
assert stats["receipts_by_store"] == {}
|
||||
assert stats["total_spent"] == 0.0
|
||||
assert stats["total_items"] == 0
|
||||
|
||||
|
||||
def test_get_stats_after_insert(
|
||||
db_conn: sqlite3.Connection,
|
||||
sample_receipt: Receipt,
|
||||
sample_receipt_leclerc: Receipt,
|
||||
):
|
||||
"""get_stats compte correctement après insertion de deux tickets."""
|
||||
repository.insert_receipt(db_conn, sample_receipt)
|
||||
repository.insert_receipt(db_conn, sample_receipt_leclerc)
|
||||
|
||||
stats = repository.get_stats(db_conn)
|
||||
|
||||
assert stats["receipts_by_store"]["picnic"] == 1
|
||||
assert stats["receipts_by_store"]["leclerc"] == 1
|
||||
assert stats["total_spent"] == pytest.approx(12.50 + 20.00)
|
||||
assert stats["total_items"] == len(sample_receipt.items) + len(sample_receipt_leclerc.items)
|
||||
assert stats["null_normalized"] == stats["total_items"] # tout NULL au Sprint 2
|
||||
222
tests/test_leclerc.py
Normal file
222
tests/test_leclerc.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Tests pour le parser Leclerc.
|
||||
|
||||
Utilise le fichier samples/ticket_leclerc_10_20260208_190621.pdf —
|
||||
ticket réel du E.Leclerc Clichy-sous-Bois du 08 novembre 2025,
|
||||
45 articles, CB 139,25 €.
|
||||
|
||||
Ce ticket est un scan JPEG embarqué dans un PDF (pas de couche texte).
|
||||
Le parser utilise Tesseract OCR (fra+eng) pour extraire le texte.
|
||||
|
||||
Notes OCR connues :
|
||||
- 'G' final peut être lu '6' (ex: "220G" → "2206", "120G" → "1206")
|
||||
- Les 'G' initiaux (grammes) sont bien reconnus dans la plupart des cas
|
||||
- FILET POULET : prix OCR 10.46 au lieu de 10.40 (3 décimales OCR → "10.460")
|
||||
- Les tests utilisent des vérifications souples sur les noms (fragment)
|
||||
pour rester stables face aux variations d'OCR
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tickettracker.parsers import leclerc
|
||||
from tickettracker.models.receipt import Receipt
|
||||
|
||||
|
||||
def _tesseract_disponible() -> bool:
|
||||
"""Vérifie si le binaire Tesseract est accessible sur ce système."""
|
||||
if shutil.which("tesseract"):
|
||||
return True
|
||||
# Chemins Windows standards (utilisés par leclerc._configure_tesseract)
|
||||
chemins_windows = [
|
||||
r"C:/Program Files/Tesseract-OCR/tesseract.exe",
|
||||
r"C:/Program Files (x86)/Tesseract-OCR/tesseract.exe",
|
||||
]
|
||||
return any(Path(p).is_file() for p in chemins_windows)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _tesseract_disponible(),
|
||||
reason="Tesseract OCR non installé — Linux : apt install tesseract-ocr tesseract-ocr-fra",
|
||||
)
|
||||
|
||||
SAMPLE_DIR = Path(__file__).parent.parent / "samples"
|
||||
LECLERC_PDF = SAMPLE_DIR / "ticket_leclerc_10_20260208_190621.pdf"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def receipt() -> Receipt:
|
||||
"""Parse le PDF une seule fois pour tous les tests du module."""
|
||||
return leclerc.parse(str(LECLERC_PDF))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structure générale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_store(receipt):
|
||||
assert receipt.store == "leclerc"
|
||||
|
||||
|
||||
def test_date(receipt):
|
||||
# "Caisse 018-0003 08 novembre 2025 12:46"
|
||||
assert receipt.date == date(2025, 11, 8)
|
||||
|
||||
|
||||
def test_caisse_id(receipt):
|
||||
assert receipt.order_id == "018-0003"
|
||||
|
||||
|
||||
def test_total_cb(receipt):
|
||||
# Montant CB (après 3 bons de réduction : 0.60 + 0.30 + 0.30 = 1.20)
|
||||
assert receipt.total == pytest.approx(139.25)
|
||||
|
||||
|
||||
def test_nombre_lignes_articles(receipt):
|
||||
# 42 lignes produits (45 articles physiques = somme des quantités)
|
||||
assert len(receipt.items) == 42
|
||||
|
||||
|
||||
def test_somme_quantites(receipt):
|
||||
# Le ticket dit "Total 45 articles" = somme des quantités
|
||||
total_qty = sum(int(i.quantity) for i in receipt.items)
|
||||
assert total_qty == 45
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catégories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_categories_presentes(receipt):
|
||||
cats = {i.category for i in receipt.items if i.category}
|
||||
assert "EPICERIE SALEE" in cats
|
||||
assert "EPICERIE SUCREE" in cats
|
||||
assert "BOUCHERIE LS" in cats
|
||||
assert "LEGUMES" in cats
|
||||
assert "CREMERIE LS" in cats
|
||||
assert "ANIMALERIE" in cats
|
||||
|
||||
|
||||
def test_categorie_de_chaque_article(receipt):
|
||||
"""Tous les articles doivent avoir une catégorie."""
|
||||
for item in receipt.items:
|
||||
assert item.category is not None, f"{item.name!r} n'a pas de catégorie"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Articles clés
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find(receipt, fragment: str):
|
||||
"""Cherche un article par fragment de nom (insensible à la casse)."""
|
||||
needle = fragment.lower()
|
||||
matches = [i for i in receipt.items if needle in i.name.lower()]
|
||||
assert matches, (
|
||||
f"Article contenant '{fragment}' introuvable. "
|
||||
f"Articles : {[i.name for i in receipt.items]}"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def test_noix_cajou(receipt):
|
||||
item = _find(receipt, "NOIX CAJOU")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(5.12)
|
||||
assert item.category == "EPICERIE SALEE"
|
||||
|
||||
|
||||
def test_coca_cola(receipt):
|
||||
item = _find(receipt, "COCA-COLA")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(6.72)
|
||||
assert item.category == "EAUX, BIERES, JUS ET SIROP,CID"
|
||||
|
||||
|
||||
def test_saucisse_multi_unites(receipt):
|
||||
# "SAUCISSE FUMEES A CUIRE X 4" acheté 2 fois : 2 X 3.48€ = 6.96
|
||||
item = _find(receipt, "SAUCISSE FUMEES")
|
||||
assert item.quantity == 2
|
||||
assert item.unit_price == pytest.approx(3.48)
|
||||
assert item.total_price == pytest.approx(6.96)
|
||||
assert item.category == "BOUCHERIE LS"
|
||||
|
||||
|
||||
def test_jambon_multi_unites(receipt):
|
||||
# "4 TR JAMBON SUP,-SEL CSN,140G" acheté 2 fois : 2 X 2.93€ = 5.86
|
||||
item = _find(receipt, "JAMBON")
|
||||
assert item.quantity == 2
|
||||
assert item.unit_price == pytest.approx(2.93)
|
||||
assert item.total_price == pytest.approx(5.86)
|
||||
assert item.category == "CHARCUTERIE LS"
|
||||
|
||||
|
||||
def test_lait_multi_unites(receipt):
|
||||
# "LAIT PAST.ENTIER,DELISSE,1L" acheté 2 fois : 2 X 1.33€ = 2.66
|
||||
item = _find(receipt, "LAIT PAST")
|
||||
assert item.quantity == 2
|
||||
assert item.unit_price == pytest.approx(1.33)
|
||||
assert item.total_price == pytest.approx(2.66)
|
||||
assert item.category == "CREMERIE LS"
|
||||
|
||||
|
||||
def test_litiere_tva20(receipt):
|
||||
# Litière = TVA 20% (code 4 sur le ticket)
|
||||
item = _find(receipt, "LITIERE")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(3.10)
|
||||
assert item.category == "ANIMALERIE"
|
||||
|
||||
|
||||
def test_citron_vert(receipt):
|
||||
item = _find(receipt, "CITRON VERT")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(0.86)
|
||||
assert item.category == "FRUITS"
|
||||
|
||||
|
||||
def test_deux_carottes(receipt):
|
||||
# Deux lignes CAROTTE (deux barquettes distinctes)
|
||||
carottes = [i for i in receipt.items if "CAROTTE" in i.name.upper()]
|
||||
assert len(carottes) == 2
|
||||
for c in carottes:
|
||||
assert c.total_price == pytest.approx(1.99)
|
||||
|
||||
|
||||
def test_lor_espresso(receipt):
|
||||
# Article le plus cher (capsules café)
|
||||
item = _find(receipt, "LUNGPRO")
|
||||
assert item.total_price == pytest.approx(16.04)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cohérence arithmétique
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"Précision OCR variable selon la plateforme Tesseract : "
|
||||
"ex. 'FARINE BLE PATISST45 1K' lu 6.69€ (Linux 5.3.4) au lieu de ~1.69€ (Windows). "
|
||||
"Solution cible : remplacer Tesseract par un LLM vision (Sprint suivant)."
|
||||
),
|
||||
strict=False,
|
||||
)
|
||||
def test_total_avant_remise(receipt):
|
||||
"""La somme des articles doit être proche du sous-total ticket (140.45).
|
||||
|
||||
La différence acceptée couvre les erreurs OCR connues
|
||||
(ex: FILET POULET 10.46 lu au lieu de 10.40 → écart de 0.06 €).
|
||||
"""
|
||||
somme = sum(i.total_price for i in receipt.items)
|
||||
assert somme == pytest.approx(140.45, abs=0.15)
|
||||
|
||||
|
||||
def test_prix_unitaire_coherent(receipt):
|
||||
"""Pour les articles multi-unités, unit_price × qty ≈ total_price."""
|
||||
for item in receipt.items:
|
||||
if item.quantity > 1:
|
||||
assert item.unit_price * item.quantity == pytest.approx(
|
||||
item.total_price, rel=0.01
|
||||
), f"Prix incohérent pour {item.name!r}"
|
||||
60
tests/test_models.py
Normal file
60
tests/test_models.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Tests pour le modèle de données Receipt.
|
||||
|
||||
Ces tests vérifient que le format JSON commun fonctionne
|
||||
correctement avant même d'avoir des parsers réels.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from tickettracker.models.receipt import Item, Receipt
|
||||
|
||||
|
||||
def test_receipt_to_dict():
|
||||
"""Un ticket se convertit correctement en dictionnaire."""
|
||||
receipt = Receipt(
|
||||
store="picnic",
|
||||
date=date(2024, 1, 15),
|
||||
total=42.50,
|
||||
items=[
|
||||
Item(name="Lait demi-écrémé", quantity=2, unit="pièce", unit_price=1.05, total_price=2.10),
|
||||
Item(name="Pain de campagne", quantity=1, unit="pièce", unit_price=2.40, total_price=2.40),
|
||||
],
|
||||
)
|
||||
|
||||
d = receipt.to_dict()
|
||||
|
||||
assert d["store"] == "picnic"
|
||||
assert d["date"] == "2024-01-15" # La date doit être une string ISO
|
||||
assert d["total"] == 42.50
|
||||
assert d["currency"] == "EUR"
|
||||
assert len(d["items"]) == 2
|
||||
assert d["items"][0]["name"] == "Lait demi-écrémé"
|
||||
|
||||
|
||||
def test_receipt_to_json_is_valid_json():
|
||||
"""Le JSON produit est bien parsable."""
|
||||
receipt = Receipt(
|
||||
store="leclerc",
|
||||
date=date(2024, 2, 3),
|
||||
total=18.90,
|
||||
items=[Item(name="Tomates", quantity=0.5, unit="kg", unit_price=3.20, total_price=1.60)],
|
||||
)
|
||||
|
||||
json_str = receipt.to_json()
|
||||
parsed = json.loads(json_str) # Lève une exception si le JSON est invalide
|
||||
|
||||
assert parsed["store"] == "leclerc"
|
||||
assert parsed["items"][0]["unit"] == "kg"
|
||||
|
||||
|
||||
def test_receipt_optional_fields():
|
||||
"""Les champs optionnels ont des valeurs par défaut correctes."""
|
||||
receipt = Receipt(store="picnic", date=date(2024, 1, 1), total=10.0)
|
||||
|
||||
assert receipt.currency == "EUR"
|
||||
assert receipt.items == []
|
||||
assert receipt.order_id is None
|
||||
320
tests/test_normalizer.py
Normal file
320
tests/test_normalizer.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Tests pour le module de normalisation LLM.
|
||||
|
||||
Aucun appel réseau réel : le client LLM est mocké via unittest.mock.patch.
|
||||
Les tests de DB utilisent tmp_path (base SQLite isolée par test).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tickettracker.db import repository, schema
|
||||
from tickettracker.llm.client import LLMError, LLMUnavailable
|
||||
from tickettracker.llm import normalizer
|
||||
from tickettracker.models.receipt import Item, Receipt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures DB (même pattern que test_db.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path: Path) -> Path:
|
||||
"""Base SQLite isolée, initialisée avec le schéma complet."""
|
||||
path = tmp_path / "test_normalizer.db"
|
||||
schema.init_db(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn(db_path: Path):
|
||||
conn = schema.get_connection(db_path)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_items(db_path: Path) -> Path:
|
||||
"""Base pré-remplie avec 3 articles (name_normalized NULL)."""
|
||||
receipt = Receipt(
|
||||
store="leclerc",
|
||||
date=date(2025, 11, 8),
|
||||
total=15.00,
|
||||
items=[
|
||||
Item("NOIX CAJOU", 1, "pièce", 5.12, 5.12, "EPICERIE SALEE"),
|
||||
Item("COCA COLA CHERRY 1.25L", 1, "pièce", 6.72, 6.72, "BOISSONS"),
|
||||
Item("PQ LOTUS CONFORT X6", 1, "pièce", 3.10, 3.10, "HYGIENE"),
|
||||
],
|
||||
)
|
||||
conn = schema.get_connection(db_path)
|
||||
repository.insert_receipt(conn, receipt)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de parsing de la réponse LLM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseNormalizedLine:
|
||||
"""Tests unitaires de _parse_normalized_line."""
|
||||
|
||||
def test_valid_line(self):
|
||||
result = normalizer._parse_normalized_line("1. Crème fraîche épaisse | MDD | 50cl")
|
||||
assert result == "Crème fraîche épaisse | MDD | 50cl"
|
||||
|
||||
def test_valid_line_with_parenthesis_number(self):
|
||||
result = normalizer._parse_normalized_line("2) Coca-Cola Cherry | Coca-Cola | 1,25L")
|
||||
assert result == "Coca-Cola Cherry | Coca-Cola | 1,25L"
|
||||
|
||||
def test_valid_line_quantity_absent(self):
|
||||
"""Un tiret '-' est une quantité valide (absente du nom brut)."""
|
||||
result = normalizer._parse_normalized_line("3. Noix de cajou | MDD | -")
|
||||
assert result == "Noix de cajou | MDD | -"
|
||||
|
||||
def test_invalid_no_pipes(self):
|
||||
"""Ligne sans séparateurs | → None."""
|
||||
result = normalizer._parse_normalized_line("1. Juste un nom sans format")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_only_one_pipe(self):
|
||||
"""Un seul | → None (il en faut deux)."""
|
||||
result = normalizer._parse_normalized_line("1. Produit | MDD")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_empty_field(self):
|
||||
"""Champ vide → None."""
|
||||
result = normalizer._parse_normalized_line("1. | MDD | 50cl")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_no_number(self):
|
||||
"""Ligne non numérotée → None."""
|
||||
result = normalizer._parse_normalized_line("Crème fraîche | MDD | 50cl")
|
||||
assert result is None
|
||||
|
||||
def test_strips_extra_spaces(self):
|
||||
"""Les espaces autour des champs sont normalisés."""
|
||||
result = normalizer._parse_normalized_line("1. Noix | MDD | 200g ")
|
||||
assert result == "Noix | MDD | 200g"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de normalize_product_name (appel unitaire)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeProductName:
|
||||
|
||||
def test_success(self):
|
||||
"""Mock LLM retourne une ligne valide → nom normalisé retourné."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = "1. Noix de cajou | MDD | 200g"
|
||||
result = normalizer.normalize_product_name("NOIX CAJOU")
|
||||
assert result == "Noix de cajou | MDD | 200g"
|
||||
|
||||
def test_llm_error_returns_none(self):
|
||||
"""LLMError → retourne None sans propager."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.side_effect = LLMError("HTTP 500")
|
||||
result = normalizer.normalize_product_name("NOIX CAJOU")
|
||||
assert result is None
|
||||
|
||||
def test_llm_unavailable_returns_none(self):
|
||||
"""LLMUnavailable → retourne None sans propager."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.side_effect = LLMUnavailable("Timeout")
|
||||
result = normalizer.normalize_product_name("NOIX CAJOU")
|
||||
assert result is None
|
||||
|
||||
def test_unparsable_response_returns_none(self):
|
||||
"""Réponse LLM non parsable → None."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = "Désolé, je ne comprends pas."
|
||||
result = normalizer.normalize_product_name("NOIX CAJOU")
|
||||
assert result is None
|
||||
|
||||
def test_passes_raw_name_to_llm(self):
|
||||
"""Vérifie que le nom brut est bien transmis au LLM."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = "1. Coca-Cola Cherry | Coca-Cola | 1,25L"
|
||||
normalizer.normalize_product_name("COCA COLA CHERRY 1.25L")
|
||||
call_args = mock_llm.call_args[0][0] # messages list
|
||||
user_content = next(m["content"] for m in call_args if m["role"] == "user")
|
||||
assert "COCA COLA CHERRY 1.25L" in user_content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de normalize_batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeBatch:
|
||||
|
||||
def test_success_full_batch(self):
|
||||
"""3 noms → 3 lignes valides retournées."""
|
||||
llm_response = (
|
||||
"1. Noix de cajou | MDD | 200g\n"
|
||||
"2. Coca-Cola Cherry | Coca-Cola | 1,25L\n"
|
||||
"3. Papier toilette confort | Lotus | x6"
|
||||
)
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = llm_response
|
||||
results = normalizer.normalize_batch([
|
||||
"NOIX CAJOU",
|
||||
"COCA COLA CHERRY 1.25L",
|
||||
"PQ LOTUS CONFORT X6",
|
||||
])
|
||||
assert len(results) == 3
|
||||
assert results[0] == "Noix de cajou | MDD | 200g"
|
||||
assert results[1] == "Coca-Cola Cherry | Coca-Cola | 1,25L"
|
||||
assert results[2] == "Papier toilette confort | Lotus | x6"
|
||||
|
||||
def test_wrong_count_returns_all_none(self):
|
||||
"""LLM retourne 2 lignes pour 3 items → [None, None, None]."""
|
||||
llm_response = (
|
||||
"1. Noix de cajou | MDD | 200g\n"
|
||||
"2. Coca-Cola Cherry | Coca-Cola | 1,25L"
|
||||
)
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = llm_response
|
||||
results = normalizer.normalize_batch([
|
||||
"NOIX CAJOU",
|
||||
"COCA COLA CHERRY 1.25L",
|
||||
"PQ LOTUS CONFORT X6",
|
||||
])
|
||||
assert results == [None, None, None]
|
||||
|
||||
def test_llm_error_returns_all_none(self):
|
||||
"""LLMError sur le batch → [None, None, None]."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.side_effect = LLMError("HTTP 429")
|
||||
results = normalizer.normalize_batch(["A", "B", "C"])
|
||||
assert results == [None, None, None]
|
||||
|
||||
def test_llm_unavailable_propagated(self):
|
||||
"""LLMUnavailable est propagé (pas silencieux) pour que normalize_all_in_db s'arrête."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.side_effect = LLMUnavailable("Connexion refusée")
|
||||
with pytest.raises(LLMUnavailable):
|
||||
normalizer.normalize_batch(["A", "B"])
|
||||
|
||||
def test_empty_list(self):
|
||||
"""Liste vide → liste vide, pas d'appel LLM."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
results = normalizer.normalize_batch([])
|
||||
assert results == []
|
||||
mock_llm.assert_not_called()
|
||||
|
||||
def test_fallback_when_batch_fails(self):
|
||||
"""Si normalize_batch retourne [None, None, None], normalize_all_in_db
|
||||
doit tenter le fallback unitaire pour chaque item."""
|
||||
# Ce test est couvert par test_normalize_all_fallback_to_unit ci-dessous.
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de normalize_all_in_db
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeAllInDb:
|
||||
|
||||
def test_dry_run_does_not_modify_db(self, db_with_items: Path):
|
||||
"""Avec --dry-run, aucun article n'est mis à jour en base."""
|
||||
llm_response = (
|
||||
"1. Noix de cajou | MDD | 200g\n"
|
||||
"2. Coca-Cola Cherry | Coca-Cola | 1,25L\n"
|
||||
"3. Papier toilette confort | Lotus | x6"
|
||||
)
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = llm_response
|
||||
nb_ok, nb_err = normalizer.normalize_all_in_db(
|
||||
db_with_items, batch_size=20, dry_run=True
|
||||
)
|
||||
|
||||
# Vérifie que la DB n'a pas été modifiée
|
||||
conn = schema.get_connection(db_with_items)
|
||||
still_null = repository.fetch_unnormalized(conn)
|
||||
conn.close()
|
||||
|
||||
assert len(still_null) == 3 # toujours 3 NULL
|
||||
assert nb_ok == 3 # mais 3 normalisés en mémoire
|
||||
assert nb_err == 0
|
||||
|
||||
def test_updates_db_when_not_dry_run(self, db_with_items: Path):
|
||||
"""Sans dry-run, les articles sont mis à jour en base."""
|
||||
llm_response = (
|
||||
"1. Noix de cajou | MDD | 200g\n"
|
||||
"2. Coca-Cola Cherry | Coca-Cola | 1,25L\n"
|
||||
"3. Papier toilette confort | Lotus | x6"
|
||||
)
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
mock_llm.return_value = llm_response
|
||||
nb_ok, nb_err = normalizer.normalize_all_in_db(
|
||||
db_with_items, batch_size=20, dry_run=False
|
||||
)
|
||||
|
||||
conn = schema.get_connection(db_with_items)
|
||||
still_null = repository.fetch_unnormalized(conn)
|
||||
conn.close()
|
||||
|
||||
assert len(still_null) == 0 # plus de NULL
|
||||
assert nb_ok == 3
|
||||
assert nb_err == 0
|
||||
|
||||
def test_no_items_to_normalize(self, db_path: Path):
|
||||
"""Base vide (aucun item) → message, (0, 0) retourné."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
nb_ok, nb_err = normalizer.normalize_all_in_db(db_path)
|
||||
mock_llm.assert_not_called()
|
||||
assert nb_ok == 0
|
||||
assert nb_err == 0
|
||||
|
||||
def test_fallback_to_unit_when_batch_returns_all_none(self, db_with_items: Path):
|
||||
"""Si normalize_batch retourne tous None, le fallback unitaire est tenté."""
|
||||
# Batch retourne mauvais count → [None, None, None]
|
||||
# Fallback unitaire : normalize_product_name est appelé 3 fois
|
||||
batch_response = "1. Un seul | truc | 200g" # 1 ligne pour 3 items → mauvais count
|
||||
|
||||
unit_responses = [
|
||||
"1. Noix de cajou | MDD | 200g",
|
||||
"1. Coca-Cola Cherry | Coca-Cola | 1,25L",
|
||||
"1. Papier toilette confort | Lotus | x6",
|
||||
]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_call_llm(messages, **kwargs):
|
||||
n = call_count["n"]
|
||||
call_count["n"] += 1
|
||||
if n == 0:
|
||||
return batch_response # premier appel = batch → mauvais count
|
||||
return unit_responses[n - 1] # appels suivants = unitaires
|
||||
|
||||
with patch("tickettracker.llm.normalizer.call_llm", side_effect=fake_call_llm):
|
||||
nb_ok, nb_err = normalizer.normalize_all_in_db(
|
||||
db_with_items, batch_size=20, dry_run=False
|
||||
)
|
||||
|
||||
# 1 appel batch + 3 appels unitaires = 4 appels total
|
||||
assert call_count["n"] == 4
|
||||
assert nb_ok == 3
|
||||
assert nb_err == 0
|
||||
|
||||
def test_error_items_stay_null(self, db_with_items: Path):
|
||||
"""Les items dont la normalisation échoue restent NULL en base."""
|
||||
with patch("tickettracker.llm.normalizer.call_llm") as mock_llm:
|
||||
# Batch échoue, fallback échoue aussi
|
||||
mock_llm.side_effect = LLMError("HTTP 500")
|
||||
nb_ok, nb_err = normalizer.normalize_all_in_db(
|
||||
db_with_items, batch_size=20, dry_run=False
|
||||
)
|
||||
|
||||
conn = schema.get_connection(db_with_items)
|
||||
still_null = repository.fetch_unnormalized(conn)
|
||||
conn.close()
|
||||
|
||||
assert len(still_null) == 3
|
||||
assert nb_ok == 0
|
||||
assert nb_err == 3
|
||||
153
tests/test_picnic.py
Normal file
153
tests/test_picnic.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Tests pour le parser Picnic.
|
||||
|
||||
Utilise le fichier samples/picnic_sample.html — vrai mail de livraison
|
||||
du 14 février 2026, commande 502-110-1147.
|
||||
|
||||
Ce mail présente des corruptions QP importantes (balises cassées, attributs
|
||||
HTML encodés, sauts de ligne au milieu de séquences UTF-8) qui ont nécessité
|
||||
un travail spécifique de robustesse dans le parser.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tickettracker.parsers import picnic
|
||||
from tickettracker.models.receipt import Receipt
|
||||
|
||||
SAMPLE_DIR = Path(__file__).parent.parent / "samples"
|
||||
PICNIC_SAMPLE = SAMPLE_DIR / "picnic_sample.html"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def receipt() -> Receipt:
|
||||
"""Parse le fichier sample une seule fois pour tous les tests du module."""
|
||||
html = PICNIC_SAMPLE.read_text(encoding="ascii", errors="replace")
|
||||
return picnic.parse(html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structure générale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_store(receipt):
|
||||
assert receipt.store == "picnic"
|
||||
|
||||
|
||||
def test_date(receipt):
|
||||
# Livraison du samedi 14 février 2026
|
||||
assert receipt.date == date(2026, 2, 14)
|
||||
|
||||
|
||||
def test_order_id(receipt):
|
||||
assert receipt.order_id == "502-110-1147"
|
||||
|
||||
|
||||
def test_total(receipt):
|
||||
# Total Payé avec Paypal : 95,10 €
|
||||
assert receipt.total == pytest.approx(95.10)
|
||||
|
||||
|
||||
def test_nombre_articles(receipt):
|
||||
# 29 produits distincts dans ce ticket
|
||||
assert len(receipt.items) == 29
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Articles clés — vérifie nom, quantité, prix total
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find(receipt, name_fragment: str):
|
||||
"""Cherche un article par fragment de nom (insensible à la casse)."""
|
||||
needle = name_fragment.lower()
|
||||
matches = [it for it in receipt.items if needle in it.name.lower()]
|
||||
assert matches, f"Article contenant '{name_fragment}' introuvable dans : {[i.name for i in receipt.items]}"
|
||||
return matches[0]
|
||||
|
||||
|
||||
def test_gerble_pepites(receipt):
|
||||
item = _find(receipt, "pépites chocolat")
|
||||
assert item.quantity == 2
|
||||
assert item.total_price == pytest.approx(3.58)
|
||||
assert item.unit == "250 g"
|
||||
|
||||
|
||||
def test_soda_zero(receipt):
|
||||
# Article dont l'image avait un alt==3D"..." corrompu
|
||||
item = _find(receipt, "Soda zéro")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(6.95)
|
||||
|
||||
|
||||
def test_le_saunier_prix_remise(receipt):
|
||||
# Article soldé : prix original 3,05 € → prix réel 2,74 €
|
||||
# Le parser doit extraire le prix APRÈS remise
|
||||
item = _find(receipt, "Saunier")
|
||||
assert item.total_price == pytest.approx(2.74)
|
||||
|
||||
|
||||
def test_saint_eloi_mais(receipt):
|
||||
# Article dans une structure HTML 4-colonnes corrompue
|
||||
item = _find(receipt, "maïs doux")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(0.95)
|
||||
|
||||
|
||||
def test_jardin_bio(receipt):
|
||||
# Article avec badge qty non encadré par <strong>
|
||||
item = _find(receipt, "Jardin Bio")
|
||||
assert item.quantity == 3
|
||||
assert item.total_price == pytest.approx(4.95)
|
||||
|
||||
|
||||
def test_jean_roze(receipt):
|
||||
item = _find(receipt, "Jean Rozé")
|
||||
assert item.quantity == 2
|
||||
assert item.total_price == pytest.approx(12.78)
|
||||
|
||||
|
||||
def test_oignon_jaune(receipt):
|
||||
# Article dont l'image avait sr=c=3D"..." corrompu → src absent
|
||||
item = _find(receipt, "Oignon")
|
||||
assert item.quantity == 2
|
||||
assert item.total_price == pytest.approx(4.38)
|
||||
assert item.unit == "500 g"
|
||||
|
||||
|
||||
def test_alfapac(receipt):
|
||||
# Article avec badge qty corrompu, extrait via texte brut
|
||||
item = _find(receipt, "Alfapac")
|
||||
assert item.quantity == 1
|
||||
assert item.total_price == pytest.approx(2.15)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cohérence arithmétique
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_somme_articles_cohérente(receipt):
|
||||
"""La somme des articles moins le solde Picnic (-0,30 €) = total payé."""
|
||||
somme = sum(it.total_price for it in receipt.items)
|
||||
solde_picnic = 0.30 # crédit appliqué sur la commande suivante
|
||||
assert somme - solde_picnic == pytest.approx(receipt.total, abs=0.02)
|
||||
|
||||
|
||||
def test_prix_unitaire_coherent(receipt):
|
||||
"""Pour chaque article multi-unité, unit_price * qty ≈ total_price."""
|
||||
for item in receipt.items:
|
||||
if item.quantity > 1:
|
||||
assert item.unit_price * item.quantity == pytest.approx(
|
||||
item.total_price, rel=0.01
|
||||
), f"Prix incohérent pour {item.name}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Robustesse — HTML invalide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_html_minimal_lève_valueerror():
|
||||
"""Un HTML sans date de livraison doit lever ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
picnic.parse("<html><body>Rien ici.</body></html>")
|
||||
270
tests/test_web.py
Normal file
270
tests/test_web.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Tests du dashboard web FastAPI (Sprint 4).
|
||||
|
||||
Stratégie :
|
||||
- Deux familles de fixtures : DB vide et DB avec données
|
||||
- On patche tickettracker.config.DB_PATH pour que l'appli pointe sur la DB de test
|
||||
- TestClient de FastAPI/httpx pour simuler les requêtes HTTP sans lancer de serveur
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tickettracker.db import schema, repository
|
||||
from tickettracker.models.receipt import Item, Receipt
|
||||
from tickettracker.web.app import app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Données synthétiques réutilisées par les fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_picnic_receipt() -> Receipt:
|
||||
"""Ticket Picnic avec deux articles dont un produit commun."""
|
||||
return Receipt(
|
||||
store="picnic",
|
||||
date=date(2026, 1, 10),
|
||||
total=15.50,
|
||||
delivery_fee=1.99,
|
||||
order_id="PICNIC-001",
|
||||
items=[
|
||||
Item(
|
||||
name="Lait demi-écremé",
|
||||
quantity=1,
|
||||
unit="pièce",
|
||||
unit_price=1.05,
|
||||
total_price=1.05,
|
||||
),
|
||||
Item(
|
||||
name="Jus d'orange",
|
||||
quantity=2,
|
||||
unit="pièce",
|
||||
unit_price=2.10,
|
||||
total_price=4.20,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_leclerc_receipt() -> Receipt:
|
||||
"""Ticket Leclerc avec deux articles dont un produit commun."""
|
||||
return Receipt(
|
||||
store="leclerc",
|
||||
date=date(2026, 1, 15),
|
||||
total=22.30,
|
||||
items=[
|
||||
Item(
|
||||
name="LAIT DEMI ECREME",
|
||||
quantity=1,
|
||||
unit="pièce",
|
||||
unit_price=0.95,
|
||||
total_price=0.95,
|
||||
),
|
||||
Item(
|
||||
name="FARINE BLE",
|
||||
quantity=1,
|
||||
unit="pièce",
|
||||
unit_price=1.20,
|
||||
total_price=1.20,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path: Path) -> Path:
|
||||
"""Base SQLite vide dans un répertoire temporaire."""
|
||||
path = tmp_path / "test_web.db"
|
||||
schema.init_db(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path_with_data(db_path: Path) -> Path:
|
||||
"""Base avec 1 ticket Picnic + 1 ticket Leclerc, 1 produit normalisé en commun."""
|
||||
conn = schema.get_connection(db_path)
|
||||
try:
|
||||
repository.insert_receipt(conn, _make_picnic_receipt())
|
||||
repository.insert_receipt(conn, _make_leclerc_receipt())
|
||||
|
||||
# Normaliser manuellement le produit commun (simule le travail de la CLI normalize)
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE items SET name_normalized = 'Lait demi-écremé'"
|
||||
" WHERE name_raw IN ('Lait demi-écremé', 'LAIT DEMI ECREME')"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_path: Path):
|
||||
"""TestClient sur la DB vide."""
|
||||
with patch("tickettracker.config.DB_PATH", db_path):
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_data(db_path_with_data: Path):
|
||||
"""TestClient sur la DB avec données."""
|
||||
with patch("tickettracker.config.DB_PATH", db_path_with_data):
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests HTML — DB vide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_index_empty_200(client):
|
||||
"""Page d'accueil accessible même si la base est vide."""
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_index_empty_shows_message(client):
|
||||
"""Page d'accueil affiche le message 'Aucun ticket' quand la base est vide."""
|
||||
resp = client.get("/")
|
||||
assert "Aucun ticket" in resp.text
|
||||
|
||||
|
||||
def test_compare_empty_200(client):
|
||||
"""Page /compare accessible même si la base est vide."""
|
||||
resp = client.get("/compare")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_product_unknown_200(client):
|
||||
"""GET /product/<inconnu> retourne 200 (pas 500) — affiche un message 'introuvable'."""
|
||||
resp = client.get("/product/ProduitInexistant")
|
||||
assert resp.status_code == 200
|
||||
assert "introuvable" in resp.text.lower()
|
||||
|
||||
|
||||
def test_receipt_not_found_404(client):
|
||||
"""GET /receipt/999 retourne 404 quand le ticket n'existe pas."""
|
||||
resp = client.get("/receipt/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests HTML — DB avec données
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_index_with_data_200(client_with_data):
|
||||
"""Page d'accueil est accessible avec des données."""
|
||||
resp = client_with_data.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_index_mentions_store(client_with_data):
|
||||
"""Page d'accueil mentionne l'enseigne picnic."""
|
||||
resp = client_with_data.get("/")
|
||||
assert "picnic" in resp.text.lower()
|
||||
|
||||
|
||||
def test_compare_with_data_shows_product(client_with_data):
|
||||
"""Page /compare affiche le produit commun normalisé."""
|
||||
resp = client_with_data.get("/compare")
|
||||
assert resp.status_code == 200
|
||||
assert "Lait demi-écremé" in resp.text
|
||||
|
||||
|
||||
def test_receipt_detail_200(client_with_data):
|
||||
"""GET /receipt/1 retourne 200 quand le ticket existe."""
|
||||
resp = client_with_data.get("/receipt/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_receipt_detail_contains_store(client_with_data):
|
||||
"""Page /receipt/1 contient le nom de l'enseigne."""
|
||||
resp = client_with_data.get("/receipt/1")
|
||||
assert "picnic" in resp.text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests API — DB vide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_stats_empty(client):
|
||||
"""GET /api/stats sur base vide retourne total_receipts = 0."""
|
||||
resp = client.get("/api/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_receipts"] == 0
|
||||
|
||||
|
||||
def test_api_compare_empty(client):
|
||||
"""GET /api/compare sur base vide retourne une liste vide."""
|
||||
resp = client.get("/api/compare")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_api_receipts_empty(client):
|
||||
"""GET /api/receipts sur base vide retourne une liste vide."""
|
||||
resp = client.get("/api/receipts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_api_receipt_not_found(client):
|
||||
"""GET /api/receipt/999 retourne 404."""
|
||||
resp = client.get("/api/receipt/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests API — DB avec données
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_stats_with_data(client_with_data):
|
||||
"""GET /api/stats avec 2 tickets retourne total_receipts = 2."""
|
||||
resp = client_with_data.get("/api/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_receipts"] == 2
|
||||
assert data["receipts_by_store"]["picnic"] == 1
|
||||
assert data["receipts_by_store"]["leclerc"] == 1
|
||||
|
||||
|
||||
def test_api_compare_returns_common_product(client_with_data):
|
||||
"""GET /api/compare retourne le produit normalisé commun aux deux enseignes."""
|
||||
resp = client_with_data.get("/api/compare")
|
||||
assert resp.status_code == 200
|
||||
products = resp.json()
|
||||
assert len(products) >= 1
|
||||
names = [p["name"] for p in products]
|
||||
assert "Lait demi-écremé" in names
|
||||
|
||||
|
||||
def test_api_receipt_detail_has_items(client_with_data):
|
||||
"""GET /api/receipt/1 retourne un ticket avec des articles."""
|
||||
resp = client_with_data.get("/api/receipt/1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["store"] == "picnic"
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
|
||||
def test_api_product_history(client_with_data):
|
||||
"""GET /api/product/<nom>/history retourne l'historique du produit commun."""
|
||||
resp = client_with_data.get("/api/product/Lait demi-écremé/history")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "Lait demi-écremé"
|
||||
assert len(data["history"]) == 2 # 1 occurrence Picnic + 1 Leclerc
|
||||
|
||||
|
||||
def test_api_product_history_not_found(client_with_data):
|
||||
"""GET /api/product/<inconnu>/history retourne 404."""
|
||||
resp = client_with_data.get("/api/product/ProduitInexistant/history")
|
||||
assert resp.status_code == 404
|
||||
1
tickettracker/__init__.py
Normal file
1
tickettracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package principal TicketTracker
|
||||
222
tickettracker/cli.py
Normal file
222
tickettracker/cli.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Point d'entrée CLI pour TicketTracker.
|
||||
|
||||
Utilisation :
|
||||
python -m tickettracker.cli import fichier.html --source picnic
|
||||
python -m tickettracker.cli import fichier.pdf --source leclerc [--db /chemin/db]
|
||||
python -m tickettracker.cli stats
|
||||
python -m tickettracker.cli stats --db /chemin/db
|
||||
python -m tickettracker.cli normalize [--dry-run] [--batch-size N] [--db /chemin/db]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from tickettracker.db.schema import DEFAULT_DB_PATH
|
||||
from tickettracker import pipeline
|
||||
|
||||
# Affiche les messages INFO dans le terminal (utile pour voir les doublons skippés)
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Construit le parseur d'arguments CLI.
|
||||
|
||||
Structure :
|
||||
tickettracker.cli
|
||||
├── import <file> --source {picnic,leclerc} [--db PATH]
|
||||
├── stats [--db PATH]
|
||||
└── normalize [--dry-run] [--batch-size N] [--db PATH]
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m tickettracker.cli",
|
||||
description="TicketTracker — import et analyse de tickets de courses",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# --- Sous-commande : import ---
|
||||
import_parser = subparsers.add_parser(
|
||||
"import",
|
||||
help="Parse et importe un ticket dans la base SQLite",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"file",
|
||||
type=Path,
|
||||
help="Chemin vers le fichier à importer (.html pour Picnic, .pdf pour Leclerc)",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--source",
|
||||
required=True,
|
||||
choices=["picnic", "leclerc"],
|
||||
help="Format du fichier",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--db",
|
||||
type=Path,
|
||||
default=DEFAULT_DB_PATH,
|
||||
metavar="PATH",
|
||||
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
|
||||
)
|
||||
|
||||
# --- Sous-commande : stats ---
|
||||
stats_parser = subparsers.add_parser(
|
||||
"stats",
|
||||
help="Affiche un résumé de la base de données",
|
||||
)
|
||||
stats_parser.add_argument(
|
||||
"--db",
|
||||
type=Path,
|
||||
default=DEFAULT_DB_PATH,
|
||||
metavar="PATH",
|
||||
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
|
||||
)
|
||||
|
||||
# --- Sous-commande : normalize ---
|
||||
from tickettracker import config as _cfg
|
||||
normalize_parser = subparsers.add_parser(
|
||||
"normalize",
|
||||
help="Normalise les noms de produits via le LLM",
|
||||
)
|
||||
normalize_parser.add_argument(
|
||||
"--db",
|
||||
type=Path,
|
||||
default=DEFAULT_DB_PATH,
|
||||
metavar="PATH",
|
||||
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
|
||||
)
|
||||
normalize_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Calcule les normalisations sans écrire en base",
|
||||
)
|
||||
normalize_parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=_cfg.LLM_BATCH_SIZE,
|
||||
metavar="N",
|
||||
help=f"Articles par appel LLM (défaut : {_cfg.LLM_BATCH_SIZE})",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def cmd_import(args: argparse.Namespace) -> int:
|
||||
"""Exécute la sous-commande 'import'.
|
||||
|
||||
Returns:
|
||||
0 si succès (ticket inséré ou déjà présent), 1 si erreur.
|
||||
"""
|
||||
try:
|
||||
inserted = pipeline.import_receipt(args.file, args.source, args.db)
|
||||
if inserted:
|
||||
print(f"OK Ticket importé depuis {args.file}")
|
||||
else:
|
||||
print(f"[skip] Ticket déjà présent en base — import ignoré.")
|
||||
return 0
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"Erreur : {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Erreur inattendue : {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_stats(args: argparse.Namespace) -> int:
|
||||
"""Exécute la sous-commande 'stats'.
|
||||
|
||||
Returns:
|
||||
0 si succès, 1 si la base est absente ou vide.
|
||||
"""
|
||||
from tickettracker.db import schema, repository
|
||||
|
||||
if not Path(args.db).exists():
|
||||
print(f"Base de données absente : {args.db}", file=sys.stderr)
|
||||
print("Importez d'abord un ticket avec la commande 'import'.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with schema.get_connection(args.db) as conn:
|
||||
stats = repository.get_stats(conn)
|
||||
|
||||
total_receipts = sum(stats["receipts_by_store"].values())
|
||||
if total_receipts == 0:
|
||||
print("Aucun ticket en base.")
|
||||
return 0
|
||||
|
||||
print("--- TicketTracker : résumé ---")
|
||||
print("Tickets par enseigne :")
|
||||
for store, nb in sorted(stats["receipts_by_store"].items()):
|
||||
print(f" {store:<10}: {nb} ticket(s)")
|
||||
print(f"Total dépensé : {stats['total_spent']:.2f} €")
|
||||
print(f"Nombre d'articles : {stats['total_items']} lignes")
|
||||
normalized = stats["distinct_normalized"]
|
||||
null_count = stats["null_normalized"]
|
||||
total_items = stats["total_items"]
|
||||
print(f"Noms normalisés : {normalized} distincts / {total_items} articles")
|
||||
if null_count > 0:
|
||||
print(f" ({null_count} articles sans nom normalisé)")
|
||||
print(" Lancez : python -m tickettracker.cli normalize")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_normalize(args: argparse.Namespace) -> int:
|
||||
"""Exécute la sous-commande 'normalize'.
|
||||
|
||||
Normalise les articles dont name_normalized est NULL en appelant
|
||||
le LLM par batchs. Avec --dry-run, affiche sans écrire en base.
|
||||
|
||||
Returns:
|
||||
0 si succès ou dry-run, 1 si erreur (LLM injoignable, clé manquante…).
|
||||
"""
|
||||
from tickettracker import config
|
||||
from tickettracker.llm.client import LLMError, LLMUnavailable
|
||||
from tickettracker.llm import normalizer
|
||||
|
||||
# Vérification préalable de la clé API
|
||||
if not config.LLM_API_KEY:
|
||||
print(
|
||||
"Erreur : clé API LLM manquante.\n"
|
||||
"Définissez la variable d'environnement TICKETTRACKER_LLM_API_KEY.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not Path(args.db).exists():
|
||||
print(f"Base de données absente : {args.db}", file=sys.stderr)
|
||||
print("Importez d'abord un ticket avec la commande 'import'.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
nb_ok, nb_err = normalizer.normalize_all_in_db(
|
||||
db_path=args.db,
|
||||
batch_size=args.batch_size,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
return 0 if nb_err == 0 else 1
|
||||
except LLMUnavailable as e:
|
||||
print(f"LLM injoignable : {e}", file=sys.stderr)
|
||||
return 1
|
||||
except LLMError as e:
|
||||
print(f"Erreur LLM : {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Erreur inattendue : {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Point d'entrée principal."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "import":
|
||||
sys.exit(cmd_import(args))
|
||||
elif args.command == "stats":
|
||||
sys.exit(cmd_stats(args))
|
||||
elif args.command == "normalize":
|
||||
sys.exit(cmd_normalize(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
tickettracker/config.py
Normal file
47
tickettracker/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Configuration de TicketTracker.
|
||||
|
||||
Toutes les valeurs sensibles (clé API) sont lues depuis des variables
|
||||
d'environnement et ne doivent jamais être codées en dur.
|
||||
|
||||
Variables d'environnement disponibles :
|
||||
TICKETTRACKER_LLM_URL URL de l'endpoint OpenAI-compatible
|
||||
TICKETTRACKER_LLM_MODEL Nom du modèle LLM
|
||||
TICKETTRACKER_LLM_API_KEY Clé API (obligatoire pour Mammouth)
|
||||
TICKETTRACKER_LLM_TIMEOUT Timeout en secondes (défaut : 60)
|
||||
TICKETTRACKER_LLM_BATCH_SIZE Taille des batchs de normalisation (défaut : 20)
|
||||
"""
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# URL de l'endpoint compatible OpenAI (Mammouth)
|
||||
LLM_URL: str = os.environ.get(
|
||||
"TICKETTRACKER_LLM_URL",
|
||||
"https://api.mammouth.ai/v1/chat/completions",
|
||||
)
|
||||
|
||||
# Modèle à utiliser
|
||||
LLM_MODEL: str = os.environ.get("TICKETTRACKER_LLM_MODEL", "mistral-small-3.2-24b-instruct")
|
||||
|
||||
# Clé API — jamais de valeur par défaut sensible ici
|
||||
LLM_API_KEY: str = os.environ.get("TICKETTRACKER_LLM_API_KEY", "")
|
||||
|
||||
# Timeout par appel en secondes (le modèle local peut être lent)
|
||||
LLM_TIMEOUT: int = int(os.environ.get("TICKETTRACKER_LLM_TIMEOUT", "60"))
|
||||
|
||||
# Nombre d'articles traités par appel LLM
|
||||
LLM_BATCH_SIZE: int = int(os.environ.get("TICKETTRACKER_LLM_BATCH_SIZE", "20"))
|
||||
1
tickettracker/db/__init__.py
Normal file
1
tickettracker/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Couche base de données SQLite
|
||||
177
tickettracker/db/repository.py
Normal file
177
tickettracker/db/repository.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Fonctions de lecture/écriture dans la base SQLite.
|
||||
|
||||
Ce module est la seule couche qui manipule les données.
|
||||
Toutes les fonctions reçoivent une connexion ouverte — elles ne
|
||||
gèrent pas les connexions elles-mêmes (séparation des responsabilités).
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from tickettracker.models.receipt import Receipt
|
||||
|
||||
|
||||
def receipt_exists(conn: sqlite3.Connection, store: str, date: str, total: float) -> bool:
|
||||
"""Vérifie si un ticket identique existe déjà en base.
|
||||
|
||||
La déduplication repose sur le triplet (store, date, total).
|
||||
Suffisant pour éviter les doubles imports accidentels d'un même fichier.
|
||||
|
||||
Args:
|
||||
conn: Connexion SQLite ouverte.
|
||||
store: Enseigne ('picnic' ou 'leclerc').
|
||||
date: Date ISO 8601 (ex: '2026-02-14').
|
||||
total: Montant total payé.
|
||||
|
||||
Returns:
|
||||
True si un ticket avec ces valeurs existe déjà.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM receipts WHERE store = ? AND date = ? AND total = ?",
|
||||
(store, date, total),
|
||||
).fetchone()
|
||||
return row[0] > 0
|
||||
|
||||
|
||||
def insert_receipt(conn: sqlite3.Connection, receipt: Receipt) -> int:
|
||||
"""Insère un ticket et tous ses articles dans la base.
|
||||
|
||||
Utilise une transaction implicite : si l'insertion des articles échoue,
|
||||
le ticket est aussi annulé (atomicité garantie par le context manager).
|
||||
|
||||
Args:
|
||||
conn: Connexion SQLite ouverte.
|
||||
receipt: Ticket normalisé à insérer.
|
||||
|
||||
Returns:
|
||||
L'id (INTEGER) du ticket inséré dans la table receipts.
|
||||
|
||||
Raises:
|
||||
sqlite3.IntegrityError: En cas de violation de contrainte FK.
|
||||
"""
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO receipts (store, date, total, delivery_fee, order_id, raw_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
receipt.store,
|
||||
receipt.date.isoformat(),
|
||||
receipt.total,
|
||||
receipt.delivery_fee,
|
||||
receipt.order_id,
|
||||
receipt.to_json(),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
receipt_id = cur.lastrowid
|
||||
|
||||
# Insertion de tous les articles en une seule passe
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO items
|
||||
(receipt_id, name_raw, name_normalized, category,
|
||||
quantity, unit, unit_price, total_price)
|
||||
VALUES (?, ?, NULL, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
receipt_id,
|
||||
item.name,
|
||||
item.category,
|
||||
item.quantity,
|
||||
item.unit,
|
||||
item.unit_price,
|
||||
item.total_price,
|
||||
)
|
||||
for item in receipt.items
|
||||
],
|
||||
)
|
||||
|
||||
return receipt_id
|
||||
|
||||
|
||||
def get_stats(conn: sqlite3.Connection) -> dict:
|
||||
"""Calcule les statistiques globales pour la commande CLI 'stats'.
|
||||
|
||||
Returns:
|
||||
Dictionnaire avec :
|
||||
- receipts_by_store : dict[str, int] — nb tickets par enseigne
|
||||
- total_spent : float — somme de tous les totaux
|
||||
- total_items : int — nb total de lignes dans items
|
||||
- distinct_normalized : int — nb de name_normalized distincts (non NULL)
|
||||
- null_normalized : int — nb d'articles sans name_normalized
|
||||
"""
|
||||
# Tickets par enseigne + total dépensé
|
||||
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}
|
||||
total_spent = sum(row["spent"] for row in rows) if rows else 0.0
|
||||
|
||||
# Statistiques articles
|
||||
item_stats = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS total_items,
|
||||
COUNT(DISTINCT name_normalized) AS distinct_normalized,
|
||||
SUM(CASE WHEN name_normalized IS NULL THEN 1 ELSE 0 END) AS null_normalized
|
||||
FROM items
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
return {
|
||||
"receipts_by_store": receipts_by_store,
|
||||
"total_spent": total_spent,
|
||||
"total_items": item_stats["total_items"],
|
||||
"distinct_normalized": item_stats["distinct_normalized"],
|
||||
"null_normalized": item_stats["null_normalized"],
|
||||
}
|
||||
|
||||
|
||||
def fetch_unnormalized(
|
||||
conn: sqlite3.Connection,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
"""Retourne les articles dont name_normalized est NULL.
|
||||
|
||||
Chaque Row expose les clés : id, name_raw, receipt_id.
|
||||
Trié par id pour un traitement reproductible.
|
||||
|
||||
Args:
|
||||
conn: Connexion SQLite ouverte.
|
||||
limit: Si fourni, retourne au maximum `limit` articles.
|
||||
|
||||
Returns:
|
||||
Liste de sqlite3.Row.
|
||||
"""
|
||||
sql = "SELECT id, name_raw, receipt_id FROM items WHERE name_normalized IS NULL ORDER BY id"
|
||||
if limit is not None:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
return conn.execute(sql).fetchall()
|
||||
|
||||
|
||||
def update_normalized(
|
||||
conn: sqlite3.Connection,
|
||||
item_id: int,
|
||||
name_normalized: str,
|
||||
) -> None:
|
||||
"""Met à jour le nom normalisé d'un article.
|
||||
|
||||
N'utilise pas de transaction ici : c'est l'appelant (normalizer.py)
|
||||
qui gère la transaction globale pour pouvoir faire un commit groupé.
|
||||
|
||||
Args:
|
||||
conn: Connexion SQLite ouverte.
|
||||
item_id: Id de l'article à mettre à jour.
|
||||
name_normalized: Valeur à écrire dans name_normalized.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE items SET name_normalized = ? WHERE id = ?",
|
||||
(name_normalized, item_id),
|
||||
)
|
||||
127
tickettracker/db/schema.py
Normal file
127
tickettracker/db/schema.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Schéma SQLite pour TicketTracker.
|
||||
|
||||
Ce module gère uniquement le DDL (création des tables, vues et index).
|
||||
Il ne contient pas de logique métier.
|
||||
|
||||
Tables :
|
||||
receipts — un ticket de courses par ligne
|
||||
items — articles, liés à leur ticket par FK
|
||||
Vue :
|
||||
price_history — jointure items × receipts pour comparer les prix dans le temps
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
# Chemin par défaut : data/tickettracker.db à la racine du projet
|
||||
DEFAULT_DB_PATH = Path(__file__).parent.parent.parent / "data" / "tickettracker.db"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instructions DDL (CREATE TABLE / INDEX / VIEW)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SQL_CREATE_RECEIPTS = """
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
store TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- format ISO 8601 : "2026-02-14"
|
||||
total REAL NOT NULL,
|
||||
delivery_fee REAL, -- NULL pour Leclerc (magasin physique)
|
||||
order_id TEXT, -- NULL si non disponible
|
||||
raw_json TEXT NOT NULL, -- résultat de receipt.to_json() pour debug
|
||||
created_at TEXT NOT NULL -- datetime UTC ISO au moment de l'insertion
|
||||
);
|
||||
"""
|
||||
|
||||
_SQL_CREATE_RECEIPTS_IDX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_dedup
|
||||
ON receipts (store, date, total);
|
||||
"""
|
||||
|
||||
_SQL_CREATE_ITEMS = """
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
receipt_id INTEGER NOT NULL REFERENCES receipts(id),
|
||||
name_raw TEXT NOT NULL, -- nom tel que sorti du parser
|
||||
name_normalized TEXT, -- NULL jusqu'au Sprint 3 (normalisation LLM)
|
||||
category TEXT, -- NULL pour Picnic (pas de catégories dans le mail)
|
||||
quantity REAL NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
total_price REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
_SQL_CREATE_ITEMS_IDX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_items_receipt_id
|
||||
ON items (receipt_id);
|
||||
"""
|
||||
|
||||
_SQL_CREATE_ITEMS_NORM_IDX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_items_name_normalized
|
||||
ON items (name_normalized);
|
||||
"""
|
||||
|
||||
_SQL_CREATE_PRICE_HISTORY = """
|
||||
CREATE VIEW IF NOT EXISTS price_history AS
|
||||
SELECT
|
||||
i.name_normalized,
|
||||
r.store,
|
||||
r.date,
|
||||
i.unit_price,
|
||||
i.total_price,
|
||||
i.quantity,
|
||||
i.unit,
|
||||
i.category
|
||||
FROM items i
|
||||
JOIN receipts r ON i.receipt_id = r.id;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fonctions publiques
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_connection(db_path: str | Path = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
||||
"""Ouvre une connexion SQLite avec les pragmas requis.
|
||||
|
||||
Active les clés étrangères (désactivées par défaut dans SQLite —
|
||||
le pragma doit être réappliqué à chaque nouvelle connexion).
|
||||
Configure row_factory = sqlite3.Row pour accéder aux colonnes par nom.
|
||||
|
||||
Args:
|
||||
db_path: Chemin vers le fichier .db (créé automatiquement si absent).
|
||||
|
||||
Returns:
|
||||
Connexion sqlite3 configurée.
|
||||
"""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: str | Path = DEFAULT_DB_PATH) -> None:
|
||||
"""Crée les tables, index et vues s'ils n'existent pas encore.
|
||||
|
||||
Idempotent : peut être appelé plusieurs fois sans erreur grâce aux
|
||||
clauses CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS.
|
||||
Crée le dossier parent (data/) s'il n'existe pas.
|
||||
|
||||
Args:
|
||||
db_path: Chemin vers le fichier .db.
|
||||
|
||||
Raises:
|
||||
PermissionError: Si le système de fichiers refuse la création du dossier.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with get_connection(db_path) as conn:
|
||||
conn.execute(_SQL_CREATE_RECEIPTS)
|
||||
conn.execute(_SQL_CREATE_RECEIPTS_IDX)
|
||||
conn.execute(_SQL_CREATE_ITEMS)
|
||||
conn.execute(_SQL_CREATE_ITEMS_IDX)
|
||||
conn.execute(_SQL_CREATE_ITEMS_NORM_IDX)
|
||||
conn.execute(_SQL_CREATE_PRICE_HISTORY)
|
||||
1
tickettracker/llm/__init__.py
Normal file
1
tickettracker/llm/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Module LLM — normalisation des noms de produits
|
||||
99
tickettracker/llm/client.py
Normal file
99
tickettracker/llm/client.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Client HTTP bas niveau pour l'API LLM compatible OpenAI.
|
||||
|
||||
Ce module ne contient qu'une seule fonction publique : call_llm().
|
||||
Il ne connaît pas la logique de normalisation — c'est le rôle de normalizer.py.
|
||||
|
||||
Exceptions levées :
|
||||
LLMUnavailable — serveur injoignable (timeout, connexion refusée)
|
||||
LLMError — réponse HTTP ≥ 400 ou format de réponse inattendu
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from tickettracker import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMUnavailable(Exception):
|
||||
"""Le serveur LLM est injoignable (réseau, timeout)."""
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""L'API LLM a retourné une erreur (HTTP ≥ 400 ou réponse malformée)."""
|
||||
|
||||
|
||||
def call_llm(
|
||||
messages: list[dict],
|
||||
*,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> str:
|
||||
"""Appelle l'API LLM et retourne le texte brut de la réponse.
|
||||
|
||||
Args:
|
||||
messages: Liste de messages au format OpenAI
|
||||
[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
|
||||
model: Nom du modèle (défaut : config.LLM_MODEL)
|
||||
timeout: Timeout en secondes (défaut : config.LLM_TIMEOUT)
|
||||
|
||||
Returns:
|
||||
Le texte du premier choix de la réponse.
|
||||
|
||||
Raises:
|
||||
LLMUnavailable: Si le serveur est injoignable.
|
||||
LLMError: Si l'API retourne une erreur ou une réponse inattendue.
|
||||
"""
|
||||
_model = model or config.LLM_MODEL
|
||||
_timeout = timeout if timeout is not None else config.LLM_TIMEOUT
|
||||
|
||||
if not config.LLM_API_KEY:
|
||||
raise LLMError(
|
||||
"Clé API LLM manquante. "
|
||||
"Définissez la variable d'environnement TICKETTRACKER_LLM_API_KEY."
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config.LLM_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": _model,
|
||||
"messages": messages,
|
||||
"temperature": 0.1, # faible variabilité : on veut un format stable
|
||||
}
|
||||
|
||||
logger.debug("Appel LLM %s (model=%s, timeout=%ds)", config.LLM_URL, _model, _timeout)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
config.LLM_URL,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=_timeout,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
raise LLMUnavailable(
|
||||
f"Timeout après {_timeout}s lors de l'appel au LLM ({config.LLM_URL})."
|
||||
)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise LLMUnavailable(
|
||||
f"Impossible de joindre le serveur LLM ({config.LLM_URL}) : {e}"
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise LLMError(
|
||||
f"Erreur API LLM : HTTP {response.status_code} — {response.text[:200]}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
raise LLMError(
|
||||
f"Réponse LLM inattendue (impossible d'extraire le contenu) : {e}\n"
|
||||
f"Réponse brute : {response.text[:300]}"
|
||||
) from e
|
||||
279
tickettracker/llm/normalizer.py
Normal file
279
tickettracker/llm/normalizer.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Normalisation des noms de produits via LLM.
|
||||
|
||||
Ce module orchestre les appels au LLM pour transformer les noms bruts
|
||||
(OCR Leclerc, HTML Picnic) en noms normalisés au format :
|
||||
"Nom du produit | Marque | Quantité"
|
||||
|
||||
Exemples :
|
||||
"NOIX CAJOU" → "Noix de cajou | MDD | -"
|
||||
"COCA COLA CHERRY 1.25L" → "Coca-Cola Cherry | Coca-Cola | 1,25L"
|
||||
"PQ LOTUS CONFORT X6" → "Papier toilette confort | Lotus | x6"
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from tickettracker import config
|
||||
from tickettracker.llm.client import LLMError, LLMUnavailable, call_llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt calibré pour Mistral
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
Tu es un assistant de normalisation de noms de produits alimentaires et ménagers.
|
||||
Pour chaque nom de produit, réponds au format strict :
|
||||
Nom du produit | Marque | Quantité
|
||||
Règles :
|
||||
- Nom : en français, lisible, sans abréviation, avec accents et majuscules correctes
|
||||
- Marque : nom exact de la marque, ou "MDD" si marque de distributeur ou inconnue
|
||||
- Quantité : format court (50cl, 1L, 200g, x6, 1kg) ou "-" si absente du nom brut
|
||||
Réponds UNIQUEMENT avec les lignes numérotées demandées. Aucun commentaire, aucune explication.\
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing de la réponse LLM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Accepte : "1. Nom | Marque | Qté" ou "1) Nom | Marque | Qté"
|
||||
_LINE_RE = re.compile(
|
||||
r"^\d+[.)]\s*" # numéro suivi de . ou )
|
||||
r"(?P<nom>.+?)" # nom du produit
|
||||
r"\s*\|\s*"
|
||||
r"(?P<marque>.+?)" # marque
|
||||
r"\s*\|\s*"
|
||||
r"(?P<qte>.+?)" # quantité
|
||||
r"\s*$",
|
||||
re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_normalized_line(line: str) -> str | None:
|
||||
"""Extrait le nom normalisé d'une ligne numérotée.
|
||||
|
||||
Args:
|
||||
line: Ligne de réponse LLM, ex: "1. Crème fraîche | MDD | 50cl"
|
||||
|
||||
Returns:
|
||||
"Crème fraîche | MDD | 50cl" si la ligne est valide, None sinon.
|
||||
"""
|
||||
m = _LINE_RE.match(line.strip())
|
||||
if not m:
|
||||
return None
|
||||
nom = m.group("nom").strip()
|
||||
marque = m.group("marque").strip()
|
||||
qte = m.group("qte").strip()
|
||||
# Valide que les trois champs ne sont pas vides
|
||||
if not nom or not marque or not qte:
|
||||
return None
|
||||
return f"{nom} | {marque} | {qte}"
|
||||
|
||||
|
||||
def _parse_batch_response(response_text: str, expected_count: int) -> list[str | None]:
|
||||
"""Transforme la réponse brute du LLM en liste de noms normalisés.
|
||||
|
||||
Si le nombre de lignes valides ne correspond pas à expected_count,
|
||||
retourne une liste de None pour déclencher le fallback.
|
||||
|
||||
Args:
|
||||
response_text: Texte brut retourné par le LLM.
|
||||
expected_count: Nombre d'items attendus.
|
||||
|
||||
Returns:
|
||||
Liste de longueur expected_count — chaque élément est le nom normalisé
|
||||
ou None si la ligne correspondante est invalide.
|
||||
"""
|
||||
# Garde uniquement les lignes non vides
|
||||
lines = [l for l in response_text.splitlines() if l.strip()]
|
||||
|
||||
# Extrait les lignes numérotées (ignore le bruit éventuel)
|
||||
parsed = [_parse_normalized_line(l) for l in lines if _LINE_RE.match(l.strip())]
|
||||
|
||||
if len(parsed) != expected_count:
|
||||
logger.warning(
|
||||
"Batch LLM : attendu %d lignes, reçu %d — fallback unitaire.",
|
||||
expected_count,
|
||||
len(parsed),
|
||||
)
|
||||
return [None] * expected_count
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fonctions publiques
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_product_name(raw_name: str) -> str | None:
|
||||
"""Normalise un seul nom de produit via le LLM.
|
||||
|
||||
Args:
|
||||
raw_name: Nom brut issu du parser (OCR ou HTML).
|
||||
|
||||
Returns:
|
||||
Nom normalisé "Nom | Marque | Quantité", ou None si le LLM
|
||||
échoue ou retourne une réponse non parsable.
|
||||
"""
|
||||
try:
|
||||
response = call_llm([
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Normalise ce nom de produit :\n1. {raw_name}",
|
||||
},
|
||||
])
|
||||
except (LLMError, LLMUnavailable) as e:
|
||||
logger.warning("Normalisation unitaire échouée pour %r : %s", raw_name, e)
|
||||
return None
|
||||
|
||||
lines = [l for l in response.splitlines() if l.strip()]
|
||||
for line in lines:
|
||||
result = _parse_normalized_line(line)
|
||||
if result:
|
||||
return result
|
||||
|
||||
logger.warning(
|
||||
"Réponse LLM non parsable pour %r : %r", raw_name, response[:100]
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_batch(raw_names: list[str]) -> list[str | None]:
|
||||
"""Normalise une liste de noms en un seul appel LLM.
|
||||
|
||||
Envoie tous les noms dans un seul prompt numéroté.
|
||||
Si la réponse ne contient pas exactement len(raw_names) lignes,
|
||||
retourne une liste de None (le fallback unitaire sera utilisé).
|
||||
|
||||
Args:
|
||||
raw_names: Liste de noms bruts.
|
||||
|
||||
Returns:
|
||||
Liste de même longueur : nom normalisé ou None.
|
||||
|
||||
Raises:
|
||||
LLMUnavailable: Si le serveur est injoignable (propagé pour que
|
||||
normalize_all_in_db puisse distinguer erreur réseau vs parsing).
|
||||
"""
|
||||
if not raw_names:
|
||||
return []
|
||||
|
||||
numbered = "\n".join(f"{i + 1}. {name}" for i, name in enumerate(raw_names))
|
||||
user_content = (
|
||||
f"Normalise ces {len(raw_names)} noms de produits :\n"
|
||||
f"{numbered}\n\n"
|
||||
f"Retourne exactement {len(raw_names)} lignes numérotées."
|
||||
)
|
||||
|
||||
try:
|
||||
response = call_llm([
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
])
|
||||
except LLMUnavailable:
|
||||
raise # propagé : erreur réseau, pas la peine de retenter
|
||||
except LLMError as e:
|
||||
logger.warning("Batch LLM échoué : %s — fallback unitaire.", e)
|
||||
return [None] * len(raw_names)
|
||||
|
||||
return _parse_batch_response(response, len(raw_names))
|
||||
|
||||
|
||||
def normalize_all_in_db(
|
||||
db_path: Union[str, Path],
|
||||
batch_size: int = config.LLM_BATCH_SIZE,
|
||||
dry_run: bool = False,
|
||||
) -> tuple[int, int]:
|
||||
"""Normalise tous les articles dont name_normalized est NULL.
|
||||
|
||||
Algorithme :
|
||||
1. Récupère les items NULL depuis la DB
|
||||
2. Traite par batch de batch_size
|
||||
3. Pour chaque batch : appel LLM groupé
|
||||
- Si le batch réussit → UPDATE en base (sauf dry_run)
|
||||
- Si le batch échoue (mauvais count) → fallback un par un
|
||||
4. Affiche la progression
|
||||
|
||||
Args:
|
||||
db_path: Chemin vers la base SQLite.
|
||||
batch_size: Nombre d'articles par appel LLM.
|
||||
dry_run: Si True, calcule mais n'écrit pas en base.
|
||||
|
||||
Returns:
|
||||
(nb_normalisés, nb_erreurs) — erreurs = items restés NULL.
|
||||
"""
|
||||
from tickettracker.db import schema, repository
|
||||
|
||||
schema.init_db(db_path)
|
||||
conn = schema.get_connection(db_path)
|
||||
|
||||
try:
|
||||
items = repository.fetch_unnormalized(conn)
|
||||
total = len(items)
|
||||
|
||||
if total == 0:
|
||||
print("Aucun article à normaliser.")
|
||||
return (0, 0)
|
||||
|
||||
mode = "[DRY-RUN] " if dry_run else ""
|
||||
print(f"{mode}{total} article(s) à normaliser (batchs de {batch_size})...")
|
||||
|
||||
nb_ok = 0
|
||||
nb_err = 0
|
||||
done = 0
|
||||
|
||||
for start in range(0, total, batch_size):
|
||||
batch = items[start: start + batch_size]
|
||||
raw_names = [row["name_raw"] for row in batch]
|
||||
|
||||
# --- Tentative batch ---
|
||||
try:
|
||||
results = normalize_batch(raw_names)
|
||||
except LLMUnavailable as e:
|
||||
logger.error("LLM injoignable : %s", e)
|
||||
print(
|
||||
f"\nErreur réseau — arrêt. {nb_ok} articles normalisés, "
|
||||
f"{total - done} restants."
|
||||
)
|
||||
return (nb_ok, total - done)
|
||||
|
||||
# Si normalize_batch a retourné que des None (batch échoué),
|
||||
# tente le fallback un par un
|
||||
if all(r is None for r in results):
|
||||
logger.debug("Fallback unitaire pour le batch %d–%d.", start, start + len(batch))
|
||||
results = [normalize_product_name(name) for name in raw_names]
|
||||
|
||||
# --- Mise à jour ou affichage ---
|
||||
for item, normalized in zip(batch, results):
|
||||
done += 1
|
||||
if normalized:
|
||||
print(
|
||||
f"[{done}/{total}] {item['name_raw']!r} "
|
||||
f"→ {normalized}"
|
||||
)
|
||||
if not dry_run:
|
||||
repository.update_normalized(conn, item["id"], normalized)
|
||||
nb_ok += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"[%d/%d] Impossible de normaliser %r — item ignoré.",
|
||||
done, total, item["name_raw"],
|
||||
)
|
||||
nb_err += 1
|
||||
|
||||
# Commit final (toutes les mises à jour sont dans la même transaction implicite)
|
||||
if not dry_run:
|
||||
conn.commit()
|
||||
|
||||
print(
|
||||
f"\n{mode}Terminé : {nb_ok} normalisé(s), {nb_err} erreur(s)."
|
||||
)
|
||||
return (nb_ok, nb_err)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
1
tickettracker/models/__init__.py
Normal file
1
tickettracker/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Modèles de données communs
|
||||
72
tickettracker/models/receipt.py
Normal file
72
tickettracker/models/receipt.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Modèle de données commun pour les tickets de courses.
|
||||
|
||||
Toutes les enseignes (Picnic, Leclerc, etc.) produisent
|
||||
une instance de Receipt après parsing. C'est le format
|
||||
JSON normalisé en sortie.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
"""Un article sur le ticket de courses."""
|
||||
|
||||
name: str
|
||||
"""Nom du produit tel qu'il apparaît sur le ticket."""
|
||||
|
||||
quantity: float
|
||||
"""Quantité achetée (ex: 2.0, 0.5)."""
|
||||
|
||||
unit: str
|
||||
"""Unité de mesure : 'pièce', 'kg', 'L', 'g', etc."""
|
||||
|
||||
unit_price: float
|
||||
"""Prix unitaire en euros."""
|
||||
|
||||
total_price: float
|
||||
"""Prix total pour cet article (quantity × unit_price)."""
|
||||
|
||||
category: Optional[str] = None
|
||||
"""Catégorie du produit, si disponible (ex: 'Fruits & Légumes')."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Receipt:
|
||||
"""Ticket de courses normalisé, toutes enseignes confondues."""
|
||||
|
||||
store: str
|
||||
"""Nom de l'enseigne : 'picnic' ou 'leclerc'."""
|
||||
|
||||
date: date
|
||||
"""Date de la commande ou de l'achat."""
|
||||
|
||||
total: float
|
||||
"""Montant total payé en euros."""
|
||||
|
||||
items: list[Item] = field(default_factory=list)
|
||||
"""Liste des articles achetés."""
|
||||
|
||||
currency: str = "EUR"
|
||||
"""Devise (EUR par défaut)."""
|
||||
|
||||
order_id: Optional[str] = None
|
||||
"""Identifiant de commande, si disponible."""
|
||||
|
||||
delivery_fee: Optional[float] = None
|
||||
"""Frais de livraison en euros, si applicable (None pour Leclerc)."""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convertit le ticket en dictionnaire JSON-sérialisable."""
|
||||
d = asdict(self)
|
||||
# La date n'est pas JSON-sérialisable nativement, on la convertit en string ISO
|
||||
d["date"] = self.date.isoformat()
|
||||
return d
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Sérialise le ticket en JSON formaté."""
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
||||
1
tickettracker/parsers/__init__.py
Normal file
1
tickettracker/parsers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Parsers de tickets de courses
|
||||
403
tickettracker/parsers/leclerc.py
Normal file
403
tickettracker/parsers/leclerc.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Parser pour les tickets de caisse PDF Leclerc.
|
||||
|
||||
Les PDFs Leclerc sont des scans d'images (pas de couche texte sélectionnable).
|
||||
Le parser extrait l'image JPEG embarquée et applique Tesseract OCR pour
|
||||
récupérer le texte, puis l'analyse ligne par ligne.
|
||||
|
||||
Prérequis système :
|
||||
- Tesseract OCR 5.x installé :
|
||||
Windows : https://github.com/UB-Mannheim/tesseract/wiki
|
||||
Linux : apt install tesseract-ocr tesseract-ocr-fra
|
||||
- Modèle français (fra.traineddata) dans le dossier tessdata.
|
||||
Si les droits manquent pour écrire dans le dossier système,
|
||||
placer les fichiers eng.traineddata et fra.traineddata dans
|
||||
un dossier local et définir TESSDATA_PREFIX=<chemin_du_dossier>.
|
||||
- pip install pytesseract pillow pdfplumber
|
||||
|
||||
Structure du ticket Leclerc :
|
||||
>> CATEGORIE → titre de catégorie (gras sur ticket)
|
||||
NOM PRODUIT PRIX TVA → article standard (1 ligne)
|
||||
* NOM PRODUIT PRIX TVA → idem, avec marque promotion
|
||||
NOM PRODUIT → article multi-unités (pas de prix ici)
|
||||
QTY X PRIX_UNIT€ TOTAL TVA → ligne de prix (suite du précédent)
|
||||
Total NN articles TOTAL_TTC → total avant remises
|
||||
Bon reduction MONTANT → bon de réduction (peut se répéter)
|
||||
CB MONTANT_FINAL → montant payé par carte
|
||||
|
||||
Dépendances Python : pdfplumber, pytesseract, Pillow
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
import pdfplumber
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
from tickettracker.models.receipt import Item, Receipt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration Tesseract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Chemins Tesseract standards selon l'OS
|
||||
_TESSERACT_PATHS = [
|
||||
r"C:/Program Files/Tesseract-OCR/tesseract.exe",
|
||||
r"C:/Program Files (x86)/Tesseract-OCR/tesseract.exe",
|
||||
"/usr/bin/tesseract",
|
||||
"/usr/local/bin/tesseract",
|
||||
]
|
||||
|
||||
# Correspondance noms de mois français → numéro
|
||||
_MOIS_FR = {
|
||||
"janvier": 1, "février": 2, "mars": 3, "avril": 4,
|
||||
"mai": 5, "juin": 6, "juillet": 7, "août": 8,
|
||||
"septembre": 9, "octobre": 10, "novembre": 11, "décembre": 12,
|
||||
}
|
||||
|
||||
|
||||
def _configure_tesseract() -> None:
|
||||
"""Détecte et configure le binaire Tesseract et le dossier tessdata.
|
||||
|
||||
Priorité :
|
||||
1. Variable d'environnement TESSERACT_CMD (chemin vers le binaire)
|
||||
2. Chemins standards Windows/Linux
|
||||
Pour tessdata :
|
||||
1. Variable TESSDATA_PREFIX déjà définie dans l'environnement
|
||||
2. Dossier tessdata/ à côté de ce fichier (usage dev avec droits limités)
|
||||
"""
|
||||
# Binaire
|
||||
cmd = os.environ.get("TESSERACT_CMD")
|
||||
if not cmd:
|
||||
for p in _TESSERACT_PATHS:
|
||||
if os.path.isfile(p):
|
||||
cmd = p
|
||||
break
|
||||
if cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = cmd
|
||||
|
||||
# Tessdata (uniquement si pas déjà configuré)
|
||||
if not os.environ.get("TESSDATA_PREFIX"):
|
||||
# Cherche tessdata/ dans le répertoire du projet (2 niveaux au-dessus)
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
local_tessdata = os.path.join(here, "..", "..", "tessdata")
|
||||
if os.path.isdir(local_tessdata) and os.path.isfile(
|
||||
os.path.join(local_tessdata, "fra.traineddata")
|
||||
):
|
||||
os.environ["TESSDATA_PREFIX"] = os.path.abspath(local_tessdata)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point d'entrée public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse(pdf_path: str) -> Receipt:
|
||||
"""Parse un PDF de ticket Leclerc et retourne un ticket normalisé.
|
||||
|
||||
Args:
|
||||
pdf_path: Chemin vers le fichier PDF du ticket Leclerc.
|
||||
Le PDF doit être un scan image (type Leclerc classique).
|
||||
|
||||
Returns:
|
||||
Receipt: Ticket de courses normalisé avec tous les articles et
|
||||
leurs catégories.
|
||||
|
||||
Raises:
|
||||
ValueError: Si la date ou le total sont introuvables.
|
||||
RuntimeError: Si Tesseract n'est pas installé ou pas configuré.
|
||||
"""
|
||||
_configure_tesseract()
|
||||
text = _extract_text_from_pdf(pdf_path)
|
||||
return _parse_text(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction image + OCR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_text_from_pdf(pdf_path: str) -> str:
|
||||
"""Extrait l'image JPEG du PDF et retourne le texte OCR.
|
||||
|
||||
Les tickets Leclerc sont une unique image haute résolution (1650×10386)
|
||||
découpée sur plusieurs pages PDF. L'image est identique dans le flux de
|
||||
chaque page ; on l'extrait une seule fois depuis la page 1.
|
||||
"""
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
if not pdf.pages or not pdf.pages[0].images:
|
||||
raise ValueError(
|
||||
f"Le PDF '{pdf_path}' ne contient pas d'image en page 1. "
|
||||
"Le format Leclerc attendu est un scan image (JPEG embarqué)."
|
||||
)
|
||||
img_obj = pdf.pages[0].images[0]
|
||||
raw_jpeg = img_obj["stream"].get_rawdata()
|
||||
|
||||
img = Image.open(io.BytesIO(raw_jpeg))
|
||||
|
||||
# Amélioration légère de la lisibilité avant OCR :
|
||||
# - Conversion en niveaux de gris (le ticket est noir sur blanc)
|
||||
# - Resize au 2/3 : accélère le traitement sans perte significative
|
||||
# (1650px → 1100px, le texte reste lisible à ~40px de hauteur)
|
||||
img_gray = img.convert("L").resize(
|
||||
(img.width * 2 // 3, img.height * 2 // 3),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
|
||||
try:
|
||||
text = pytesseract.image_to_string(
|
||||
img_gray,
|
||||
lang="fra+eng",
|
||||
config="--psm 6 --oem 3", # bloc de texte uniforme, LSTM
|
||||
)
|
||||
except pytesseract.TesseractError as e:
|
||||
raise RuntimeError(
|
||||
f"Erreur Tesseract lors de l'OCR : {e}\n"
|
||||
"Vérifiez que Tesseract est installé et que le modèle 'fra' est disponible.\n"
|
||||
"Voir README ou commentaires dans tickettracker/parsers/leclerc.py."
|
||||
) from e
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing du texte OCR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Regex pour une ligne article standard :
|
||||
# NOM PRODUIT PRIX CODE_TVA
|
||||
#
|
||||
# Problèmes OCR observés sur le ticket Leclerc Clichy :
|
||||
# - Un seul espace entre nom et prix (pas d'alignement de colonnes garanti)
|
||||
# - Artefacts de séparateur de colonne dans le nom : ' | ' ou ' — '
|
||||
# - Prix avec 3 décimales (ex: "10.460" au lieu de "10.40")
|
||||
# - Code TVA avec 0 devant : "01" au lieu de "1"
|
||||
# - Préfixe "* " pour les articles en promotion
|
||||
#
|
||||
# Stratégie : le code TVA (1 ou 2 chiffres max) est TOUJOURS le dernier token ;
|
||||
# le prix (N.NN ou N,NN) est toujours l'avant-dernier. On greedy-matche .+ pour
|
||||
# le nom et le moteur de regex backtrackera jusqu'à trouver le bon découpage.
|
||||
_ITEM_RE = re.compile(
|
||||
r"^(?:\* )?" # préfixe optionnel * (non capturé)
|
||||
r"(?P<name>.+)" # nom du produit (greedy → backtrack)
|
||||
r"\s+"
|
||||
r"(?P<price>\d{1,3}[.,]\d{2,3})" # prix : 2 ou 3 décimales (OCR)
|
||||
r"\s+"
|
||||
r"(?P<tva>0?\d{1,2})" # code TVA : 1 ou 4, parfois "01"
|
||||
r"\s*$"
|
||||
)
|
||||
|
||||
# Regex pour la ligne de prix des articles en multi-unités :
|
||||
# 2 X 3.48€ 6.96 1
|
||||
_MULTI_RE = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s*[Xx]\s*(?P<unit_price>\d+[.,]\d+)€?"
|
||||
r"\s+(?P<total>\d+[.,]\d{2})\s+(?P<tva>\d{1,2})\s*$"
|
||||
)
|
||||
|
||||
# Regex pour la ligne de total général
|
||||
_TOTAL_RE = re.compile(r"Total\s+\d+\s+articles\s+(?P<total>\d+[.,]\d{2})")
|
||||
|
||||
# Regex pour les remises
|
||||
_REDUCTION_RE = re.compile(r"Bon\s+r[ée]duction\s+(?P<amount>\d+[.,]\d{2})", re.IGNORECASE)
|
||||
|
||||
# Regex pour le paiement CB (montant final payé)
|
||||
_CB_RE = re.compile(r"^[Cc][Bb]\s+(?P<amount>\d+[.,]\d{2})\s*$")
|
||||
|
||||
# Regex pour la date dans la ligne "Caisse XXX DD mois YYYY HH:MM"
|
||||
_DATE_RE = re.compile(
|
||||
r"Caisse\s+\S+\s+(\d{1,2})\s+(\w+)\s+(\d{4})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Regex pour l'identifiant de caisse (ordre "Caisse 018-0003 ...")
|
||||
_CAISSE_RE = re.compile(r"Caisse\s+(\S+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_price(s: str) -> float:
|
||||
"""Convertit une chaîne de prix (virgule ou point) en float."""
|
||||
return float(s.replace(",", "."))
|
||||
|
||||
|
||||
def _parse_text(text: str) -> Receipt:
|
||||
"""Analyse le texte OCR d'un ticket Leclerc.
|
||||
|
||||
Retourne un Receipt avec tous les articles, catégories, date et total.
|
||||
|
||||
Note OCR : Tesseract peut commettre des erreurs sur des caractères
|
||||
ambigus (0 ↔ 6, | ↔ 1, etc.). Le total du ticket est retourné tel qu'OCR
|
||||
l'a lu ; il peut différer légèrement si des caractères sont mal reconnus.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
|
||||
delivery_date = _extract_date(lines)
|
||||
order_id = _extract_caisse_id(lines)
|
||||
items = _extract_items(lines)
|
||||
total = _extract_total(lines)
|
||||
|
||||
return Receipt(
|
||||
store="leclerc",
|
||||
date=delivery_date,
|
||||
total=total,
|
||||
items=items,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
|
||||
def _extract_date(lines: list[str]) -> date:
|
||||
"""Extrait la date depuis la ligne 'Caisse XXX DD mois AAAA HH:MM'."""
|
||||
for line in lines:
|
||||
m = _DATE_RE.search(line)
|
||||
if m:
|
||||
day = int(m.group(1))
|
||||
month_str = m.group(2).lower().strip()
|
||||
year = int(m.group(3))
|
||||
month = _MOIS_FR.get(month_str)
|
||||
if month:
|
||||
return date(year, month, day)
|
||||
raise ValueError(
|
||||
"Date introuvable dans le ticket Leclerc. "
|
||||
"Attendu : 'Caisse XXX DD mois YYYY' dans l'OCR."
|
||||
)
|
||||
|
||||
|
||||
def _extract_caisse_id(lines: list[str]) -> Optional[str]:
|
||||
"""Extrait le numéro de caisse (ex: '018-0003')."""
|
||||
for line in lines:
|
||||
m = _CAISSE_RE.search(line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_total(lines: list[str]) -> float:
|
||||
"""Extrait le montant final payé.
|
||||
|
||||
Préférence au montant CB (après remises). Sinon, le 'Total N articles'.
|
||||
"""
|
||||
cb_total: Optional[float] = None
|
||||
subtotal: Optional[float] = None
|
||||
|
||||
for line in lines:
|
||||
m_cb = _CB_RE.match(line.strip())
|
||||
if m_cb:
|
||||
cb_total = _parse_price(m_cb.group("amount"))
|
||||
|
||||
m_tot = _TOTAL_RE.search(line)
|
||||
if m_tot:
|
||||
subtotal = _parse_price(m_tot.group("total"))
|
||||
|
||||
total = cb_total if cb_total is not None else subtotal
|
||||
if total is None:
|
||||
raise ValueError(
|
||||
"Montant total introuvable dans le ticket Leclerc. "
|
||||
"La structure du ticket a peut-être changé."
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def _extract_items(lines: list[str]) -> list[Item]:
|
||||
"""Extrait tous les articles du texte OCR ligne par ligne.
|
||||
|
||||
Gère :
|
||||
- Articles standard (1 ligne : nom + prix + code TVA)
|
||||
- Articles en multi-unités (nom sur une ligne, QTY × PRIX sur la suivante)
|
||||
- Changements de catégorie (lignes débutant par >>)
|
||||
- Arrêt à la ligne 'Total N articles'
|
||||
"""
|
||||
items: list[Item] = []
|
||||
current_category: Optional[str] = None
|
||||
pending_name: Optional[str] = None # nom en attente de la ligne de prix
|
||||
|
||||
# On cherche d'abord la ligne "TTC TVA" qui marque le début des articles
|
||||
in_items = False
|
||||
|
||||
for line in lines:
|
||||
raw = line.strip()
|
||||
|
||||
# Début de la section articles (insensible à la casse : "TTc TvA" possible)
|
||||
# Note OCR : Tesseract Linux lit parfois "TIc" au lieu de "TTC"
|
||||
# (confusion T↔I en 2e position, fréquente avec Tesseract 5.x)
|
||||
if not in_items:
|
||||
if re.search(r"T[TI]C\s+TVA", raw, re.IGNORECASE):
|
||||
in_items = True
|
||||
continue
|
||||
|
||||
# Fin de la section articles
|
||||
if _TOTAL_RE.search(raw):
|
||||
break
|
||||
|
||||
# Ligne vide → ignore
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
# Changement de catégorie (>> CATEGORIE)
|
||||
if raw.startswith(">>"):
|
||||
category_raw = raw.lstrip(">").strip()
|
||||
current_category = re.sub(r"[|_—]+", " ", category_raw).strip()
|
||||
pending_name = None
|
||||
continue
|
||||
|
||||
# Pré-nettoyage des artefacts OCR dans la ligne avant les regex :
|
||||
# '|' et '—' sont des séparateurs de colonnes que Tesseract voit parfois
|
||||
# comme des caractères dans le texte.
|
||||
clean = re.sub(r"\s*[|—]\s*", " ", raw).strip()
|
||||
|
||||
# Ligne de prix pour un article multi-unités (2 X 3.48€ 6.96 1)
|
||||
m_multi = _MULTI_RE.match(clean)
|
||||
if m_multi and pending_name is not None:
|
||||
qty = float(m_multi.group("qty"))
|
||||
unit_price = _parse_price(m_multi.group("unit_price"))
|
||||
total_price = _parse_price(m_multi.group("total"))
|
||||
items.append(Item(
|
||||
name=_clean_name(pending_name),
|
||||
quantity=qty,
|
||||
unit="pièce",
|
||||
unit_price=unit_price,
|
||||
total_price=total_price,
|
||||
category=current_category,
|
||||
))
|
||||
pending_name = None
|
||||
continue
|
||||
|
||||
# Ligne article standard (nom prix tva) — regex greedy depuis la droite
|
||||
m_item = _ITEM_RE.match(clean)
|
||||
if m_item:
|
||||
if pending_name is not None:
|
||||
pending_name = None
|
||||
|
||||
name = _clean_name(m_item.group("name"))
|
||||
# Tronque à 2 décimales pour corriger les prix OCR comme "10.460"
|
||||
price = round(_parse_price(m_item.group("price")), 2)
|
||||
items.append(Item(
|
||||
name=name,
|
||||
quantity=1.0,
|
||||
unit="pièce",
|
||||
unit_price=price,
|
||||
total_price=price,
|
||||
category=current_category,
|
||||
))
|
||||
continue
|
||||
|
||||
# Ligne sans prix reconnaissable → début d'un article multi-unités
|
||||
if len(clean) > 4 and not re.match(r"^[\d\s.,|*_-]+$", clean):
|
||||
pending_name = clean.lstrip("* ").strip()
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
"""Nettoie le nom d'un article des artefacts OCR courants.
|
||||
|
||||
Artefacts observés sur le ticket Leclerc Clichy :
|
||||
- ' |' ou '| ' en fin de nom (artefact de séparateur de colonne)
|
||||
- ' _' ou '_ ' (bruit d'image)
|
||||
- espaces multiples
|
||||
"""
|
||||
# Retire les artefacts de colonnes en fin de chaîne
|
||||
name = re.sub(r"[\s|_—]+$", "", name)
|
||||
# Normalise les espaces internes
|
||||
name = re.sub(r"\s{2,}", " ", name)
|
||||
return name.strip()
|
||||
514
tickettracker/parsers/picnic.py
Normal file
514
tickettracker/parsers/picnic.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Parser pour les mails de confirmation de commande Picnic.
|
||||
|
||||
Les mails Picnic arrivent en HTML sur remora@dilain.com
|
||||
après un forward du mail de confirmation de livraison.
|
||||
|
||||
Le corps du mail est encodé en Quoted-Printable (QP), ce parser
|
||||
le décode automatiquement avant d'analyser le HTML.
|
||||
|
||||
Structure HTML Picnic identifiée :
|
||||
- Date de livraison dans le texte d'intro "livraison du JJ MOIS AAAA"
|
||||
- Numéro de commande dans "Commande : XXX-XXX-XXXX"
|
||||
- Articles : lignes HTML repérées par les images produit
|
||||
(domaine storefront-prod.fr.picnicinternational.com)
|
||||
Structure standard (7 colonnes directes) :
|
||||
col 0 : quantité dans un badge borduré
|
||||
col 2 : image produit (alt = nom du produit)
|
||||
col 4 : nom (font-size 15px) + format (font-size 12px, vert #234314)
|
||||
col 6 : prix splitté euros (font-size 26px) / centimes (17px)
|
||||
Structure corrompue (< 7 colonnes) : fallback via balises <strong>
|
||||
- Total dans une ligne labelisée <strong>Total</strong>
|
||||
|
||||
Corruption QP connue dans ce mail :
|
||||
- Sauts de ligne doux (=\r\n) au milieu de séquences UTF-8 multi-octets
|
||||
- Balises corrompues : "<= /td>", "<t d>", "78 <= /strong>", etc.
|
||||
- Valeur d'attribut style qui déborde en contenu texte du td
|
||||
|
||||
Dépendances : beautifulsoup4, lxml
|
||||
"""
|
||||
|
||||
import quopri
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from tickettracker.models.receipt import Item, Receipt
|
||||
|
||||
# Correspondance noms de mois français → numéro
|
||||
_MOIS_FR = {
|
||||
"janvier": 1, "février": 2, "mars": 3, "avril": 4,
|
||||
"mai": 5, "juin": 6, "juillet": 7, "août": 8,
|
||||
"septembre": 9, "octobre": 10, "novembre": 11, "décembre": 12,
|
||||
}
|
||||
|
||||
|
||||
def parse(html_content: str) -> Receipt:
|
||||
"""Parse un mail HTML Picnic et retourne un ticket normalisé.
|
||||
|
||||
Args:
|
||||
html_content: Contenu HTML du mail de confirmation Picnic,
|
||||
potentiellement encodé en Quoted-Printable (format brut d'email).
|
||||
|
||||
Returns:
|
||||
Receipt: Ticket de courses normalisé avec tous les articles.
|
||||
|
||||
Raises:
|
||||
ValueError: Si la date ou le total sont introuvables dans le HTML.
|
||||
"""
|
||||
soup = _decode_and_parse(html_content)
|
||||
full_text = soup.get_text(" ", strip=True)
|
||||
|
||||
delivery_date = _extract_date(full_text)
|
||||
order_id = _extract_order_id(full_text)
|
||||
items = _extract_items(soup)
|
||||
total = _extract_total(full_text)
|
||||
|
||||
return Receipt(
|
||||
store="picnic",
|
||||
date=delivery_date,
|
||||
total=total,
|
||||
items=items,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Décodage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _decode_and_parse(html_content: str) -> BeautifulSoup:
|
||||
"""Décode le Quoted-Printable et retourne un objet BeautifulSoup.
|
||||
|
||||
Problème connu avec les mails Picnic : l'encodeur QP insère des sauts de
|
||||
ligne doux (=\\r\\n) au milieu de séquences UTF-8 multi-octets, avec une
|
||||
indentation HTML sur la ligne suivante. Par exemple :
|
||||
|
||||
f=C3=
|
||||
=A9vrier 2026
|
||||
|
||||
devient après décodage QP naïf : f\\xc3⎵⎵⎵⎵\\xa9vrier (séquence cassée).
|
||||
|
||||
Solution : on supprime d'abord les sauts doux ET leur indentation éventuelle
|
||||
(=\\r?\\n\\s*) avant de passer au décodeur QP standard, ce qui reconstitue
|
||||
correctement =C3=A9 → é.
|
||||
"""
|
||||
raw_bytes = html_content.encode("ascii", errors="replace")
|
||||
# Supprime les sauts de ligne doux QP et leur indentation HTML éventuelle
|
||||
raw_clean = re.sub(rb"=\r?\n\s*", b"", raw_bytes)
|
||||
decoded_bytes = quopri.decodestring(raw_clean)
|
||||
decoded_html = decoded_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
# Corrige les artefacts résiduels du double-encodage QP dans les attributs HTML.
|
||||
#
|
||||
# Certains encodeurs QP encodent aussi les '=' de la syntaxe HTML (attribut="valeur"),
|
||||
# créant des séquences comme alt==3D"..." dans l'email brut. Notre décodeur QP
|
||||
# résout le dernier =3D mais laisse le premier '=' → "alt=3D\"...\"" dans le HTML.
|
||||
# lxml interprète alors "alt=3D" comme valeur non-quotée et perd le vrai contenu.
|
||||
#
|
||||
# De même, un saut QP tombe parfois au milieu du nom d'attribut "src" →
|
||||
# "sr=c=3D\"...\"" → après décodage QP → "sr=c=\"...\"".
|
||||
# lxml crée un attribut "sr" au lieu de "src".
|
||||
#
|
||||
# Corrections :
|
||||
# "\w+=3D\"" → "\w+=\"" (ex: alt=3D" → alt=", src=3D" → src=")
|
||||
# "sr=c=\"" → "src=\"" (reconstruction du nom d'attribut corrompu)
|
||||
decoded_html = re.sub(r'(\w+=)3D"', r'\1"', decoded_html)
|
||||
decoded_html = re.sub(r'\bsr=c="', 'src="', decoded_html)
|
||||
|
||||
return BeautifulSoup(decoded_html, "lxml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_date(text: str) -> date:
|
||||
"""Extrait la date de livraison depuis le texte du mail.
|
||||
|
||||
Le mail contient une phrase du type :
|
||||
"Voici le reçu de votre livraison du samedi 14 février 2026."
|
||||
"""
|
||||
m = re.search(
|
||||
r"livraison du\s+\w+\s+(\d{1,2})\s+(\w+)\s+(\d{4})",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
"Date de livraison introuvable dans le mail Picnic. "
|
||||
"Attendu : 'livraison du <jour_semaine> <JJ> <mois> <AAAA>'"
|
||||
)
|
||||
|
||||
day = int(m.group(1))
|
||||
month_str = m.group(2).lower().strip()
|
||||
year = int(m.group(3))
|
||||
|
||||
month = _MOIS_FR.get(month_str)
|
||||
if month is None:
|
||||
raise ValueError(
|
||||
f"Mois '{month_str}' non reconnu. "
|
||||
f"Mois attendus : {', '.join(_MOIS_FR)}"
|
||||
)
|
||||
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numéro de commande
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_order_id(text: str) -> Optional[str]:
|
||||
"""Extrait le numéro de commande Picnic (optionnel)."""
|
||||
m = re.search(r"Commande\s*:\s*([\d\-]+)", text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Articles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_items(soup: BeautifulSoup) -> list[Item]:
|
||||
"""Extrait la liste des articles depuis le HTML du mail.
|
||||
|
||||
Pour chaque image produit, tente d'abord la lecture en ligne 7 colonnes
|
||||
(structure standard), puis utilise un fallback basé sur les balises
|
||||
<strong> pour les lignes dont le HTML est corrompu par l'encodage QP.
|
||||
"""
|
||||
items = []
|
||||
seen_rows: set[int] = set() # Évite de traiter le même conteneur deux fois
|
||||
|
||||
# La corruption QP insère des '=' dans les URLs de src (ex: "picnici=nternational.com").
|
||||
# On les neutralise avant le test pour trouver toutes les images produit.
|
||||
product_imgs = soup.find_all(
|
||||
"img",
|
||||
src=lambda s: s and "picnicinternational.com" in s.replace("=", ""),
|
||||
)
|
||||
|
||||
for img in product_imgs:
|
||||
item = _try_7col(img, seen_rows)
|
||||
if item is None:
|
||||
item = _try_fallback(img, seen_rows)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _try_7col(img, seen_rows: set[int]) -> Optional[Item]:
|
||||
"""Tente d'extraire un article depuis une ligne à 7 colonnes directes.
|
||||
|
||||
Structure attendue :
|
||||
td[0] : badge quantité | td[2] : image | td[4] : nom+unité | td[6] : prix
|
||||
"""
|
||||
# Remonte jusqu'à trouver un <tr> avec exactement 7 <td> directs
|
||||
tds = []
|
||||
node = img.parent
|
||||
while node:
|
||||
if node.name == "tr":
|
||||
candidate = node.find_all("td", recursive=False)
|
||||
if len(candidate) == 7:
|
||||
tds = candidate
|
||||
break
|
||||
node = node.parent
|
||||
|
||||
if len(tds) != 7:
|
||||
return None
|
||||
|
||||
row_id = id(tds[0])
|
||||
if row_id in seen_rows:
|
||||
return None
|
||||
seen_rows.add(row_id)
|
||||
|
||||
# --- Quantité (colonne 0) ---
|
||||
qty_text = tds[0].get_text(strip=True)
|
||||
try:
|
||||
quantity = float(qty_text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# --- Nom et format/unité (colonne 4) ---
|
||||
# Approche positionnelle : 1er td feuille non vide = nom, 2e = unité.
|
||||
# On utilise uniquement les tds feuilles (sans td enfant) pour éviter que
|
||||
# le td parent récapitulatif ne double le texte.
|
||||
inner_texts = [
|
||||
" ".join(inner_td.get_text().split())
|
||||
for inner_td in tds[4].find_all("td")
|
||||
if not inner_td.find("td") and inner_td.get_text(strip=True)
|
||||
]
|
||||
name = inner_texts[0] if inner_texts else img.get("alt", "Inconnu").strip()
|
||||
unit = inner_texts[1] if len(inner_texts) > 1 else "pièce"
|
||||
|
||||
name = _clean_artifact(name)
|
||||
unit = _clean_unit(unit)
|
||||
|
||||
# --- Prix total de la ligne (colonne 6) ---
|
||||
# Les balises <strong> sont plus robustes que get_text() pour éviter la
|
||||
# contamination par les valeurs CSS corrompues (ex: "#234314" dans du texte).
|
||||
total_price = _parse_price_from_cell(tds[6])
|
||||
if total_price is None:
|
||||
return None
|
||||
|
||||
unit_price = round(total_price / quantity, 4) if quantity > 0 else total_price
|
||||
|
||||
return Item(
|
||||
name=name,
|
||||
quantity=quantity,
|
||||
unit=unit,
|
||||
unit_price=unit_price,
|
||||
total_price=total_price,
|
||||
)
|
||||
|
||||
|
||||
def _try_fallback(img, seen_rows: set[int]) -> Optional[Item]:
|
||||
"""Fallback pour les articles dont la ligne HTML est corrompue (< 7 td directs).
|
||||
|
||||
Deux tentatives successives :
|
||||
1. Tr avec >= 3 td directs ET >= 3 strongs chiffres (quantité + prix en tête)
|
||||
→ utilisé pour les items dont le badge qty est dans un <strong>
|
||||
2. Tr avec >= 1 td ET exactement 2 strongs chiffres (badge qty absent/corrompu)
|
||||
→ utilisé quand seul le prix est dans des <strong> (ex: Jardin Bio, Alfapac)
|
||||
"""
|
||||
# --- Tentative 1 : badge qty dans un <strong> (cas standard) ---
|
||||
item_tr = None
|
||||
node = img.parent
|
||||
while node and node.name != "body":
|
||||
if node.name == "tr":
|
||||
tds = node.find_all("td", recursive=False)
|
||||
if len(tds) >= 3 and len(_get_digit_strongs(node)) >= 3:
|
||||
item_tr = node
|
||||
break
|
||||
node = node.parent
|
||||
|
||||
if item_tr is not None:
|
||||
row_id = id(item_tr)
|
||||
if row_id not in seen_rows:
|
||||
seen_rows.add(row_id)
|
||||
digit_strongs = _get_digit_strongs(item_tr)
|
||||
try:
|
||||
quantity = float(digit_strongs[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
# L'article est le PREMIER dans le tr → ses euros/centimes sont à [1] et [2].
|
||||
# (Contrairement à _parse_price_from_cell qui utilise [-2][-1] car dans une
|
||||
# cellule dédiée, le dernier prix affiché est le prix réel après réduction.)
|
||||
try:
|
||||
total_price = float(
|
||||
f"{digit_strongs[1]}.{digit_strongs[2].zfill(2)}"
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
name, unit = _extract_name_unit_fallback(img, item_tr)
|
||||
unit_price = round(total_price / quantity, 4) if quantity > 0 else total_price
|
||||
return Item(
|
||||
name=name, quantity=quantity, unit=unit,
|
||||
unit_price=unit_price, total_price=total_price,
|
||||
)
|
||||
|
||||
# --- Tentative 2 : badge qty corrompu, seul le prix est dans des <strong> ---
|
||||
# Ex: Jardin Bio, Alfapac dont le badge qty n'est pas dans un <strong>.
|
||||
item_tr = None
|
||||
node = img.parent
|
||||
while node and node.name != "body":
|
||||
if node.name == "tr":
|
||||
ds = _get_digit_strongs(node)
|
||||
if len(ds) == 2:
|
||||
item_tr = node
|
||||
break
|
||||
node = node.parent
|
||||
|
||||
if item_tr is None:
|
||||
return None
|
||||
|
||||
row_id = id(item_tr)
|
||||
if row_id in seen_rows:
|
||||
return None
|
||||
seen_rows.add(row_id)
|
||||
|
||||
digit_strongs = _get_digit_strongs(item_tr)
|
||||
# Seulement 2 strongs → ce sont euros et centimes du prix (pas de badge qty strong)
|
||||
try:
|
||||
total_price = float(f"{digit_strongs[0]}.{digit_strongs[1].zfill(2)}")
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
# Quantité : extraire le premier chiffre du texte brut du tr
|
||||
text = item_tr.get_text(strip=True)
|
||||
qty_match = re.match(r"(\d+)", text)
|
||||
quantity = float(qty_match.group(1)) if qty_match else 1.0
|
||||
|
||||
name, unit = _extract_name_unit_fallback(img, item_tr)
|
||||
unit_price = round(total_price / quantity, 4) if quantity > 0 else total_price
|
||||
|
||||
return Item(
|
||||
name=name, quantity=quantity, unit=unit,
|
||||
unit_price=unit_price, total_price=total_price,
|
||||
)
|
||||
|
||||
|
||||
def _extract_name_unit_fallback(img, item_tr) -> tuple[str, str]:
|
||||
"""Extrait nom et unité pour un article en structure corrompue.
|
||||
|
||||
Cherche d'abord dans le td ancêtre de l'image au sein de item_tr
|
||||
(cas 3-col : tout est collapsé dans td[0] à cause du QP), puis dans
|
||||
les td suivants (cas 5-col ou 6-col : nom+unité dans un td frère).
|
||||
"""
|
||||
name = img.get("alt", "Inconnu").strip()
|
||||
unit = "pièce"
|
||||
|
||||
# Trouve le td direct de item_tr qui contient l'image
|
||||
img_container_td = None
|
||||
node = img.parent
|
||||
while node and node is not item_tr:
|
||||
if node.name == "td" and node.parent is item_tr:
|
||||
img_container_td = node
|
||||
break
|
||||
node = node.parent
|
||||
|
||||
if img_container_td is None:
|
||||
return _clean_artifact(name), unit
|
||||
|
||||
def _significant_texts(td):
|
||||
"""Textes des tds feuilles significatifs (longueur > 3, non numérique).
|
||||
|
||||
N'utilise que les tds feuilles (sans td enfant) pour éviter que
|
||||
les tds parents récapitulatifs ne doublent le nom/unité.
|
||||
Exclut les artefacts QP du type '= td>' qui commencent par '=' ou '<'.
|
||||
"""
|
||||
return [
|
||||
" ".join(t.get_text().split())
|
||||
for t in td.find_all("td")
|
||||
if not t.find("td") # td feuille uniquement
|
||||
and len(t.get_text(strip=True)) > 3
|
||||
and not t.get_text(strip=True).isdigit()
|
||||
and not t.get_text(strip=True).startswith(("=", "<")) # exclut artefacts
|
||||
]
|
||||
|
||||
# Cas A : tout est dans le td de l'image (structure 3-col collapsed)
|
||||
inner = _significant_texts(img_container_td)
|
||||
if inner:
|
||||
name = inner[0]
|
||||
unit = inner[1] if len(inner) > 1 else "pièce"
|
||||
else:
|
||||
# Cas B : nom+unité dans un td frère après l'image (5-col ou 6-col)
|
||||
sibling = img_container_td.next_sibling
|
||||
while sibling:
|
||||
if hasattr(sibling, "find_all"):
|
||||
inner = _significant_texts(sibling)
|
||||
if inner:
|
||||
name = inner[0]
|
||||
unit = inner[1] if len(inner) > 1 else "pièce"
|
||||
break
|
||||
sibling = sibling.next_sibling
|
||||
|
||||
return _clean_artifact(name), _clean_unit(unit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers prix et nettoyage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_digit_strongs(node) -> list[str]:
|
||||
"""Retourne les valeurs des <strong> ne contenant que des chiffres.
|
||||
|
||||
Prend le premier token whitespace de chaque <strong> pour ignorer les
|
||||
artefacts QP du type "78 <= /strong>" → premier token "78" (digit).
|
||||
"""
|
||||
result = []
|
||||
for s in node.find_all("strong"):
|
||||
text = s.get_text(strip=True)
|
||||
first_token = text.split()[0] if text.split() else ""
|
||||
if first_token.isdigit():
|
||||
result.append(first_token)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_price_from_cell(td) -> Optional[float]:
|
||||
"""Extrait le prix depuis une cellule de prix Picnic.
|
||||
|
||||
Picnic split les euros (grande police) et les centimes (petite police)
|
||||
dans des éléments <strong> séparés. L'approche via <strong> est préférée
|
||||
à get_text() pour éviter la contamination par des valeurs CSS corrompues.
|
||||
|
||||
On utilise les DEUX DERNIERS chiffres strongs car un article soldé affiche
|
||||
deux prix : barré (original) puis réel. Ex: '3 05 . 2 74 .' → prix=€2.74.
|
||||
"""
|
||||
digit_strongs = _get_digit_strongs(td)
|
||||
if len(digit_strongs) >= 2:
|
||||
euros = digit_strongs[-2] # avant-dernier = euros du prix réel
|
||||
centimes = digit_strongs[-1].zfill(2) # dernier = centimes
|
||||
return float(f"{euros}.{centimes}")
|
||||
# Fallback : extraction textuelle brute
|
||||
return _parse_price_from_text(td.get_text())
|
||||
|
||||
|
||||
def _parse_price_from_text(raw_text: str) -> Optional[float]:
|
||||
"""Parse un montant Picnic depuis le texte brut d'une cellule de prix (fallback).
|
||||
|
||||
Stratégie : on extrait tous les chiffres consécutifs, et on interprète
|
||||
les 2 derniers comme les centimes, le reste comme les euros.
|
||||
Exemples :
|
||||
'358.' → '358' → 3€58 = 3.58
|
||||
'065.' → '065' → 0€65 = 0.65
|
||||
"""
|
||||
digits = re.sub(r"[^0-9]", "", raw_text)
|
||||
if len(digits) < 3:
|
||||
return None
|
||||
return float(f"{digits[:-2]}.{digits[-2:]}")
|
||||
|
||||
|
||||
def _clean_artifact(text: str) -> str:
|
||||
"""Supprime les artefacts HTML/QP du texte.
|
||||
|
||||
Trois types d'artefacts observés dans ce mail Picnic :
|
||||
- '<= /td>' : scission sur '<=' → partie avant
|
||||
- ' = tr>' : scission sur ' = <lettre>' avec espace avant
|
||||
- 'Soda zéro= td>' : scission sur '= <lettre>' sans espace avant
|
||||
"""
|
||||
text = text.split("<=")[0]
|
||||
text = re.split(r" = [a-z/]", text)[0]
|
||||
text = re.split(r"= [a-z]", text)[0] # ex: "zéro= td>" → "zéro"
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _clean_unit(unit: str) -> str:
|
||||
"""Nettoie l'unité ; retourne 'pièce' si le contenu ressemble à du CSS.
|
||||
|
||||
La corruption QP peut faire déborder des valeurs d'attribut style
|
||||
dans le contenu texte d'un td (ex: '; color: #234314; padding: ...').
|
||||
"""
|
||||
if unit.startswith(";") or "font-" in unit or "color:" in unit:
|
||||
return "pièce"
|
||||
return _clean_artifact(unit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Total
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_total(full_text: str) -> float:
|
||||
"""Extrait le montant total payé depuis le texte décodé du mail.
|
||||
|
||||
Picnic affiche le prix splitté : les euros (grand) et les centimes (petit)
|
||||
sont dans des éléments HTML séparés, ce qui donne dans le texte brut :
|
||||
"Total Payé avec Paypal 95 10 ."
|
||||
|
||||
On cherche "Total" (majuscule, pour ne pas capturer "Sous-total")
|
||||
puis les deux premiers groupes de chiffres qui suivent.
|
||||
|
||||
Note : on utilise le texte plutôt que le DOM car lxml redécoupe les
|
||||
tables imbriquées de prix Picnic (euros/centimes dans des <td> sœurs),
|
||||
rendant la navigation par arbre peu fiable.
|
||||
"""
|
||||
# "Total" majuscule pour exclure "Sous-total" (lowercase 't' en français)
|
||||
m = re.search(r"Total[^0-9]{0,60}?(\d+)\s+(\d+)\s*\.", full_text)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
"Montant 'Total' introuvable dans le texte du mail Picnic. "
|
||||
"La structure HTML a peut-être changé."
|
||||
)
|
||||
euros = m.group(1)
|
||||
centimes = m.group(2).zfill(2)
|
||||
return float(f"{euros}.{centimes}")
|
||||
106
tickettracker/pipeline.py
Normal file
106
tickettracker/pipeline.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Pipeline d'import : du fichier brut à la base de données.
|
||||
|
||||
Ce module coordonne les parsers et la couche DB.
|
||||
Il choisit le bon parser selon la source, vérifie les doublons,
|
||||
puis délègue l'insertion à repository.py.
|
||||
|
||||
Usage :
|
||||
from tickettracker.pipeline import import_receipt
|
||||
inserted = import_receipt("samples/picnic_sample.html", source="picnic")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from tickettracker.db import schema, repository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Parsers disponibles — importés à la demande pour éviter de charger
|
||||
# pytesseract/pdfplumber si on n'importe que du Picnic.
|
||||
_SOURCES = ("picnic", "leclerc")
|
||||
|
||||
|
||||
def import_receipt(
|
||||
file_path: str | Path,
|
||||
source: str,
|
||||
db_path: str | Path = schema.DEFAULT_DB_PATH,
|
||||
) -> bool:
|
||||
"""Parse un fichier et l'importe dans la base si non dupliqué.
|
||||
|
||||
Étapes :
|
||||
1. Vérifie que la source est connue et que le fichier existe
|
||||
2. Appelle le bon parser selon `source`
|
||||
3. Vérifie la déduplication via (store, date, total)
|
||||
4. Si nouveau : insère le ticket et ses articles en base
|
||||
5. Retourne True si inséré, False si déjà présent
|
||||
|
||||
Args:
|
||||
file_path: Chemin vers le fichier à importer.
|
||||
(.html pour Picnic, .pdf pour Leclerc)
|
||||
source: 'picnic' ou 'leclerc'.
|
||||
db_path: Chemin vers la base SQLite (créé si absent).
|
||||
|
||||
Returns:
|
||||
True si le ticket a été inséré, False s'il était déjà présent.
|
||||
|
||||
Raises:
|
||||
ValueError: Si `source` est inconnu.
|
||||
FileNotFoundError: Si `file_path` n'existe pas.
|
||||
"""
|
||||
if source not in _SOURCES:
|
||||
raise ValueError(
|
||||
f"Source inconnue : '{source}'. Valeurs acceptées : {_SOURCES}"
|
||||
)
|
||||
|
||||
file_path = Path(file_path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Fichier introuvable : {file_path}")
|
||||
|
||||
# --- Parsing ---
|
||||
receipt = _parse(file_path, source)
|
||||
|
||||
# --- Initialisation de la base (idempotent) ---
|
||||
schema.init_db(db_path)
|
||||
|
||||
# --- Déduplication ---
|
||||
with schema.get_connection(db_path) as conn:
|
||||
date_iso = receipt.date.isoformat()
|
||||
if repository.receipt_exists(conn, receipt.store, date_iso, receipt.total):
|
||||
logger.info(
|
||||
"Ticket déjà présent (store=%s date=%s total=%.2f) — import ignoré.",
|
||||
receipt.store,
|
||||
date_iso,
|
||||
receipt.total,
|
||||
)
|
||||
return False
|
||||
|
||||
repository.insert_receipt(conn, receipt)
|
||||
logger.info(
|
||||
"Ticket importé : store=%s date=%s total=%.2f (%d articles).",
|
||||
receipt.store,
|
||||
date_iso,
|
||||
receipt.total,
|
||||
len(receipt.items),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _parse(file_path: Path, source: str):
|
||||
"""Sélectionne et appelle le parser approprié.
|
||||
|
||||
Les imports sont retardés pour ne charger les dépendances lourdes
|
||||
(pytesseract, pdfplumber) que si nécessaire.
|
||||
"""
|
||||
if source == "picnic":
|
||||
from tickettracker.parsers import picnic
|
||||
html_content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
return picnic.parse(html_content)
|
||||
|
||||
if source == "leclerc":
|
||||
from tickettracker.parsers import leclerc
|
||||
return leclerc.parse(str(file_path))
|
||||
|
||||
# Jamais atteint grâce à la validation en amont, mais satisfait mypy
|
||||
raise ValueError(f"Source inconnue : '{source}'")
|
||||
0
tickettracker/web/__init__.py
Normal file
0
tickettracker/web/__init__.py
Normal file
87
tickettracker/web/api.py
Normal file
87
tickettracker/web/api.py
Normal 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
206
tickettracker/web/app.py
Normal 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)
|
||||
297
tickettracker/web/queries.py
Normal file
297
tickettracker/web/queries.py
Normal 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]
|
||||
48
tickettracker/web/static/style.css
Normal file
48
tickettracker/web/static/style.css
Normal 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;
|
||||
}
|
||||
36
tickettracker/web/templates/base.html
Normal file
36
tickettracker/web/templates/base.html
Normal 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>
|
||||
62
tickettracker/web/templates/compare.html
Normal file
62
tickettracker/web/templates/compare.html
Normal 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 %}
|
||||
104
tickettracker/web/templates/index.html
Normal file
104
tickettracker/web/templates/index.html
Normal 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 %}
|
||||
118
tickettracker/web/templates/product.html
Normal file
118
tickettracker/web/templates/product.html
Normal 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 %}
|
||||
89
tickettracker/web/templates/receipt.html
Normal file
89
tickettracker/web/templates/receipt.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user