Compare commits

...

1 Commits

Author SHA1 Message Date
b2934ed190 feat: refonte UX page /matches — révision carte par carte
- Remplace la table trop large par une carte de révision centrée
- Une paire à la fois : noms wrappés, score, prix moyens
- Valider/Rejeter via fetch() sans rechargement de page
- Passage automatique à la paire suivante après chaque action
- Compteurs mis à jour en temps réel (en attente/validées/rejetées)
- Message de fin avec lien vers /compare quand tout est traité
- Ajout tests pytest pour la page /matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:45:51 +01:00
4 changed files with 333 additions and 50 deletions

View File

@@ -268,3 +268,118 @@ 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
# ---------------------------------------------------------------------------
# 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

View File

@@ -188,6 +188,7 @@ async def page_matches(request: Request):
"matches.html",
{
"pending": pending,
"pending_json": json.dumps(pending),
"validated_count": validated_count,
"rejected_count": rejected_count,
},

View File

@@ -71,7 +71,90 @@
.score-medium { background: #fff3cd; color: #856404; }
.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 {
background: var(--pico-primary);
color: white;

View File

@@ -5,69 +5,152 @@
{% block content %}
<h1>Correspondances Picnic ↔ Leclerc</h1>
<p>
Ces paires ont été détectées automatiquement par fuzzy matching.
Validez celles qui désignent le même produit pour enrichir la comparaison de prix.
</p>
<!-- Résumé statistiques -->
<div class="stat-grid">
<!-- Compteurs -->
<div class="stat-grid" id="stat-grid">
<article class="stat-card">
<h3>{{ pending | length }}</h3>
<h3 id="stat-pending">{{ pending | length }}</h3>
<p>En attente</p>
</article>
<article class="stat-card">
<h3>{{ validated_count }}</h3>
<h3 id="stat-validated">{{ validated_count }}</h3>
<p>Validées</p>
</article>
<article class="stat-card">
<h3>{{ rejected_count }}</h3>
<h3 id="stat-rejected">{{ rejected_count }}</h3>
<p>Rejetées</p>
</article>
</div>
{% if pending %}
<article>
<h2>Paires à valider</h2>
<div class="overflow-auto">
<table>
<thead>
<tr>
<th>Produit Picnic</th>
<th>Prix moy.</th>
<th>Produit Leclerc</th>
<th>Prix moy.</th>
<th>Score</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for m in pending %}
<tr>
<td>{{ m.name_picnic }}</td>
<td>{% if m.price_picnic %}{{ "%.2f"|format(m.price_picnic) }} €{% else %}—{% endif %}</td>
<td>{{ m.name_leclerc }}</td>
<td>{% if m.price_leclerc %}{{ "%.2f"|format(m.price_leclerc) }} €{% else %}—{% endif %}</td>
<td>
<small class="match-score {% if m.score >= 95 %}score-high{% elif m.score >= 85 %}score-medium{% else %}score-low{% endif %}">
{{ "%.0f"|format(m.score) }}%
</small>
</td>
<td class="match-actions">
<form method="post" action="/api/match/{{ m.id }}/validate" style="display:inline">
<button type="submit" class="btn-validate">✓ Valider</button>
</form>
<form method="post" action="/api/match/{{ m.id }}/reject" style="display:inline">
<button type="submit" class="btn-reject secondary outline">✗ Rejeter</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Carte de révision -->
<div id="review-zone">
<article id="match-card" class="match-card">
<!-- Barre de progression -->
<div class="match-progress">
<span id="progress-text"></span>
<span id="score-badge" class="match-score"></span>
</div>
<!-- Les deux noms face à face -->
<div class="match-sides">
<div class="match-side match-side-picnic">
<div class="match-store-label">🛍 Picnic</div>
<div class="match-name" id="name-picnic"></div>
<div class="match-price" id="price-picnic"></div>
</div>
<div class="match-vs"></div>
<div class="match-side match-side-leclerc">
<div class="match-store-label">🏪 Leclerc</div>
<div class="match-name" id="name-leclerc"></div>
<div class="match-price" id="price-leclerc"></div>
</div>
</div>
<!-- Boutons d'action -->
<div class="match-buttons">
<button id="btn-reject" class="btn-reject secondary outline" onclick="decide('reject')">
✗ Rejeter
</button>
<button id="btn-validate" class="btn-validate" onclick="decide('validate')">
✓ Valider — c'est le même produit
</button>
</div>
<!-- Message d'erreur éventuel -->
<p id="error-msg" style="color:var(--pico-color-red-500);display:none;text-align:center;margin-top:0.5rem"></p>
</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>
&nbsp;
<a href="/compare" role="button">Voir la comparaison →</a>
</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 %}
<article>
<p>
@@ -77,6 +160,7 @@
<pre><code>python -m tickettracker.cli match --threshold 85</code></pre>
{% else %}
Toutes les paires ont été traitées ({{ validated_count }} validées, {{ rejected_count }} rejetées).
<a href="/compare">Voir la comparaison →</a>
{% endif %}
</p>
</article>