From a6df3b636f0290690510575f592c788c66c4a318 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 15 Jun 2026 19:25:21 +0000 Subject: [PATCH] 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) --- app/api/v1/router.py | 77 +++++++- app/db.py | 5 + app/idempotency.py | 17 +- app/mapping.py | 301 +++++++++++++++++++++++++++++++ app/models.py | 29 ++- app/nomenclator_seed.py | 33 ++++ app/schema.sql | 4 + app/web/routes.py | 52 +++++- app/web/templates/_mapari.html | 58 ++++++ app/web/templates/base.html | 13 ++ app/web/templates/dashboard.html | 5 + app/worker/__main__.py | 14 ++ docs/api-rar-contract.md | 29 +++ requirements.txt | 4 +- tests/test_mapping.py | 163 +++++++++++++++++ 15 files changed, 788 insertions(+), 16 deletions(-) create mode 100644 app/mapping.py create mode 100644 app/nomenclator_seed.py create mode 100644 app/web/templates/_mapari.html create mode 100644 tests/test_mapping.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index cb56f7b..aa97ebe 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -14,9 +14,18 @@ from __future__ import annotations import json from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field from ...db import get_connection from ...idempotency import idempotency_key +from ...mapping import ( + account_or_default, + load_mapping, + pending_unmapped, + reresolve_account, + resolve_prestatii, + save_mapping, +) from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult from ...validation import validate_prezentare @@ -35,9 +44,11 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse: pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. """ account_id = None # TODO(auth): din API key + acct = account_or_default(account_id) conn = get_connection() results: list[SubmissionResult] = [] try: + mapping = load_mapping(conn, acct) for prez in req.prezentari: content = prez.model_dump() key = idempotency_key(account_id, content) @@ -56,17 +67,28 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse: ) continue - # T3: validare de continut -> queued daca e curat, altfel needs_data + motiv. - errors = validate_prezentare(content) - if errors: - status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False) + # Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul + # intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite), + # apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci + # validarea T3 + payload builder + worker raman code-driven. + resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) + content["prestatii"] = resolved + + if unmapped: + status = "needs_mapping" + rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False) else: - status, rar_error = "queued", None + # T3: validare de continut -> queued daca e curat, altfel needs_data + motiv. + errors = validate_prezentare(content) + if errors: + status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False) + else: + status, rar_error = "queued", None cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) " "VALUES (?, ?, ?, ?, ?)", - (key, account_id, status, json.dumps(content, ensure_ascii=False), rar_error), + (key, acct, status, json.dumps(content, ensure_ascii=False), rar_error), ) results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) finally: @@ -135,3 +157,46 @@ def get_mapari(account_id: int | None = None) -> dict: return {"mapari": [dict(r) for r in rows]} finally: conn.close() + + +@router.get("/mapari/pending") +def get_mapari_pending() -> dict: + """Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy. + + Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire, + blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. + """ + conn = get_connection() + try: + return {"pending": pending_unmapped(conn)} + finally: + conn.close() + + +class MapareIn(BaseModel): + account_id: int | None = None + cod_op_service: str = Field(..., min_length=1) + cod_prestatie: str = Field(..., min_length=1) + auto_send: bool = True + + +@router.post("/mapari") +def create_mapare(req: MapareIn) -> dict: + """Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate. + + Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam mapari catre + coduri inexistente). Apoi upsert + re-rezolvare automata a `needs_mapping`. + """ + conn = get_connection() + try: + cod = req.cod_prestatie.strip().upper() + exists = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) + ).fetchone() + if not exists: + raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator") + save_mapping(conn, req.account_id, req.cod_op_service, cod, req.auto_send) + stats = reresolve_account(conn, req.account_id) + return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats} + finally: + conn.close() diff --git a/app/db.py b/app/db.py index 69a9286..dfebbb0 100644 --- a/app/db.py +++ b/app/db.py @@ -32,6 +32,11 @@ def init_db() -> None: try: conn.executescript(_SCHEMA.read_text(encoding="utf-8")) _migrate(conn) + # Seed fallback nomenclator (doar daca e gol) ca editorul de mapari + fuzzy + # sa mearga inainte ca worker-ul sa fi luat lista live din RAR. + from .mapping import seed_nomenclator_if_empty + + seed_nomenclator_if_empty(conn) finally: conn.close() diff --git a/app/idempotency.py b/app/idempotency.py index 7cf6c6f..b395fb8 100644 --- a/app/idempotency.py +++ b/app/idempotency.py @@ -11,6 +11,15 @@ import json from typing import Any +def _op_identity(p: Any) -> str: + """Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO.""" + get = p.get if isinstance(p, dict) else (lambda k, d=None: getattr(p, k, d)) + cod = (get("cod_prestatie", "") or "").strip().upper() + if cod: + return cod + return (get("cod_op_service", "") or "").strip() + + def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str: """SHA-256 peste (account_id + campurile semnificative ale prezentarii). @@ -22,10 +31,10 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str: "nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(), "data_prestatie": prezentare.get("data_prestatie"), "odometru_final": str(prezentare.get("odometru_final") or "").strip(), - "prestatii": sorted( - str(p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", "")) - for p in (prezentare.get("prestatii") or []) - ), + # Identitatea operatiei = codul RAR daca exista, altfel codul intern ROAAUTO + # (hibrid): doua trimiteri ale aceleiasi comenzi dedup corect indiferent de + # forma in care vin codurile. + "prestatii": sorted(_op_identity(p) for p in (prezentare.get("prestatii") or [])), } blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":")) return hashlib.sha256(blob.encode("utf-8")).hexdigest() diff --git a/app/mapping.py b/app/mapping.py new file mode 100644 index 0000000..2eea17c --- /dev/null +++ b/app/mapping.py @@ -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 diff --git a/app/models.py b/app/models.py index baf72cd..5f874b9 100644 --- a/app/models.py +++ b/app/models.py @@ -8,7 +8,7 @@ obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este from __future__ import annotations -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class RarCredentials(BaseModel): @@ -19,12 +19,33 @@ class RarCredentials(BaseModel): class PrestatieItem(BaseModel): - cod_prestatie: str = Field(..., description="cod din nomenclator RAR, ex. OE-1") + """O operatie de declarat. Contract hibrid (decis 2026-06-15): + + ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE + `cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le + mapeaza in cod RAR prin operations_mapping. Cel putin unul dintre + cod_prestatie / cod_op_service e obligatoriu (shape -> 422 daca lipsesc ambele). + """ + + cod_prestatie: str | None = Field(None, description="cod din nomenclator RAR, ex. OE-1") + cod_op_service: str | None = Field(None, description="cod intern operatie ROAAUTO (mapat -> cod RAR)") + denumire: str | None = Field(None, description="denumirea operatiei ROAAUTO (pentru fuzzy lookup la mapare)") @field_validator("cod_prestatie") @classmethod - def _norm_cod(cls, v: str) -> str: - return v.strip().upper() + def _norm_cod(cls, v: str | None) -> str | None: + return v.strip().upper() if v else None + + @field_validator("cod_op_service", "denumire") + @classmethod + def _norm_op(cls, v: str | None) -> str | None: + return v.strip() if v else None + + @model_validator(mode="after") + def _require_one(self) -> "PrestatieItem": + if not self.cod_prestatie and not self.cod_op_service: + raise ValueError("fiecare prestatie are nevoie de cod_prestatie sau cod_op_service") + return self class PrezentareIn(BaseModel): diff --git a/app/nomenclator_seed.py b/app/nomenclator_seed.py new file mode 100644 index 0000000..dea2c5a --- /dev/null +++ b/app/nomenclator_seed.py @@ -0,0 +1,33 @@ +"""Seed fallback pentru nomenclator_rar. + +Nomenclatorul autoritativ se ia LIVE din RAR (`getNomenclatorPrestatii`) si e +upsert-at de worker la fiecare login (vezi worker.refresh_nomenclator). Dar +editorul de mapari + fuzzy lookup trebuie sa functioneze si inainte ca worker-ul +sa fi rulat (dev, offline, primul boot). De aceea seed-uim aceste 18 coduri din +contract (docs/api-rar-contract.md, verificat live 2026-06-15) DOAR daca tabela +e goala; refresh-ul live le suprascrie cu textul lung oficial. +""" + +from __future__ import annotations + +# (cod_prestatie, nume_prestatie) — 18 coduri, conform contractului live. +FALLBACK_NOMENCLATOR: list[tuple[str, str]] = [ + ("OE-1", "REPARATIE"), + ("OE-2", "INTRETINERE"), + ("OE-3", "REVIZIE PERIODICA"), + ("OE-4", "REGLARE FUNCTIONALA"), + ("OE-5", "MODIFICARE CONSTRUCTIVA"), + ("OE-6", "RECONSTRUCTIE"), + ("OE-7", "ACTUALIZARE SOFTWARE"), + ("OE-8", "INLOCUIRE SEZONIERA A ANVELOPELOR"), + ("OE-D", "AVARII GRAVE LA SISTEMUL DE DIRECTIE"), + ("OE-F", "AVARII GRAVE LA SISTEMUL DE FRANARE"), + ("OE-C", "AVARII GRAVE LA STRUCTURA DE REZISTENTA A CAROSERIEI"), + ("OE-S", "AVARII GRAVE LA STRUCTURA DE REZISTENTA A SASIULUI"), + ("OE-R", "AVARII GRAVE LA UN SISTEM DE RETINERE SI PROTECTIE IN CAZ DE ACCIDENT"), + ("OE-A", "AVARII GRAVE LA UN SISTEM AVANSAT DE ASISTENTA A CONDUCATORULUI AUTO (ADAS)"), + ("OE-I", "ISTORICUL INDICATIEI ODOMETRULUI (vehicule anterior inmatriculate in alte tari)"), + ("AITLV", "INREGISTRARE ATELIER INSPECTIE TAHOGRAFE / LIMITATOARE DE VITEZA"), + ("R-ODO", "REPARATIE ODOMETRU"), + ("I-ODO", "INLOCUIRE ODOMETRU"), +] diff --git a/app/schema.sql b/app/schema.sql index 26b116f..f7ee3ed 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -14,6 +14,10 @@ CREATE TABLE IF NOT EXISTS accounts ( cui TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Cont implicit (id=1): auth API-key (CORE) inca neimplementat, deci ingestiile vin +-- cu account_id NULL. Le atribuim contului default ca FK + UNIQUE(account_id,...) din +-- operations_mapping sa fie valide; cand auth livreaza, account_id real va curge natural. +INSERT OR IGNORE INTO accounts (id, name) VALUES (1, 'default'); -- Chei API per cont (separate de creds RAR). Stocam doar hash-ul. CREATE TABLE IF NOT EXISTS api_keys ( diff --git a/app/web/routes.py b/app/web/routes.py index 19f1d3a..9c11072 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -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() diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html new file mode 100644 index 0000000..9afc1a2 --- /dev/null +++ b/app/web/templates/_mapari.html @@ -0,0 +1,58 @@ +
+

Mapari de rezolvat

+ + {% if message %} +
{{ message }}
+ {% endif %} + + {% if not pending %} +
Nicio operatie nemapata. Tot ce a venit s-a tradus in coduri RAR.
+ {% else %} +

+ Operatii ROAAUTO necunoscute, blocate in needs_mapping. + Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat. +

+ + {% 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 '' %} +
+ + + +
+
{{ e.cod_op_service }} + {{ e.blocked }} blocate
+
{{ e.denumire or '(fara denumire)' }}
+ {% if e.suggestions %} +
+ sugestii: + {% for s in e.suggestions[:3] %} + {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ {% endfor %} + {% endif %} +
diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 103bdeb..a6687a4 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -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; } diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 8e573a4..ccc83ab 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -18,6 +18,11 @@ + +
+
se incarca mapari…
+
+

Coada submissions

diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 1853457..6864df7 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -30,6 +30,7 @@ import httpx from ..config import Settings, get_settings, load_test_credentials from ..db import get_connection, init_db, write_heartbeat +from ..mapping import upsert_nomenclator from ..payload import build_rar_payload from ..reconcile import match_finalizata from ..rar_client import RarAuthError, RarClient, RarError @@ -202,6 +203,16 @@ def _queue_depth(conn) -> int: return int(row["n"]) if row else 0 +def _refresh_nomenclator(conn, rar: RarClient, token: str) -> None: + """Ia nomenclatorul live din RAR si il upsert-eaza local. Erorile NU opresc worker-ul.""" + try: + items = rar.get_nomenclator(token) + n = upsert_nomenclator(conn, items) + print(f"[worker] nomenclator refresh: {n} coduri", flush=True) + except Exception as exc: # noqa: BLE001 — refresh best-effort, nu blocheaza trimiterea + print(f"[worker] nomenclator refresh esuat (continui): {exc}", flush=True) + + def run() -> int: signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGINT, _stop) @@ -231,6 +242,9 @@ def run() -> int: rar = RarClient(settings) token = rar.login(creds["email"], creds["password"]) write_heartbeat(conn, rar_login_ok=True, detail="login RAR ok") + # Refresh nomenclator live (autoritativ) la fiecare login proaspat — + # alimenteaza fuzzy lookup-ul din editorul de mapari. + _refresh_nomenclator(conn, rar, token) recover_orphans(conn, settings, rar, token) diff --git a/docs/api-rar-contract.md b/docs/api-rar-contract.md index 3cebe4b..d85515e 100644 --- a/docs/api-rar-contract.md +++ b/docs/api-rar-contract.md @@ -265,6 +265,35 @@ Fiecare item din `content` (live): 7. **URL nomenclator confirmat** = `/nomenclator/getNomenclatorPrestatii` (nu varianta din operationId Swagger). Constatarea #5 din `plan-eng-review` (URL-uri din VFP, nu din spec) — confirmată. +## API gateway (ROAAUTO -> gateway): mapare operatii (hibrid, 2026-06-15) + +Aceasta e suprafata **gateway-ului**, nu RAR. Un item din `prestatii` la +`POST /v1/prezentari` poate veni in doua forme (cel putin una obligatorie): + +| Camp item | Note | +|---|---| +| `cod_prestatie` | cod RAR direct (ex. `OE-1`). Trece neatins -> validare T3 -> coada. | +| `cod_op_service` | cod intern ROAAUTO. Gateway-ul il traduce in cod RAR prin `operations_mapping`. | +| `denumire` | denumirea operatiei ROAAUTO; folosita pentru fuzzy lookup in editor. | + +Daca lipsesc **ambele** coduri -> `422` (shape). Op cu `cod_op_service` necunoscut +(fara mapare) -> submission `needs_mapping` (NU se trimite la RAR), apare in editorul +web. La salvarea maparii, submission-urile blocate pe acel cod se re-rezolva automat +(-> `queued`, sau `needs_data` daca regula odometru/continut cere asta). Codul RAR +rezolvat se scrie inapoi in `payload_json`, deci payload builder + worker raman +code-driven. + +Endpointuri noi: +- `GET /v1/mapari/pending` — operatii nemapate distincte + sugestii fuzzy (`{cod_prestatie, nume_prestatie, score}`). +- `POST /v1/mapari` `{account_id?, cod_op_service, cod_prestatie, auto_send}` — upsert mapare + re-rezolvare. Respinge `cod_prestatie` inexistent in nomenclator (422). +- Web: `GET /_fragments/mapari` (editor HTMX), `POST /mapari` (form, salveaza + re-randeaza). + +Fuzzy: `rapidfuzz.token_sort_ratio` pe denumire normalizata (fara diacritice, upper). +Nomenclatorul se ia **live** din RAR (worker upsert la fiecare login); seed fallback +de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline. +Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit +contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural. + ## Open questions rămase (actualizat) 1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională). diff --git a/requirements.txt b/requirements.txt index ba6c509..c87d57b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,8 @@ jinja2==3.1.* pydantic==2.8.2 pydantic-settings==2.* python-multipart==0.0.* +# Fuzzy lookup pentru editorul de mapari operatii (app/mapping.py). Pur Python/C, fara build extern. +rapidfuzz==3.14.5 -# Migrare DBF (tools/import_dbf.py — T5). Necesar doar pentru import, nu pentru runtime. +# Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime. dbfread==2.0.7 diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..675a5e6 --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,163 @@ +"""Teste mapare op ROAAUTO -> cod RAR: fuzzy, rezolvare pura, flux on-demand. + +Contract hibrid (decis 2026-06-15): item de prestatie cu cod_prestatie (RAR direct) +SAU cod_op_service+denumire (mapat de gateway). Op nemapata -> needs_mapping, apare +in editor; la salvarea maparii submission-ul se re-rezolva automat. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + +from app.mapping import normalize_for_match, resolve_prestatii, suggest_codes + + +# --------------------------------------------------------------------------- # +# Pur # +# --------------------------------------------------------------------------- # + +def test_normalize_scoate_diacritice_si_colapseaza(): + assert normalize_for_match("Reparație motor") == "REPARATIE MOTOR" + assert normalize_for_match(" întreținere ") == "INTRETINERE" + assert normalize_for_match(None) == "" + + +_NOM = [ + {"cod_prestatie": "OE-1", "nume_prestatie": "REPARATIE"}, + {"cod_prestatie": "OE-2", "nume_prestatie": "INTRETINERE"}, + {"cod_prestatie": "OE-3", "nume_prestatie": "REVIZIE PERIODICA"}, + {"cod_prestatie": "R-ODO", "nume_prestatie": "REPARATIE ODOMETRU"}, +] + + +def test_suggest_pune_potrivirea_evidenta_prima(): + s = suggest_codes("Reparatie odometru electronic", _NOM, limit=4) + assert s[0]["cod_prestatie"] == "R-ODO" + assert s[0]["score"] >= 60 + + +def test_suggest_denumire_goala_intoarce_nomenclator_scor_zero(): + s = suggest_codes("", _NOM, limit=2) + assert len(s) == 2 + assert all(x["score"] == 0 for x in s) + + +def test_resolve_cod_direct_trece_neatins(): + resolved, unmapped = resolve_prestatii([{"cod_prestatie": "oe-1"}], {}) + assert resolved[0]["cod_prestatie"] == "OE-1" # normalizat upper + assert unmapped == [] + + +def test_resolve_op_mapata(): + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "1234", "denumire": "Schimb ulei"}], {"1234": "OE-2"} + ) + assert resolved[0]["cod_prestatie"] == "OE-2" + assert unmapped == [] + + +def test_resolve_op_nemapata_iese_in_unmapped(): + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "9999", "denumire": "Operatie noua"}], {} + ) + assert resolved[0]["cod_prestatie"] is None + assert unmapped == [{"cod_op_service": "9999", "denumire": "Operatie noua"}] + + +# --------------------------------------------------------------------------- # +# Flux complet (API) # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def _body(prestatii, **over): + prez = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": prestatii, + } + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]} + + +def test_nomenclator_seed_la_boot(client): + r = client.get("/v1/nomenclator") + coduri = {n["cod_prestatie"] for n in r.json()["nomenclator"]} + assert {"OE-1", "R-ODO", "I-ODO"} <= coduri + + +def test_cod_op_nemapat_da_needs_mapping(client): + r = client.post("/v1/prezentari", json=_body([{"cod_op_service": "OP100", "denumire": "Reparatie generala"}])) + assert r.status_code == 200 + assert r.json()["results"][0]["status"] == "needs_mapping" + + +def test_pending_arata_op_cu_sugestii(client): + client.post("/v1/prezentari", json=_body([{"cod_op_service": "OP100", "denumire": "Reparatie generala"}])) + pend = client.get("/v1/mapari/pending").json()["pending"] + assert len(pend) == 1 + e = pend[0] + assert e["cod_op_service"] == "OP100" + assert e["blocked"] == 1 + assert e["suggestions"] and e["suggestions"][0]["cod_prestatie"] + + +def test_salvare_mapare_deblocheaza_submission(client): + client.post("/v1/prezentari", json=_body([{"cod_op_service": "OP100", "denumire": "Reparatie"}])) + r = client.post("/v1/mapari", json={"cod_op_service": "OP100", "cod_prestatie": "OE-1", "auto_send": True}) + assert r.status_code == 200 + assert r.json()["reresolve"]["requeued"] == 1 + # submission-ul e acum queued + subs = client.get("/v1/prezentari", params={"status": "queued"}).json()["submissions"] + assert len(subs) == 1 + # nu mai e nimic in pending + assert client.get("/v1/mapari/pending").json()["pending"] == [] + + +def test_mapare_cod_inexistent_respinsa(client): + client.post("/v1/prezentari", json=_body([{"cod_op_service": "OP100", "denumire": "x"}])) + r = client.post("/v1/mapari", json={"cod_op_service": "OP100", "cod_prestatie": "ZZZ", "auto_send": True}) + assert r.status_code == 422 + + +def test_mapare_apoi_re_ingestie_e_directa(client): + """Dupa ce maparea exista, o noua comanda cu acelasi op intra direct queued.""" + client.post("/v1/prezentari", json=_body([{"cod_op_service": "OP100", "denumire": "x"}])) + client.post("/v1/mapari", json={"cod_op_service": "OP100", "cod_prestatie": "OE-1", "auto_send": True}) + r = client.post("/v1/prezentari", json=_body([{"cod_op_service": "OP100", "denumire": "x"}], vin="WVWZZZ1KZAW000999")) + assert r.json()["results"][0]["status"] == "queued" + + +def test_cod_prestatie_direct_inca_merge(client): + """Back-compat: trimiterea codului RAR direct se comporta ca inainte (queued).""" + r = client.post("/v1/prezentari", json=_body([{"cod_prestatie": "OE-1"}])) + assert r.json()["results"][0]["status"] == "queued" + + +def test_op_mapat_declanseaza_regula_odometru(client): + """Dupa mapare la R-ODO, validarea cere odometruInitial -> needs_data (nu queued).""" + client.post("/v1/prezentari", json=_body([{"cod_op_service": "OPODO", "denumire": "Reparatie odometru"}])) + r = client.post("/v1/mapari", json={"cod_op_service": "OPODO", "cod_prestatie": "R-ODO", "auto_send": True}) + stats = r.json()["reresolve"] + assert stats["needs_data"] == 1 and stats["requeued"] == 0 + + +def test_item_fara_cod_si_fara_op_e_422(client): + r = client.post("/v1/prezentari", json=_body([{"denumire": "doar text"}])) + assert r.status_code == 422