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

@@ -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()

View File

@@ -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()

View File

@@ -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()

301
app/mapping.py Normal file
View 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

View File

@@ -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):

33
app/nomenclator_seed.py Normal file
View File

@@ -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"),
]

View File

@@ -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 (

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">

View File

@@ -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)

View File

@@ -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ă).

View File

@@ -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

163
tests/test_mapping.py Normal file
View File

@@ -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