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 @@ +
+ 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 '' %} + + {% endfor %} + {% endif %} +