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