feat(T5): editor web mapare operatii (hibrid + fuzzy + on-demand needs_mapping)

T5 reinterpretat: nu import DBF, ci editor web al maparii operatie ROAAUTO ->
cod RAR, cu fuzzy lookup si validare de catre utilizator.

- Contract hibrid: item prestatie accepta cod_prestatie (RAR direct, back-compat)
  SAU cod_op_service+denumire (mapat de gateway prin operations_mapping).
- Ingestie: op intern necunoscut -> submission needs_mapping (nu pleaca la RAR);
  codul rezolvat se scrie inapoi in payload_json -> payload builder + worker neatinse.
- Editor HTMX (_mapari.html + GET /_fragments/mapari, POST /mapari): listeaza
  op-urile nemapate, fuzzy preselecteaza codul RAR, save -> re-rezolvare automata
  (queued / needs_data).
- Fuzzy: rapidfuzz.token_sort_ratio pe denumire normalizata (fara diacritice).
- Nomenclator: seed fallback 18 coduri la boot (offline) + refresh live din worker.
- Cont default id=1 cat timp auth API-key (CORE) nu exista (account_id NULL).
- Endpointuri API: GET /v1/mapari/pending, POST /v1/mapari (respinge cod inexistent).
- 15 teste noi (tests/test_mapping.py); 69 pass total.
- Contract actualizat (docs/api-rar-contract.md), rapidfuzz==3.14.5 in requirements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-15 19:25:21 +00:00
parent 77088daf29
commit a6df3b636f
15 changed files with 788 additions and 16 deletions

View File

@@ -10,13 +10,14 @@ from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..config import get_settings
from ..db import get_connection, read_heartbeat
from ..mapping import load_nomenclator, pending_unmapped, reresolve_account, save_mapping
router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
@@ -83,3 +84,52 @@ def fragment_submissions(request: Request) -> HTMLResponse:
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
finally:
conn.close()
def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse:
return templates.TemplateResponse(
"_mapari.html",
{
"request": request,
"pending": pending_unmapped(conn),
"nomenclator": load_nomenclator(conn),
"message": message,
},
)
@router.get("/_fragments/mapari", response_class=HTMLResponse)
def fragment_mapari(request: Request) -> HTMLResponse:
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR."""
conn = get_connection()
try:
return _render_mapari(request, conn)
finally:
conn.close()
@router.post("/mapari", response_class=HTMLResponse)
def post_mapare(
request: Request,
cod_op_service: str = Form(...),
cod_prestatie: str = Form(...),
account_id: int | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
conn = get_connection()
try:
cod = cod_prestatie.strip().upper()
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
if not exists:
return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = (
f"Mapat {cod_op_service.strip()} -> {cod}. "
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, message=msg)
finally:
conn.close()

View File

@@ -0,0 +1,58 @@
<div id="mapari-section" class="card">
<h2 style="font-size:14px; margin:0 0 12px;">Mapari de rezolvat</h2>
{% if message %}
<div class="flash">{{ message }}</div>
{% endif %}
{% if not pending %}
<div class="empty">Nicio operatie nemapata. Tot ce a venit s-a tradus in coduri RAR.</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
</p>
{% for e in pending %}
{% set top = e.suggestions[0] if e.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="account_id" value="{{ e.account_id }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" required>
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true" checked> auto-send</label>
</div>
<div class="mapcol">
<button type="submit">Salveaza</button>
</div>
</form>
{% endfor %}
{% endif %}
</div>

View File

@@ -26,6 +26,19 @@
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.muted { color:var(--muted); }
a { color:var(--accent); }
.flash { background:#16241c; border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
margin:0 0 12px; font-size:13px; }
.maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line);
flex-wrap:wrap; }
.maprow:last-child { border-bottom:0; }
.mapcol.grow { flex:1 1 280px; min-width:240px; }
.sugg { color:var(--accent); }
select, button, input[type=text] { font:inherit; background:var(--bg); color:var(--ink);
border:1px solid var(--line); border-radius:6px; padding:6px 10px; }
select { max-width:340px; }
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
</style>
</head>
<body>

View File

@@ -18,6 +18,11 @@
</div>
</div>
<!-- incarcat o data; NU poll (sa nu stergem o selectie in curs). Se re-randeaza la salvare. -->
<div hx-get="/_fragments/mapari" hx-trigger="load" hx-swap="outerHTML">
<div class="card"><div class="empty">se incarca mapari…</div></div>
</div>
<div class="card">
<h2 style="font-size:14px; margin:0 0 12px;">Coada submissions</h2>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">