Compare commits
4 Commits
be4d4a7076
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b2934ed190 | |||
| 1d8f139c7c | |||
| 93333afffa | |||
| 8af474c928 |
107
tests/test_eml.py
Normal file
107
tests/test_eml.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
Tests de l'extraction HTML depuis les fichiers .eml (pipeline._eml_to_html).
|
||||||
|
|
||||||
|
Stratégie : on construit des .eml synthétiques en mémoire (tmp_path)
|
||||||
|
sans dépendre d'un vrai mail Picnic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tickettracker.pipeline import _eml_to_html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers pour construire des .eml de test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_eml(tmp_path: Path, html: str, add_text_part: bool = True) -> Path:
|
||||||
|
"""Crée un fichier .eml multipart/alternative avec une partie HTML."""
|
||||||
|
boundary = "BOUNDARY123"
|
||||||
|
lines = [
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
f'Content-Type: multipart/alternative; boundary="{boundary}"',
|
||||||
|
"From: picnic@picnic.app",
|
||||||
|
"Subject: Votre commande Picnic",
|
||||||
|
"",
|
||||||
|
f"--{boundary}",
|
||||||
|
]
|
||||||
|
if add_text_part:
|
||||||
|
lines += [
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"Version texte de l'email.",
|
||||||
|
"",
|
||||||
|
f"--{boundary}",
|
||||||
|
]
|
||||||
|
lines += [
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
html,
|
||||||
|
"",
|
||||||
|
f"--{boundary}--",
|
||||||
|
]
|
||||||
|
p = tmp_path / "ticket.eml"
|
||||||
|
p.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _make_eml_no_html(tmp_path: Path) -> Path:
|
||||||
|
"""Crée un .eml sans partie HTML (texte seul)."""
|
||||||
|
boundary = "BOUNDARY456"
|
||||||
|
content = "\n".join([
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
f'Content-Type: multipart/alternative; boundary="{boundary}"',
|
||||||
|
"",
|
||||||
|
f"--{boundary}",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"Texte seul, pas de HTML.",
|
||||||
|
"",
|
||||||
|
f"--{boundary}--",
|
||||||
|
])
|
||||||
|
p = tmp_path / "no_html.eml"
|
||||||
|
p.write_text(content, encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_eml_to_html_retourne_le_contenu_html(tmp_path):
|
||||||
|
"""_eml_to_html extrait correctement le HTML d'un .eml multipart."""
|
||||||
|
html = "<html><body><p>Commande Picnic</p></body></html>"
|
||||||
|
eml = _make_eml(tmp_path, html)
|
||||||
|
result = _eml_to_html(eml)
|
||||||
|
assert "Commande Picnic" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_eml_to_html_contient_les_balises(tmp_path):
|
||||||
|
"""Le HTML retourné contient bien les balises HTML."""
|
||||||
|
html = "<html><body><h1>Titre</h1></body></html>"
|
||||||
|
eml = _make_eml(tmp_path, html)
|
||||||
|
result = _eml_to_html(eml)
|
||||||
|
assert "<h1>" in result or "Titre" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_eml_to_html_retourne_str(tmp_path):
|
||||||
|
"""_eml_to_html retourne une chaîne de caractères."""
|
||||||
|
eml = _make_eml(tmp_path, "<html><body>test</body></html>")
|
||||||
|
result = _eml_to_html(eml)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eml_to_html_sans_partie_texte(tmp_path):
|
||||||
|
"""Fonctionne aussi sur un .eml avec uniquement une partie HTML."""
|
||||||
|
html = "<html><body><p>HTML only</p></body></html>"
|
||||||
|
eml = _make_eml(tmp_path, html, add_text_part=False)
|
||||||
|
result = _eml_to_html(eml)
|
||||||
|
assert "HTML only" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_eml_to_html_leve_valueerror_si_pas_de_html(tmp_path):
|
||||||
|
"""Lève ValueError si le .eml ne contient aucune partie HTML."""
|
||||||
|
eml = _make_eml_no_html(tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="Aucune partie HTML"):
|
||||||
|
_eml_to_html(eml)
|
||||||
@@ -268,3 +268,118 @@ def test_api_product_history_not_found(client_with_data):
|
|||||||
"""GET /api/product/<inconnu>/history retourne 404."""
|
"""GET /api/product/<inconnu>/history retourne 404."""
|
||||||
resp = client_with_data.get("/api/product/ProduitInexistant/history")
|
resp = client_with_data.get("/api/product/ProduitInexistant/history")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests /matches — DB vide et avec données
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_matches_page_empty_200(client):
|
||||||
|
"""/matches accessible même si la base est vide."""
|
||||||
|
resp = client.get("/matches")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_page_shows_no_pending(client):
|
||||||
|
"""/matches sans données affiche un message indiquant qu'il n'y a rien à valider."""
|
||||||
|
resp = client.get("/matches")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Le template affiche soit "Aucune paire" soit un message d'invitation
|
||||||
|
assert "match" in resp.text.lower() or "paire" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path_with_match(db_path: Path) -> Path:
|
||||||
|
"""Base avec 1 paire fuzzy pending dans product_matches."""
|
||||||
|
conn = schema.get_connection(db_path)
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO product_matches "
|
||||||
|
"(name_picnic, name_leclerc, score, status, created_at) "
|
||||||
|
"VALUES ('lait demi-écrémé', 'lait demi ecreme', 92.0, 'pending', '2026-01-01T00:00:00')"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_match(db_path_with_match: Path):
|
||||||
|
"""TestClient avec une paire fuzzy pending."""
|
||||||
|
with patch("tickettracker.config.DB_PATH", db_path_with_match):
|
||||||
|
yield TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_page_shows_pending(client_with_match):
|
||||||
|
"""/matches affiche la paire pending."""
|
||||||
|
resp = client_with_match.get("/matches")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "lait demi" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_match_validate_200(client_with_match, db_path_with_match):
|
||||||
|
"""POST /api/match/1/validate retourne 200 et met à jour le statut."""
|
||||||
|
resp = client_with_match.post("/api/match/1/validate")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Vérification en base
|
||||||
|
conn = schema.get_connection(db_path_with_match)
|
||||||
|
status = conn.execute("SELECT status FROM product_matches WHERE id=1").fetchone()["status"]
|
||||||
|
conn.close()
|
||||||
|
assert status == "validated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_match_reject_200(client_with_match, db_path_with_match):
|
||||||
|
"""POST /api/match/1/reject retourne 200 et met à jour le statut."""
|
||||||
|
resp = client_with_match.post("/api/match/1/reject")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
conn = schema.get_connection(db_path_with_match)
|
||||||
|
status = conn.execute("SELECT status FROM product_matches WHERE id=1").fetchone()["status"]
|
||||||
|
conn.close()
|
||||||
|
assert status == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_match_validate_not_found(client):
|
||||||
|
"""POST /api/match/999/validate retourne 404."""
|
||||||
|
resp = client.post("/api/match/999/validate")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_match_reject_not_found(client):
|
||||||
|
"""POST /api/match/999/reject retourne 404."""
|
||||||
|
resp = client.post("/api/match/999/reject")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_compare_includes_fuzzy_match(db_path_with_data: Path):
|
||||||
|
"""GET /api/compare retourne les fuzzy matches validés dans les résultats."""
|
||||||
|
# Insérer un fuzzy match validé
|
||||||
|
conn = schema.get_connection(db_path_with_data)
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
# Normaliser les articles pour avoir les données dans price_history
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE items SET name_normalized = 'lait demi-écrémé' "
|
||||||
|
"WHERE name_raw = 'Lait demi-écremé'"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE items SET name_normalized = 'lait demi ecreme' "
|
||||||
|
"WHERE name_raw = 'LAIT DEMI ECREME'"
|
||||||
|
)
|
||||||
|
# Insérer un fuzzy match validé liant les deux noms
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO product_matches "
|
||||||
|
"(name_picnic, name_leclerc, score, status, created_at) "
|
||||||
|
"VALUES ('lait demi-écrémé', 'lait demi ecreme', 92.0, 'validated', '2026-01-01T00:00:00')"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with patch("tickettracker.config.DB_PATH", db_path_with_data):
|
||||||
|
test_client = TestClient(app)
|
||||||
|
resp = test_client.get("/api/compare")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
products = resp.json()
|
||||||
|
match_types = [p["match_type"] for p in products]
|
||||||
|
assert "fuzzy" in match_types
|
||||||
|
|||||||
@@ -51,12 +51,17 @@ def find_fuzzy_matches(
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Produit cartésien filtré par seuil
|
# Produit cartésien filtré par seuil
|
||||||
|
# On compare uniquement le nom (avant le premier " | ") pour éviter que
|
||||||
|
# les différences de marque/quantité ("| MDD | 1kg" vs "| - | -") ne
|
||||||
|
# pénalisent artificiellement le score.
|
||||||
matches = []
|
matches = []
|
||||||
for p in picnic_names:
|
for p in picnic_names:
|
||||||
|
p_name = p.split(" | ")[0].strip()
|
||||||
for lec in leclerc_names:
|
for lec in leclerc_names:
|
||||||
if p == lec:
|
if p == lec:
|
||||||
continue # exact match déjà géré par get_compare_prices
|
continue # exact match déjà géré par get_compare_prices
|
||||||
score = fuzz.token_sort_ratio(p, lec)
|
lec_name = lec.split(" | ")[0].strip()
|
||||||
|
score = fuzz.token_sort_ratio(p_name, lec_name)
|
||||||
if score >= threshold:
|
if score >= threshold:
|
||||||
matches.append({"name_picnic": p, "name_leclerc": lec, "score": score})
|
matches.append({"name_picnic": p, "name_leclerc": lec, "score": score})
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def fetch_unnormalized(
|
|||||||
) -> list[sqlite3.Row]:
|
) -> list[sqlite3.Row]:
|
||||||
"""Retourne les articles dont name_normalized est NULL.
|
"""Retourne les articles dont name_normalized est NULL.
|
||||||
|
|
||||||
Chaque Row expose les clés : id, name_raw, receipt_id.
|
Chaque Row expose les clés : id, name_raw, unit, receipt_id.
|
||||||
Trié par id pour un traitement reproductible.
|
Trié par id pour un traitement reproductible.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -150,7 +150,7 @@ def fetch_unnormalized(
|
|||||||
Returns:
|
Returns:
|
||||||
Liste de sqlite3.Row.
|
Liste de sqlite3.Row.
|
||||||
"""
|
"""
|
||||||
sql = "SELECT id, name_raw, receipt_id FROM items WHERE name_normalized IS NULL ORDER BY id"
|
sql = "SELECT id, name_raw, unit, receipt_id FROM items WHERE name_normalized IS NULL ORDER BY id"
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
sql += f" LIMIT {int(limit)}"
|
sql += f" LIMIT {int(limit)}"
|
||||||
return conn.execute(sql).fetchall()
|
return conn.execute(sql).fetchall()
|
||||||
|
|||||||
@@ -229,7 +229,13 @@ def normalize_all_in_db(
|
|||||||
|
|
||||||
for start in range(0, total, batch_size):
|
for start in range(0, total, batch_size):
|
||||||
batch = items[start: start + batch_size]
|
batch = items[start: start + batch_size]
|
||||||
raw_names = [row["name_raw"] for row in batch]
|
# On inclut l'unité/poids (ex: "250 g", "20 sachets") dans le nom
|
||||||
|
# envoyé au LLM pour qu'il puisse le placer dans le champ format.
|
||||||
|
# Pour les articles sans unité (Leclerc OCR), unit est None ou "".
|
||||||
|
raw_names = [
|
||||||
|
f"{row['name_raw']} {row['unit']}".strip() if row["unit"] else row["name_raw"]
|
||||||
|
for row in batch
|
||||||
|
]
|
||||||
|
|
||||||
# --- Tentative batch ---
|
# --- Tentative batch ---
|
||||||
try:
|
try:
|
||||||
@@ -246,7 +252,7 @@ def normalize_all_in_db(
|
|||||||
# tente le fallback un par un
|
# tente le fallback un par un
|
||||||
if all(r is None for r in results):
|
if all(r is None for r in results):
|
||||||
logger.debug("Fallback unitaire pour le batch %d–%d.", start, start + len(batch))
|
logger.debug("Fallback unitaire pour le batch %d–%d.", start, start + len(batch))
|
||||||
results = [normalize_product_name(name) for name in raw_names]
|
results = [normalize_product_name(name) for name in raw_names] # raw_names contient déjà l'unité
|
||||||
|
|
||||||
# --- Mise à jour ou affichage ---
|
# --- Mise à jour ou affichage ---
|
||||||
for item, normalized in zip(batch, results):
|
for item, normalized in zip(batch, results):
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ Usage :
|
|||||||
inserted = import_receipt("samples/picnic_sample.html", source="picnic")
|
inserted = import_receipt("samples/picnic_sample.html", source="picnic")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import email
|
||||||
import logging
|
import logging
|
||||||
|
from email import policy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tickettracker.db import schema, repository
|
from tickettracker.db import schema, repository
|
||||||
@@ -95,7 +97,10 @@ def _parse(file_path: Path, source: str):
|
|||||||
"""
|
"""
|
||||||
if source == "picnic":
|
if source == "picnic":
|
||||||
from tickettracker.parsers import picnic
|
from tickettracker.parsers import picnic
|
||||||
html_content = file_path.read_text(encoding="utf-8", errors="replace")
|
if file_path.suffix.lower() == ".eml":
|
||||||
|
html_content = _eml_to_html(file_path)
|
||||||
|
else:
|
||||||
|
html_content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
return picnic.parse(html_content)
|
return picnic.parse(html_content)
|
||||||
|
|
||||||
if source == "leclerc":
|
if source == "leclerc":
|
||||||
@@ -104,3 +109,40 @@ def _parse(file_path: Path, source: str):
|
|||||||
|
|
||||||
# Jamais atteint grâce à la validation en amont, mais satisfait mypy
|
# Jamais atteint grâce à la validation en amont, mais satisfait mypy
|
||||||
raise ValueError(f"Source inconnue : '{source}'")
|
raise ValueError(f"Source inconnue : '{source}'")
|
||||||
|
|
||||||
|
|
||||||
|
def _eml_to_html(file_path: Path) -> str:
|
||||||
|
"""Extrait la partie HTML d'un fichier .eml (email de confirmation Picnic).
|
||||||
|
|
||||||
|
Retourne le corps HTML brut, encore encodé en Quoted-Printable (QP),
|
||||||
|
exactement comme si on lisait un fichier .html sauvegardé depuis le mail.
|
||||||
|
Le parser Picnic (picnic._decode_and_parse) se charge lui-même du décodage QP.
|
||||||
|
|
||||||
|
Pourquoi ne pas utiliser policy.default / get_content() ?
|
||||||
|
Parce que cette API décode déjà les accents (=C3=A9 → é), ce qui empêche
|
||||||
|
picnic.py de les retrouver via sa propre pipeline QP → UTF-8.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Chemin vers le fichier .eml.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Corps HTML brut (QP-encodé) sous forme de chaîne ASCII.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si aucune partie HTML n'est trouvée dans le .eml.
|
||||||
|
"""
|
||||||
|
raw = file_path.read_bytes()
|
||||||
|
# On utilise l'ancienne API (sans policy.default) pour garder le payload brut
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() == "text/html":
|
||||||
|
# decode=False → payload brut, encore QP-encodé, en str ASCII
|
||||||
|
payload = part.get_payload(decode=False)
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
return payload.decode("ascii", errors="replace")
|
||||||
|
return payload # déjà une str
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"Aucune partie HTML trouvée dans le fichier .eml : {file_path.name}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ async def page_matches(request: Request):
|
|||||||
"matches.html",
|
"matches.html",
|
||||||
{
|
{
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
|
"pending_json": json.dumps(pending),
|
||||||
"validated_count": validated_count,
|
"validated_count": validated_count,
|
||||||
"rejected_count": rejected_count,
|
"rejected_count": rejected_count,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,7 +71,90 @@
|
|||||||
.score-medium { background: #fff3cd; color: #856404; }
|
.score-medium { background: #fff3cd; color: #856404; }
|
||||||
.score-low { background: #f8d7da; color: #721c24; }
|
.score-low { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
/* Boutons valider/rejeter dans la table matches */
|
/* ── Carte de révision match (page /matches) ── */
|
||||||
|
|
||||||
|
.match-card {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barre de progression + score */
|
||||||
|
.match-progress {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Les deux colonnes Picnic / Leclerc */
|
||||||
|
.match-sides {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-side {
|
||||||
|
background: var(--pico-card-background-color, #f8f9fa);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-side-picnic { border-left: 4px solid #4a9eff; }
|
||||||
|
.match-side-leclerc { border-left: 4px solid #ff6b35; }
|
||||||
|
|
||||||
|
.match-store-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nom du produit : petit, word-wrap pour les noms longs */
|
||||||
|
.match-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-price {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-vs {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutons d'action */
|
||||||
|
.match-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-buttons button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Garder .btn-validate / .btn-reject pour rétrocompat éventuelle */
|
||||||
.btn-validate {
|
.btn-validate {
|
||||||
background: var(--pico-primary);
|
background: var(--pico-primary);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -5,69 +5,152 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Correspondances Picnic ↔ Leclerc</h1>
|
<h1>Correspondances Picnic ↔ Leclerc</h1>
|
||||||
|
|
||||||
<p>
|
<!-- Compteurs -->
|
||||||
Ces paires ont été détectées automatiquement par fuzzy matching.
|
<div class="stat-grid" id="stat-grid">
|
||||||
Validez celles qui désignent le même produit pour enrichir la comparaison de prix.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Résumé statistiques -->
|
|
||||||
<div class="stat-grid">
|
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h3>{{ pending | length }}</h3>
|
<h3 id="stat-pending">{{ pending | length }}</h3>
|
||||||
<p>En attente</p>
|
<p>En attente</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h3>{{ validated_count }}</h3>
|
<h3 id="stat-validated">{{ validated_count }}</h3>
|
||||||
<p>Validées</p>
|
<p>Validées</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h3>{{ rejected_count }}</h3>
|
<h3 id="stat-rejected">{{ rejected_count }}</h3>
|
||||||
<p>Rejetées</p>
|
<p>Rejetées</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if pending %}
|
{% if pending %}
|
||||||
<article>
|
|
||||||
<h2>Paires à valider</h2>
|
<!-- Carte de révision -->
|
||||||
<div class="overflow-auto">
|
<div id="review-zone">
|
||||||
<table>
|
<article id="match-card" class="match-card">
|
||||||
<thead>
|
|
||||||
<tr>
|
<!-- Barre de progression -->
|
||||||
<th>Produit Picnic</th>
|
<div class="match-progress">
|
||||||
<th>Prix moy.</th>
|
<span id="progress-text"></span>
|
||||||
<th>Produit Leclerc</th>
|
<span id="score-badge" class="match-score"></span>
|
||||||
<th>Prix moy.</th>
|
</div>
|
||||||
<th>Score</th>
|
|
||||||
<th>Action</th>
|
<!-- Les deux noms face à face -->
|
||||||
</tr>
|
<div class="match-sides">
|
||||||
</thead>
|
<div class="match-side match-side-picnic">
|
||||||
<tbody>
|
<div class="match-store-label">🛍 Picnic</div>
|
||||||
{% for m in pending %}
|
<div class="match-name" id="name-picnic"></div>
|
||||||
<tr>
|
<div class="match-price" id="price-picnic"></div>
|
||||||
<td>{{ m.name_picnic }}</td>
|
</div>
|
||||||
<td>{% if m.price_picnic %}{{ "%.2f"|format(m.price_picnic) }} €{% else %}—{% endif %}</td>
|
<div class="match-vs">↔</div>
|
||||||
<td>{{ m.name_leclerc }}</td>
|
<div class="match-side match-side-leclerc">
|
||||||
<td>{% if m.price_leclerc %}{{ "%.2f"|format(m.price_leclerc) }} €{% else %}—{% endif %}</td>
|
<div class="match-store-label">🏪 Leclerc</div>
|
||||||
<td>
|
<div class="match-name" id="name-leclerc"></div>
|
||||||
<small class="match-score {% if m.score >= 95 %}score-high{% elif m.score >= 85 %}score-medium{% else %}score-low{% endif %}">
|
<div class="match-price" id="price-leclerc"></div>
|
||||||
{{ "%.0f"|format(m.score) }}%
|
</div>
|
||||||
</small>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="match-actions">
|
<!-- Boutons d'action -->
|
||||||
<form method="post" action="/api/match/{{ m.id }}/validate" style="display:inline">
|
<div class="match-buttons">
|
||||||
<button type="submit" class="btn-validate">✓ Valider</button>
|
<button id="btn-reject" class="btn-reject secondary outline" onclick="decide('reject')">
|
||||||
</form>
|
✗ Rejeter
|
||||||
<form method="post" action="/api/match/{{ m.id }}/reject" style="display:inline">
|
</button>
|
||||||
<button type="submit" class="btn-reject secondary outline">✗ Rejeter</button>
|
<button id="btn-validate" class="btn-validate" onclick="decide('validate')">
|
||||||
</form>
|
✓ Valider — c'est le même produit
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
<!-- Message d'erreur éventuel -->
|
||||||
</table>
|
<p id="error-msg" style="color:var(--pico-color-red-500);display:none;text-align:center;margin-top:0.5rem"></p>
|
||||||
</div>
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message "tout fait" (caché au départ) -->
|
||||||
|
<article id="done-card" style="display:none;text-align:center;padding:2rem">
|
||||||
|
<h2>Tout traité ✓</h2>
|
||||||
|
<p id="done-summary"></p>
|
||||||
|
<a href="/matches" role="button" class="secondary outline">Recharger la page</a>
|
||||||
|
|
||||||
|
<a href="/compare" role="button">Voir la comparaison →</a>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Liste des paires en attente, injectée depuis le serveur */
|
||||||
|
const MATCHES = {{ pending_json | safe }};
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
let sessionValidated = 0;
|
||||||
|
let sessionRejected = 0;
|
||||||
|
|
||||||
|
function fmt_price(p) {
|
||||||
|
return p !== null && p !== undefined ? p.toFixed(2) + ' €' : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_current() {
|
||||||
|
if (idx >= MATCHES.length) {
|
||||||
|
/* Plus rien à traiter */
|
||||||
|
document.getElementById('review-zone').style.display = 'none';
|
||||||
|
const done = document.getElementById('done-card');
|
||||||
|
done.style.display = '';
|
||||||
|
document.getElementById('stat-pending').textContent = '0';
|
||||||
|
document.getElementById('done-summary').textContent =
|
||||||
|
sessionValidated + ' validée(s), ' + sessionRejected + ' rejetée(s) cette session.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = MATCHES[idx];
|
||||||
|
const score = Math.round(m.score);
|
||||||
|
const scoreEl = document.getElementById('score-badge');
|
||||||
|
scoreEl.textContent = 'Score : ' + score + '%';
|
||||||
|
scoreEl.className = 'match-score ' +
|
||||||
|
(score >= 95 ? 'score-high' : score >= 85 ? 'score-medium' : 'score-low');
|
||||||
|
|
||||||
|
document.getElementById('progress-text').textContent =
|
||||||
|
'Paire ' + (idx + 1) + ' / ' + MATCHES.length;
|
||||||
|
|
||||||
|
document.getElementById('name-picnic').textContent = m.name_picnic;
|
||||||
|
document.getElementById('name-leclerc').textContent = m.name_leclerc;
|
||||||
|
document.getElementById('price-picnic').textContent = 'Prix moyen : ' + fmt_price(m.price_picnic);
|
||||||
|
document.getElementById('price-leclerc').textContent = 'Prix moyen : ' + fmt_price(m.price_leclerc);
|
||||||
|
|
||||||
|
document.getElementById('error-msg').style.display = 'none';
|
||||||
|
document.getElementById('btn-validate').disabled = false;
|
||||||
|
document.getElementById('btn-reject').disabled = false;
|
||||||
|
document.getElementById('stat-pending').textContent = MATCHES.length - idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide(action) {
|
||||||
|
const m = MATCHES[idx];
|
||||||
|
document.getElementById('btn-validate').disabled = true;
|
||||||
|
document.getElementById('btn-reject').disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/match/' + m.id + '/' + action, {method: 'POST'});
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('error-msg').textContent = 'Erreur : ' + e.message;
|
||||||
|
document.getElementById('error-msg').style.display = '';
|
||||||
|
document.getElementById('btn-validate').disabled = false;
|
||||||
|
document.getElementById('btn-reject').disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'validate') {
|
||||||
|
sessionValidated++;
|
||||||
|
document.getElementById('stat-validated').textContent =
|
||||||
|
parseInt(document.getElementById('stat-validated').textContent) + 1;
|
||||||
|
} else {
|
||||||
|
sessionRejected++;
|
||||||
|
document.getElementById('stat-rejected').textContent =
|
||||||
|
parseInt(document.getElementById('stat-rejected').textContent) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx++;
|
||||||
|
show_current();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Démarrage */
|
||||||
|
show_current();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<article>
|
<article>
|
||||||
<p>
|
<p>
|
||||||
@@ -77,6 +160,7 @@
|
|||||||
<pre><code>python -m tickettracker.cli match --threshold 85</code></pre>
|
<pre><code>python -m tickettracker.cli match --threshold 85</code></pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
Toutes les paires ont été traitées ({{ validated_count }} validées, {{ rejected_count }} rejetées).
|
Toutes les paires ont été traitées ({{ validated_count }} validées, {{ rejected_count }} rejetées).
|
||||||
|
<a href="/compare">Voir la comparaison →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Reference in New Issue
Block a user