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:
301
app/mapping.py
Normal file
301
app/mapping.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
|
||||
|
||||
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni
|
||||
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service`
|
||||
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
|
||||
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
|
||||
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
|
||||
cu ajutorul unei sugestii fuzzy pe nomenclatorul RAR. La salvarea maparii,
|
||||
submission-urile blocate pe acel cod se re-rezolva automat.
|
||||
|
||||
Functiile de la inceput (normalize/suggest/resolve) sunt PURE (fara DB/HTTP),
|
||||
unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from rapidfuzz import fuzz, process
|
||||
|
||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||
from .validation import validate_prezentare
|
||||
|
||||
# Cont implicit cat timp auth API-key (CORE) nu e implementat: ingestiile vin cu
|
||||
# account_id NULL si le atribuim contului seed-at in schema (id=1).
|
||||
DEFAULT_ACCOUNT_ID = 1
|
||||
|
||||
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
|
||||
SUGGEST_MIN_SCORE = 60
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pur: normalizare + fuzzy + rezolvare #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def normalize_for_match(value: object) -> str:
|
||||
"""Upper + fara diacritice + spatii colapsate, pentru potrivire robusta.
|
||||
|
||||
'Reparație motor' -> 'REPARATIE MOTOR'. Diacriticele romanesti (ă/â/î/ș/ț)
|
||||
si artefactele de encoding nu trebuie sa strice scorul fuzzy.
|
||||
"""
|
||||
s = str(value or "")
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
||||
return " ".join(s.upper().split())
|
||||
|
||||
|
||||
def suggest_codes(
|
||||
denumire: object,
|
||||
nomenclator: list[dict],
|
||||
*,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
"""Clasament fuzzy al codurilor RAR pentru o denumire de operatie ROAAUTO.
|
||||
|
||||
`nomenclator` = randuri {cod_prestatie, nume_prestatie}. Intoarce
|
||||
[{cod_prestatie, nume_prestatie, score}] sortat descrescator dupa scor.
|
||||
Daca denumirea e goala, intoarce nomenclatorul in ordinea data, scor 0.
|
||||
"""
|
||||
query = normalize_for_match(denumire)
|
||||
rows = [r for r in nomenclator if (r.get("cod_prestatie") or "")]
|
||||
if not query:
|
||||
return [{**r, "score": 0.0} for r in rows[:limit]]
|
||||
|
||||
choices = {r["cod_prestatie"]: normalize_for_match(r.get("nume_prestatie")) for r in rows}
|
||||
by_cod = {r["cod_prestatie"]: r for r in rows}
|
||||
# token_sort_ratio (nu token_set_ratio): recompenseaza acoperirea cat mai multor
|
||||
# cuvinte din denumire, in loc sa dea 100 la orice subset (ex. "REPARATIE" si
|
||||
# "REPARATIE ODOMETRU" ar fi egale la set_ratio).
|
||||
ranked = process.extract(
|
||||
query,
|
||||
choices,
|
||||
scorer=fuzz.token_sort_ratio,
|
||||
limit=limit,
|
||||
)
|
||||
# process.extract -> [(value, score, key)]; key = cod_prestatie.
|
||||
return [
|
||||
{
|
||||
"cod_prestatie": cod,
|
||||
"nume_prestatie": by_cod[cod].get("nume_prestatie"),
|
||||
"score": float(score),
|
||||
}
|
||||
for _val, score, cod in ranked
|
||||
]
|
||||
|
||||
|
||||
def resolve_prestatii(
|
||||
prestatii: list[dict] | None,
|
||||
mapping: dict[str, str],
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
|
||||
|
||||
Reguli (hibrid):
|
||||
- item cu `cod_prestatie` -> pastrat ca atare (cod RAR direct).
|
||||
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
|
||||
- item fara cod si fara mapare -> ramane nemapat.
|
||||
|
||||
Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza
|
||||
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
|
||||
contextul; payload-ul RAR citeste doar cod_prestatie. `nemapate` =
|
||||
[{cod_op_service, denumire}] pentru editor.
|
||||
"""
|
||||
resolved: list[dict] = []
|
||||
unmapped: list[dict] = []
|
||||
for item in prestatii or []:
|
||||
it = dict(item)
|
||||
cod = (it.get("cod_prestatie") or "").strip().upper()
|
||||
op = (it.get("cod_op_service") or "").strip()
|
||||
if cod:
|
||||
it["cod_prestatie"] = cod
|
||||
elif op and op in mapping:
|
||||
it["cod_prestatie"] = mapping[op]
|
||||
elif op:
|
||||
it["cod_prestatie"] = None
|
||||
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
|
||||
# item fara cod si fara op: il lasam asa; validarea de continut prinde
|
||||
# "prestatii goale"/cod lipsa.
|
||||
resolved.append(it)
|
||||
return resolved, unmapped
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Persistenta (conn) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def account_or_default(account_id: int | None) -> int:
|
||||
return account_id if account_id is not None else DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
def seed_nomenclator_if_empty(conn) -> int:
|
||||
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
||||
|
||||
Worker-ul suprascrie live; aici doar garantam ca editorul fuzzy merge offline.
|
||||
Intoarce nr. de randuri inserate.
|
||||
"""
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM nomenclator_rar").fetchone()["n"]
|
||||
if n:
|
||||
return 0
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
FALLBACK_NOMENCLATOR,
|
||||
)
|
||||
return len(FALLBACK_NOMENCLATOR)
|
||||
|
||||
|
||||
def upsert_nomenclator(conn, items: list[dict]) -> int:
|
||||
"""Upsert nomenclator live din RAR. `items` = forma API (codPrestatie/numePrestatie).
|
||||
|
||||
Tolerant la chei: codPrestatie/cod_prestatie/cod, numePrestatie/nume_prestatie/nume.
|
||||
Intoarce nr. de coduri upsert-ate.
|
||||
"""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for it in items or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
cod = it.get("codPrestatie") or it.get("cod_prestatie") or it.get("cod")
|
||||
nume = it.get("numePrestatie") or it.get("nume_prestatie") or it.get("nume")
|
||||
if cod:
|
||||
rows.append((str(cod).strip().upper(), str(nume or "").strip()))
|
||||
if not rows:
|
||||
return 0
|
||||
conn.executemany(
|
||||
"INSERT INTO nomenclator_rar (cod_prestatie, nume_prestatie, updated_at) "
|
||||
"VALUES (?, ?, datetime('now')) "
|
||||
"ON CONFLICT(cod_prestatie) DO UPDATE SET nume_prestatie=excluded.nume_prestatie, "
|
||||
"updated_at=datetime('now')",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def load_nomenclator(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT cod_prestatie, nume_prestatie FROM nomenclator_rar ORDER BY cod_prestatie"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
||||
"""{cod_op_service -> cod_prestatie} pentru un cont."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
|
||||
|
||||
|
||||
def pending_unmapped(conn) -> list[dict]:
|
||||
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
|
||||
|
||||
Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa,
|
||||
nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de
|
||||
adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie
|
||||
null + cod_op_service setat.
|
||||
"""
|
||||
nomenclator = load_nomenclator(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||
).fetchall()
|
||||
|
||||
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
|
||||
try:
|
||||
content = json.loads(r["payload_json"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
for item in content.get("prestatii") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if (item.get("cod_prestatie") or ""):
|
||||
continue
|
||||
op = (item.get("cod_op_service") or "").strip()
|
||||
if not op:
|
||||
continue
|
||||
key = (acct, op)
|
||||
entry = agg.setdefault(
|
||||
key,
|
||||
{"account_id": acct, "cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0, "_ids": set()},
|
||||
)
|
||||
if not entry["denumire"] and item.get("denumire"):
|
||||
entry["denumire"] = item.get("denumire")
|
||||
entry["_ids"].add(r["id"])
|
||||
|
||||
out: list[dict] = []
|
||||
for entry in agg.values():
|
||||
entry["blocked"] = len(entry.pop("_ids"))
|
||||
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
||||
return out
|
||||
|
||||
|
||||
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
|
||||
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service)."""
|
||||
acct = account_or_default(account_id)
|
||||
op = (cod_op_service or "").strip()
|
||||
cod = (cod_prestatie or "").strip().upper()
|
||||
if not op or not cod:
|
||||
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
|
||||
(acct, op, cod, 1 if auto_send else 0),
|
||||
)
|
||||
|
||||
|
||||
def reresolve_account(conn, account_id: int | None) -> dict[str, int]:
|
||||
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
|
||||
|
||||
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
|
||||
ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu
|
||||
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
|
||||
motivul actualizat. Intoarce {requeued, still_blocked, needs_data}.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
mapping = load_mapping(conn, acct)
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions WHERE status='needs_mapping' AND account_id=?",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
|
||||
stats = {"requeued": 0, "still_blocked": 0, "needs_data": 0}
|
||||
for r in rows:
|
||||
try:
|
||||
content = json.loads(r["payload_json"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
|
||||
content["prestatii"] = resolved
|
||||
payload_json = json.dumps(content, ensure_ascii=False)
|
||||
|
||||
if unmapped:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), r["id"]),
|
||||
)
|
||||
stats["still_blocked"] += 1
|
||||
continue
|
||||
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps(errors, ensure_ascii=False), r["id"]),
|
||||
)
|
||||
stats["needs_data"] += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
||||
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, r["id"]),
|
||||
)
|
||||
stats["requeued"] += 1
|
||||
return stats
|
||||
Reference in New Issue
Block a user