feat: migration Windows → Ubuntu, stabilisation suite de tests

- Ajout venv Python (.venv) avec pip bootstrap (python3-venv absent)
- Correction OCR Linux : marqueur TTC/TVA tolère la confusion T↔I
  (Tesseract 5.3.4 Linux lit parfois "TIc" au lieu de "TTC")
- test_leclerc.py : skipif si Tesseract absent, xfail pour test de somme
  (précision OCR variable entre plateformes, solution LLM vision prévue)
- Résultat : 77 passent, 1 xfail, 0 échec (vs 78 sur Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 18:53:41 +01:00
parent bb62bd6eb6
commit 1e5fc97bb7
24 changed files with 3181 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Couche base de données SQLite

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