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:
@@ -14,9 +14,18 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
from ...idempotency import idempotency_key
|
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 ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
||||||
from ...validation import validate_prezentare
|
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.
|
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||||
"""
|
"""
|
||||||
account_id = None # TODO(auth): din API key
|
account_id = None # TODO(auth): din API key
|
||||||
|
acct = account_or_default(account_id)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
results: list[SubmissionResult] = []
|
results: list[SubmissionResult] = []
|
||||||
try:
|
try:
|
||||||
|
mapping = load_mapping(conn, acct)
|
||||||
for prez in req.prezentari:
|
for prez in req.prezentari:
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
key = idempotency_key(account_id, content)
|
key = idempotency_key(account_id, content)
|
||||||
@@ -56,17 +67,28 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
|
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul
|
||||||
errors = validate_prezentare(content)
|
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite),
|
||||||
if errors:
|
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci
|
||||||
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
|
# 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:
|
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(
|
cur = conn.execute(
|
||||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
|
||||||
"VALUES (?, ?, ?, ?, ?)",
|
"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))
|
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
|
||||||
finally:
|
finally:
|
||||||
@@ -135,3 +157,46 @@ def get_mapari(account_id: int | None = None) -> dict:
|
|||||||
return {"mapari": [dict(r) for r in rows]}
|
return {"mapari": [dict(r) for r in rows]}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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()
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ def init_db() -> None:
|
|||||||
try:
|
try:
|
||||||
conn.executescript(_SCHEMA.read_text(encoding="utf-8"))
|
conn.executescript(_SCHEMA.read_text(encoding="utf-8"))
|
||||||
_migrate(conn)
|
_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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import json
|
|||||||
from typing import Any
|
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:
|
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
"""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(),
|
"nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
||||||
"data_prestatie": prezentare.get("data_prestatie"),
|
"data_prestatie": prezentare.get("data_prestatie"),
|
||||||
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
|
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
|
||||||
"prestatii": sorted(
|
# Identitatea operatiei = codul RAR daca exista, altfel codul intern ROAAUTO
|
||||||
str(p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", ""))
|
# (hibrid): doua trimiteri ale aceleiasi comenzi dedup corect indiferent de
|
||||||
for p in (prezentare.get("prestatii") or [])
|
# 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=(",", ":"))
|
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|||||||
301
app/mapping.py
Normal file
301
app/mapping.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
|
||||||
|
|
||||||
|
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni
|
||||||
|
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service`
|
||||||
|
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
|
||||||
|
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
|
||||||
|
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
|
||||||
|
cu ajutorul unei sugestii fuzzy pe nomenclatorul RAR. La salvarea maparii,
|
||||||
|
submission-urile blocate pe acel cod se re-rezolva automat.
|
||||||
|
|
||||||
|
Functiile de la inceput (normalize/suggest/resolve) sunt PURE (fara DB/HTTP),
|
||||||
|
unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unicodedata
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rapidfuzz import fuzz, process
|
||||||
|
|
||||||
|
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||||
|
from .validation import validate_prezentare
|
||||||
|
|
||||||
|
# Cont implicit cat timp auth API-key (CORE) nu e implementat: ingestiile vin cu
|
||||||
|
# account_id NULL si le atribuim contului seed-at in schema (id=1).
|
||||||
|
DEFAULT_ACCOUNT_ID = 1
|
||||||
|
|
||||||
|
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
|
||||||
|
SUGGEST_MIN_SCORE = 60
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pur: normalizare + fuzzy + rezolvare #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def normalize_for_match(value: object) -> str:
|
||||||
|
"""Upper + fara diacritice + spatii colapsate, pentru potrivire robusta.
|
||||||
|
|
||||||
|
'Reparație motor' -> 'REPARATIE MOTOR'. Diacriticele romanesti (ă/â/î/ș/ț)
|
||||||
|
si artefactele de encoding nu trebuie sa strice scorul fuzzy.
|
||||||
|
"""
|
||||||
|
s = str(value or "")
|
||||||
|
s = unicodedata.normalize("NFKD", s)
|
||||||
|
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
||||||
|
return " ".join(s.upper().split())
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_codes(
|
||||||
|
denumire: object,
|
||||||
|
nomenclator: list[dict],
|
||||||
|
*,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Clasament fuzzy al codurilor RAR pentru o denumire de operatie ROAAUTO.
|
||||||
|
|
||||||
|
`nomenclator` = randuri {cod_prestatie, nume_prestatie}. Intoarce
|
||||||
|
[{cod_prestatie, nume_prestatie, score}] sortat descrescator dupa scor.
|
||||||
|
Daca denumirea e goala, intoarce nomenclatorul in ordinea data, scor 0.
|
||||||
|
"""
|
||||||
|
query = normalize_for_match(denumire)
|
||||||
|
rows = [r for r in nomenclator if (r.get("cod_prestatie") or "")]
|
||||||
|
if not query:
|
||||||
|
return [{**r, "score": 0.0} for r in rows[:limit]]
|
||||||
|
|
||||||
|
choices = {r["cod_prestatie"]: normalize_for_match(r.get("nume_prestatie")) for r in rows}
|
||||||
|
by_cod = {r["cod_prestatie"]: r for r in rows}
|
||||||
|
# token_sort_ratio (nu token_set_ratio): recompenseaza acoperirea cat mai multor
|
||||||
|
# cuvinte din denumire, in loc sa dea 100 la orice subset (ex. "REPARATIE" si
|
||||||
|
# "REPARATIE ODOMETRU" ar fi egale la set_ratio).
|
||||||
|
ranked = process.extract(
|
||||||
|
query,
|
||||||
|
choices,
|
||||||
|
scorer=fuzz.token_sort_ratio,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
# process.extract -> [(value, score, key)]; key = cod_prestatie.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"cod_prestatie": cod,
|
||||||
|
"nume_prestatie": by_cod[cod].get("nume_prestatie"),
|
||||||
|
"score": float(score),
|
||||||
|
}
|
||||||
|
for _val, score, cod in ranked
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_prestatii(
|
||||||
|
prestatii: list[dict] | None,
|
||||||
|
mapping: dict[str, str],
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
|
||||||
|
|
||||||
|
Reguli (hibrid):
|
||||||
|
- item cu `cod_prestatie` -> pastrat ca atare (cod RAR direct).
|
||||||
|
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
|
||||||
|
- item fara cod si fara mapare -> ramane nemapat.
|
||||||
|
|
||||||
|
Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza
|
||||||
|
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
|
||||||
|
contextul; payload-ul RAR citeste doar cod_prestatie. `nemapate` =
|
||||||
|
[{cod_op_service, denumire}] pentru editor.
|
||||||
|
"""
|
||||||
|
resolved: list[dict] = []
|
||||||
|
unmapped: list[dict] = []
|
||||||
|
for item in prestatii or []:
|
||||||
|
it = dict(item)
|
||||||
|
cod = (it.get("cod_prestatie") or "").strip().upper()
|
||||||
|
op = (it.get("cod_op_service") or "").strip()
|
||||||
|
if cod:
|
||||||
|
it["cod_prestatie"] = cod
|
||||||
|
elif op and op in mapping:
|
||||||
|
it["cod_prestatie"] = mapping[op]
|
||||||
|
elif op:
|
||||||
|
it["cod_prestatie"] = None
|
||||||
|
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
|
||||||
|
# item fara cod si fara op: il lasam asa; validarea de continut prinde
|
||||||
|
# "prestatii goale"/cod lipsa.
|
||||||
|
resolved.append(it)
|
||||||
|
return resolved, unmapped
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Persistenta (conn) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def account_or_default(account_id: int | None) -> int:
|
||||||
|
return account_id if account_id is not None else DEFAULT_ACCOUNT_ID
|
||||||
|
|
||||||
|
|
||||||
|
def seed_nomenclator_if_empty(conn) -> int:
|
||||||
|
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
||||||
|
|
||||||
|
Worker-ul suprascrie live; aici doar garantam ca editorul fuzzy merge offline.
|
||||||
|
Intoarce nr. de randuri inserate.
|
||||||
|
"""
|
||||||
|
n = conn.execute("SELECT COUNT(*) AS n FROM nomenclator_rar").fetchone()["n"]
|
||||||
|
if n:
|
||||||
|
return 0
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||||
|
FALLBACK_NOMENCLATOR,
|
||||||
|
)
|
||||||
|
return len(FALLBACK_NOMENCLATOR)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_nomenclator(conn, items: list[dict]) -> int:
|
||||||
|
"""Upsert nomenclator live din RAR. `items` = forma API (codPrestatie/numePrestatie).
|
||||||
|
|
||||||
|
Tolerant la chei: codPrestatie/cod_prestatie/cod, numePrestatie/nume_prestatie/nume.
|
||||||
|
Intoarce nr. de coduri upsert-ate.
|
||||||
|
"""
|
||||||
|
rows: list[tuple[str, str]] = []
|
||||||
|
for it in items or []:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
cod = it.get("codPrestatie") or it.get("cod_prestatie") or it.get("cod")
|
||||||
|
nume = it.get("numePrestatie") or it.get("nume_prestatie") or it.get("nume")
|
||||||
|
if cod:
|
||||||
|
rows.append((str(cod).strip().upper(), str(nume or "").strip()))
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO nomenclator_rar (cod_prestatie, nume_prestatie, updated_at) "
|
||||||
|
"VALUES (?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(cod_prestatie) DO UPDATE SET nume_prestatie=excluded.nume_prestatie, "
|
||||||
|
"updated_at=datetime('now')",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def load_nomenclator(conn) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT cod_prestatie, nume_prestatie FROM nomenclator_rar ORDER BY cod_prestatie"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
||||||
|
"""{cod_op_service -> cod_prestatie} pentru un cont."""
|
||||||
|
acct = account_or_default(account_id)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
|
||||||
|
(acct,),
|
||||||
|
).fetchall()
|
||||||
|
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def pending_unmapped(conn) -> list[dict]:
|
||||||
|
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
|
||||||
|
|
||||||
|
Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa,
|
||||||
|
nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de
|
||||||
|
adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie
|
||||||
|
null + cod_op_service setat.
|
||||||
|
"""
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
||||||
|
for r in rows:
|
||||||
|
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
|
||||||
|
try:
|
||||||
|
content = json.loads(r["payload_json"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
for item in content.get("prestatii") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if (item.get("cod_prestatie") or ""):
|
||||||
|
continue
|
||||||
|
op = (item.get("cod_op_service") or "").strip()
|
||||||
|
if not op:
|
||||||
|
continue
|
||||||
|
key = (acct, op)
|
||||||
|
entry = agg.setdefault(
|
||||||
|
key,
|
||||||
|
{"account_id": acct, "cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0, "_ids": set()},
|
||||||
|
)
|
||||||
|
if not entry["denumire"] and item.get("denumire"):
|
||||||
|
entry["denumire"] = item.get("denumire")
|
||||||
|
entry["_ids"].add(r["id"])
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for entry in agg.values():
|
||||||
|
entry["blocked"] = len(entry.pop("_ids"))
|
||||||
|
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
|
||||||
|
out.append(entry)
|
||||||
|
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
|
||||||
|
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service)."""
|
||||||
|
acct = account_or_default(account_id)
|
||||||
|
op = (cod_op_service or "").strip()
|
||||||
|
cod = (cod_prestatie or "").strip().upper()
|
||||||
|
if not op or not cod:
|
||||||
|
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||||
|
"VALUES (?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||||
|
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
|
||||||
|
(acct, op, cod, 1 if auto_send else 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reresolve_account(conn, account_id: int | None) -> dict[str, int]:
|
||||||
|
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
|
||||||
|
|
||||||
|
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
|
||||||
|
ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu
|
||||||
|
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
|
||||||
|
motivul actualizat. Intoarce {requeued, still_blocked, needs_data}.
|
||||||
|
"""
|
||||||
|
acct = account_or_default(account_id)
|
||||||
|
mapping = load_mapping(conn, acct)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, payload_json FROM submissions WHERE status='needs_mapping' AND account_id=?",
|
||||||
|
(acct,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
stats = {"requeued": 0, "still_blocked": 0, "needs_data": 0}
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
content = json.loads(r["payload_json"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
|
||||||
|
content["prestatii"] = resolved
|
||||||
|
payload_json = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
if unmapped:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
|
||||||
|
(payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), r["id"]),
|
||||||
|
)
|
||||||
|
stats["still_blocked"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
errors = validate_prezentare(content)
|
||||||
|
if errors:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||||
|
"updated_at=datetime('now') WHERE id=?",
|
||||||
|
(payload_json, json.dumps(errors, ensure_ascii=False), r["id"]),
|
||||||
|
)
|
||||||
|
stats["needs_data"] += 1
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
||||||
|
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
|
||||||
|
(payload_json, r["id"]),
|
||||||
|
)
|
||||||
|
stats["requeued"] += 1
|
||||||
|
return stats
|
||||||
@@ -8,7 +8,7 @@ obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
class RarCredentials(BaseModel):
|
class RarCredentials(BaseModel):
|
||||||
@@ -19,12 +19,33 @@ class RarCredentials(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrestatieItem(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")
|
@field_validator("cod_prestatie")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _norm_cod(cls, v: str) -> str:
|
def _norm_cod(cls, v: str | None) -> str | None:
|
||||||
return v.strip().upper()
|
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):
|
class PrezentareIn(BaseModel):
|
||||||
|
|||||||
33
app/nomenclator_seed.py
Normal file
33
app/nomenclator_seed.py
Normal 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"),
|
||||||
|
]
|
||||||
@@ -14,6 +14,10 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
cui TEXT,
|
cui TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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.
|
-- Chei API per cont (separate de creds RAR). Stocam doar hash-ul.
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection, read_heartbeat
|
from ..db import get_connection, read_heartbeat
|
||||||
|
from ..mapping import load_nomenclator, pending_unmapped, reresolve_account, save_mapping
|
||||||
|
|
||||||
router = APIRouter(tags=["web"])
|
router = APIRouter(tags=["web"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
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})
|
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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()
|
||||||
|
|||||||
58
app/web/templates/_mapari.html
Normal file
58
app/web/templates/_mapari.html
Normal 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>
|
||||||
@@ -26,6 +26,19 @@
|
|||||||
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
||||||
.muted { color:var(--muted); }
|
.muted { color:var(--muted); }
|
||||||
a { color:var(--accent); }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card">
|
||||||
<h2 style="font-size:14px; margin:0 0 12px;">Coada submissions</h2>
|
<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">
|
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import httpx
|
|||||||
|
|
||||||
from ..config import Settings, get_settings, load_test_credentials
|
from ..config import Settings, get_settings, load_test_credentials
|
||||||
from ..db import get_connection, init_db, write_heartbeat
|
from ..db import get_connection, init_db, write_heartbeat
|
||||||
|
from ..mapping import upsert_nomenclator
|
||||||
from ..payload import build_rar_payload
|
from ..payload import build_rar_payload
|
||||||
from ..reconcile import match_finalizata
|
from ..reconcile import match_finalizata
|
||||||
from ..rar_client import RarAuthError, RarClient, RarError
|
from ..rar_client import RarAuthError, RarClient, RarError
|
||||||
@@ -202,6 +203,16 @@ def _queue_depth(conn) -> int:
|
|||||||
return int(row["n"]) if row else 0
|
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:
|
def run() -> int:
|
||||||
signal.signal(signal.SIGTERM, _stop)
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
signal.signal(signal.SIGINT, _stop)
|
signal.signal(signal.SIGINT, _stop)
|
||||||
@@ -231,6 +242,9 @@ def run() -> int:
|
|||||||
rar = RarClient(settings)
|
rar = RarClient(settings)
|
||||||
token = rar.login(creds["email"], creds["password"])
|
token = rar.login(creds["email"], creds["password"])
|
||||||
write_heartbeat(conn, rar_login_ok=True, detail="login RAR ok")
|
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)
|
recover_orphans(conn, settings, rar, token)
|
||||||
|
|
||||||
|
|||||||
@@ -265,6 +265,35 @@ Fiecare item din `content` (live):
|
|||||||
7. **URL nomenclator confirmat** = `/nomenclator/getNomenclatorPrestatii` (nu varianta din
|
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ă.
|
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)
|
## Open questions rămase (actualizat)
|
||||||
|
|
||||||
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ jinja2==3.1.*
|
|||||||
pydantic==2.8.2
|
pydantic==2.8.2
|
||||||
pydantic-settings==2.*
|
pydantic-settings==2.*
|
||||||
python-multipart==0.0.*
|
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
|
dbfread==2.0.7
|
||||||
|
|||||||
163
tests/test_mapping.py
Normal file
163
tests/test_mapping.py
Normal 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
|
||||||
Reference in New Issue
Block a user