feat(api): raspuns minim implicit + corelare cerere-raspuns + sugestie cod in raspuns; coloana # in lista trimiteri
- POST /v1/prezentari: camp optional 'raspuns' (minim implicit / complet); forma minima = 6 chei fixe (index, submission_id, vehicul, status, motiv, id_prezentare), formatul detaliat ramane la cerere - corelare rezultat<->cerere: index 0-based + ecou vin/nr_inmatriculare - sugestia principala de cod RAR pe nemapate (GOLD partajat > SILVER > embeddings > fuzzy peste prag), in motiv si in nemapate[].sugestie (si pe dry-run /valideaza); suggestion-only, nu se aplica automat - lista Trimiteri: coloana # cu id-ul trimiterii (desktop + mobil) - README: sectiune 'Mapari si sugestii de cod RAR'; contract actualizat Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
18
README.md
18
README.md
@@ -59,6 +59,24 @@ pe semnatura coloanelor, per cont) → **preview** (fiecare rand: `ok` / `needs_
|
|||||||
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
|
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
|
||||||
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
|
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
|
||||||
|
|
||||||
|
## Mapari si sugestii de cod RAR
|
||||||
|
|
||||||
|
O operatie fara cod RAR valid (din import sau din API) opreste randul in **Lipsa cod**
|
||||||
|
(`needs_mapping`) — nimic nu pleaca la RAR pana nu confirma un om codul. Sistemul propune
|
||||||
|
insa singur un cod ("sugestia principala"), calculat pe loc din denumirea operatiei:
|
||||||
|
|
||||||
|
- **Cand:** de fiecare data cand un rand ajunge in `needs_mapping` — sugestia apare imediat
|
||||||
|
in raspunsul API (`motiv` + `nemapate[].sugestie`), in tab-ul **Mapari** (precompletata
|
||||||
|
in select) si in `GET /v1/mapari/pending`.
|
||||||
|
- **Cum (in ordinea increderii):** mapari confirmate de oameni in alte conturi (GOLD partajat) →
|
||||||
|
clasificari LLM pre-calculate (SILVER) → similaritate semantica (embeddings k-NN) →
|
||||||
|
potrivire fuzzy pe numele din nomenclatorul RAR. Prima sursa care raspunde castiga;
|
||||||
|
textele care nu sunt operatii (ITP, taxe, discounturi) nu primesc sugestie.
|
||||||
|
- **Sugestia NU se aplica singura** — declararea la RAR e definitiva, deci prima asociere
|
||||||
|
operatie→cod cere confirmarea ta (tab Mapari sau `POST /v1/mapari`). Din acel moment maparea
|
||||||
|
e memorata: aparitiile viitoare se rezolva automat, iar **toate** trimiterile deja blocate
|
||||||
|
pe acel cod se deblocheaza pe loc si intra in coada.
|
||||||
|
|
||||||
## API v1 (curl)
|
## API v1 (curl)
|
||||||
|
|
||||||
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
|
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
|
||||||
|
|||||||
@@ -33,16 +33,19 @@ from ...mapping import (
|
|||||||
classify_prezentare,
|
classify_prezentare,
|
||||||
load_excluded_ops,
|
load_excluded_ops,
|
||||||
load_mapping_meta,
|
load_mapping_meta,
|
||||||
|
load_nomenclator,
|
||||||
load_nomenclator_codes,
|
load_nomenclator_codes,
|
||||||
load_text_rules,
|
load_text_rules,
|
||||||
pending_unmapped,
|
pending_unmapped,
|
||||||
reresolve_account,
|
reresolve_account,
|
||||||
save_mapping,
|
save_mapping,
|
||||||
|
sugestie_pentru_op,
|
||||||
)
|
)
|
||||||
from ...models import (
|
from ...models import (
|
||||||
PrezentareRequest,
|
PrezentareRequest,
|
||||||
PrezentariResponse,
|
PrezentariResponse,
|
||||||
SubmissionResult,
|
SubmissionResult,
|
||||||
|
SubmissionResultMinim,
|
||||||
ValidarePrezentariRequest,
|
ValidarePrezentariRequest,
|
||||||
ValidareResponse,
|
ValidareResponse,
|
||||||
ValidareResult,
|
ValidareResult,
|
||||||
@@ -89,6 +92,16 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ataseaza_sugestii(conn, cl: dict, nomenclator: list[dict]) -> None:
|
||||||
|
"""Adauga `sugestie` ({cod_prestatie, nume_prestatie, sursa} | None) pe fiecare
|
||||||
|
cod nemapat din clasificare. Suggestion-only: informeaza raspunsul API, nu
|
||||||
|
schimba statusul si nu se aplica automat."""
|
||||||
|
for u in cl.get("unmapped") or []:
|
||||||
|
u["sugestie"] = sugestie_pentru_op(
|
||||||
|
conn, u.get("denumire") or u.get("cod_op_service"), nomenclator
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Mesaj uman pentru un rand tinut manual (queued AND held=1).
|
# Mesaj uman pentru un rand tinut manual (queued AND held=1).
|
||||||
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea
|
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea
|
||||||
# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
|
# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
|
||||||
@@ -117,8 +130,16 @@ def _motiv_clasificare(cl: dict) -> str | None:
|
|||||||
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
|
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
|
||||||
).strip("; ") or "Date incomplete (respinse de RAR)."
|
).strip("; ") or "Date incomplete (respinse de RAR)."
|
||||||
if cl["unmapped"]:
|
if cl["unmapped"]:
|
||||||
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"])
|
parti = []
|
||||||
return f"Coduri fara mapare RAR: {coduri}"
|
for u in cl["unmapped"]:
|
||||||
|
op = u.get("cod_op_service") or ""
|
||||||
|
s = u.get("sugestie")
|
||||||
|
if s and s.get("cod_prestatie"):
|
||||||
|
nume = f" {s['nume_prestatie']}" if s.get("nume_prestatie") else ""
|
||||||
|
parti.append(f"{op} (sugestie: {s['cod_prestatie']}{nume})")
|
||||||
|
else:
|
||||||
|
parti.append(op)
|
||||||
|
return f"Coduri fara mapare RAR: {', '.join(parti)}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +206,8 @@ def create_prezentari(
|
|||||||
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
||||||
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
|
# Nomenclator complet (cod+nume) pentru sugestiile atasate codurilor nemapate.
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
excluded_ops = load_excluded_ops(conn, acct)
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
@@ -288,6 +311,7 @@ def create_prezentari(
|
|||||||
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
||||||
if existing["status"] == "error":
|
if existing["status"] == "error":
|
||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||||
|
_ataseaza_sugestii(conn, cl, nomenclator)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
||||||
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
||||||
@@ -340,6 +364,7 @@ def create_prezentari(
|
|||||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||||
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
|
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
|
||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||||
|
_ataseaza_sugestii(conn, cl, nomenclator)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||||
results.append(_rezultat_respins(None, cl, rar_env=env))
|
results.append(_rezultat_respins(None, cl, rar_env=env))
|
||||||
@@ -356,6 +381,14 @@ def create_prezentari(
|
|||||||
# pe queued tinut manual (held=1) expune held=true + motiv.
|
# pe queued tinut manual (held=1) expune held=true + motiv.
|
||||||
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env))
|
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env))
|
||||||
|
|
||||||
|
# Corelare raspuns<->cerere: fiecare prezentare produce EXACT un rezultat,
|
||||||
|
# in ordine, deci pozitia din lista e si indexul cererii. Ecoul vehiculului
|
||||||
|
# foloseste valorile normalizate de Pydantic (upper/strip).
|
||||||
|
for i, (rez, prez) in enumerate(zip(results, req.prezentari)):
|
||||||
|
rez.index = i
|
||||||
|
rez.vin = prez.vin
|
||||||
|
rez.nr_inmatriculare = prez.nr_inmatriculare
|
||||||
|
|
||||||
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||||
dist: dict[str, int] = {}
|
dist: dict[str, int] = {}
|
||||||
@@ -376,7 +409,21 @@ def create_prezentari(
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return PrezentariResponse(results=results)
|
# Forma implicita = minim (6 chei fixe per rezultat); formatul complet
|
||||||
|
# (erori/nemapate/deduped/held/...) doar la cerere explicita.
|
||||||
|
if req.raspuns == "complet":
|
||||||
|
return PrezentariResponse(results=list(results))
|
||||||
|
return PrezentariResponse(results=[
|
||||||
|
SubmissionResultMinim(
|
||||||
|
index=r.index if r.index is not None else 0,
|
||||||
|
submission_id=r.submission_id,
|
||||||
|
vehicul=r.nr_inmatriculare or "",
|
||||||
|
status=r.status,
|
||||||
|
motiv=r.motiv,
|
||||||
|
id_prezentare=r.id_prezentare,
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
|
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
|
||||||
@@ -397,6 +444,7 @@ def valideaza_prezentari(
|
|||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
excluded_ops = load_excluded_ops(conn, acct)
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
@@ -433,6 +481,7 @@ def valideaza_prezentari(
|
|||||||
for i, prez in enumerate(req.prezentari):
|
for i, prez in enumerate(req.prezentari):
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||||
|
_ataseaza_sugestii(conn, res, nomenclator)
|
||||||
if res["blocked_error"]:
|
if res["blocked_error"]:
|
||||||
res = {**res, "status": "error"}
|
res = {**res, "status": "error"}
|
||||||
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||||
|
|||||||
@@ -981,6 +981,40 @@ def enrich_suggestions(
|
|||||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||||
|
|
||||||
|
|
||||||
|
# Prag fuzzy sub care nu propunem nimic (evita sugestii de zgomot in raspunsul API).
|
||||||
|
FUZZY_SUGESTIE_MIN_SCORE = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def sugestie_pentru_op(conn, denumire: str | None, nomenclator: list[dict]) -> dict | None:
|
||||||
|
"""Sugestia principala de cod RAR pentru o operatie nemapata.
|
||||||
|
|
||||||
|
Aceeasi precedenta ca editorul de mapari (enrich_suggestions: GOLD partajat >
|
||||||
|
SILVER > embeddings), cu fallback fuzzy pe nomenclator peste prag. SUGGESTION-ONLY:
|
||||||
|
nu se aplica automat, doar se afiseaza (raspuns API / editor).
|
||||||
|
|
||||||
|
Returneaza {cod_prestatie, nume_prestatie, sursa} sau None (inclusiv pe
|
||||||
|
pre-filtrul NUL: non-operatiile nu primesc sugestie de cod).
|
||||||
|
"""
|
||||||
|
if not denumire:
|
||||||
|
return None
|
||||||
|
nume = {r.get("cod_prestatie"): r.get("nume_prestatie") for r in nomenclator}
|
||||||
|
enriched = enrich_suggestions(conn, denumire)
|
||||||
|
sp = enriched.get("sugestie_principala")
|
||||||
|
if sp and sp.get("cod_prestatie"):
|
||||||
|
cod = str(sp["cod_prestatie"])
|
||||||
|
return {"cod_prestatie": cod, "nume_prestatie": nume.get(cod), "sursa": sp.get("sursa")}
|
||||||
|
if enriched.get("surse", {}).get("nul"):
|
||||||
|
return None
|
||||||
|
fuzzy = suggest_codes(denumire, nomenclator, limit=1)
|
||||||
|
if fuzzy and float(fuzzy[0].get("score") or 0) >= FUZZY_SUGESTIE_MIN_SCORE:
|
||||||
|
return {
|
||||||
|
"cod_prestatie": fuzzy[0]["cod_prestatie"],
|
||||||
|
"nume_prestatie": fuzzy[0].get("nume_prestatie"),
|
||||||
|
"sursa": "fuzzy",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
||||||
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,37 @@ class PrezentareRequest(BaseModel):
|
|||||||
on_unmapped_error: bool | None = None
|
on_unmapped_error: bool | None = None
|
||||||
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
|
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
|
||||||
rar_env: Literal["test", "prod"] | None = None
|
rar_env: Literal["test", "prod"] | None = None
|
||||||
|
# Forma raspunsului: 'minim' (implicit) = 6 campuri fixe per rezultat
|
||||||
|
# (index, submission_id, vehicul, status, motiv, id_prezentare);
|
||||||
|
# 'complet' = SubmissionResult intreg (erori/nemapate/deduped/held/...).
|
||||||
|
raspuns: Literal["minim", "complet"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionResultMinim(BaseModel):
|
||||||
|
"""Rezultat compact per prezentare — forma implicita a raspunsului.
|
||||||
|
|
||||||
|
Chei FIXE (mereu prezente), usor de parsat de clienti simpli (ex. VFP):
|
||||||
|
`motiv` e null cand randul e curat in coada; `id_prezentare` e non-null
|
||||||
|
doar pe dedup al unui rand deja trimis la RAR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
submission_id: int | None = None
|
||||||
|
vehicul: str
|
||||||
|
status: str
|
||||||
|
motiv: str | None = None
|
||||||
|
id_prezentare: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubmissionResult(BaseModel):
|
class SubmissionResult(BaseModel):
|
||||||
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
|
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
|
||||||
submission_id: int | None = None
|
submission_id: int | None = None
|
||||||
|
# Corelare cu cererea (campuri aditive): pozitia 0-based in `prezentari`
|
||||||
|
# + ecoul vehiculului trimis, ca integratorul sa lege fiecare rezultat
|
||||||
|
# de randul lui fara sa se bazeze doar pe ordine.
|
||||||
|
index: int | None = None
|
||||||
|
vin: str | None = None
|
||||||
|
nr_inmatriculare: str | None = None
|
||||||
status: str
|
status: str
|
||||||
id_prezentare: int | None = None
|
id_prezentare: int | None = None
|
||||||
deduped: bool = False # True daca idempotency a intors un submission existent
|
deduped: bool = False # True daca idempotency a intors un submission existent
|
||||||
@@ -127,7 +153,9 @@ class SubmissionResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrezentariResponse(BaseModel):
|
class PrezentariResponse(BaseModel):
|
||||||
results: list[SubmissionResult]
|
# Uniune per element: instantele sunt deja modelul potrivit (smart union
|
||||||
|
# pydantic v2 pastreaza tipul exact), deci serializarea nu amesteca formele.
|
||||||
|
results: list[SubmissionResult | SubmissionResultMinim]
|
||||||
|
|
||||||
|
|
||||||
class ValidarePrezentariRequest(BaseModel):
|
class ValidarePrezentariRequest(BaseModel):
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
celulele raman semnale compacte (bulina, litera). #}
|
celulele raman semnale compacte (bulina, litera). #}
|
||||||
<li class="trimiteri-head" aria-hidden="true">
|
<li class="trimiteri-head" aria-hidden="true">
|
||||||
<span></span>
|
<span></span>
|
||||||
|
<span>#</span>
|
||||||
<span>Vehicul</span>
|
<span>Vehicul</span>
|
||||||
<span>Cod RAR</span>
|
<span>Cod RAR</span>
|
||||||
<span>Operatie</span>
|
<span>Operatie</span>
|
||||||
@@ -144,6 +145,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Numarul trimiterii (id din baza) — aceeasi valoare ca `submission_id`
|
||||||
|
din raspunsul API si ca titlul modalului de detaliu/editare. #}
|
||||||
|
<span class="c-id">#{{ r.id }}</span>
|
||||||
|
|
||||||
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
|
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
|
||||||
(niciodata em-dash izolat). #}
|
(niciodata em-dash izolat). #}
|
||||||
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
||||||
|
|||||||
@@ -758,7 +758,7 @@
|
|||||||
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
||||||
.trimiteri-head, .trimitere-slim {
|
.trimiteri-head, .trimitere-slim {
|
||||||
display:grid; align-items:center; column-gap:10px; row-gap:2px;
|
display:grid; align-items:center; column-gap:10px; row-gap:2px;
|
||||||
grid-template-columns:22px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
|
grid-template-columns:22px 42px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
|
||||||
.trimiteri-head { padding:2px 16px 8px; border-bottom:1px solid var(--line);
|
.trimiteri-head { padding:2px 16px 8px; border-bottom:1px solid var(--line);
|
||||||
font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
|
font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
|
||||||
color:var(--muted); }
|
color:var(--muted); }
|
||||||
@@ -769,6 +769,10 @@
|
|||||||
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||||
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||||
.trimitere-slim .c-check { display:flex; align-items:center; }
|
.trimitere-slim .c-check { display:flex; align-items:center; }
|
||||||
|
/* Numarul trimiterii: id-ul din baza, corelabil cu submission_id din API. */
|
||||||
|
.trimitere-slim .c-id { font-family:var(--font-mono); font-size:var(--fs-xs);
|
||||||
|
color:var(--muted); white-space:nowrap;
|
||||||
|
font-variant-numeric:tabular-nums; }
|
||||||
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
|
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
|
||||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||||
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
|
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
|
||||||
@@ -800,7 +804,7 @@
|
|||||||
.env-l-test { color:var(--muted); border:1px solid var(--line); }
|
.env-l-test { color:var(--muted); border:1px solid var(--line); }
|
||||||
.trimitere-slim .c-act { justify-self:end; }
|
.trimitere-slim .c-act { justify-self:end; }
|
||||||
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
|
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
|
||||||
.trimitere-slim .c-err { grid-column:2 / -1; min-width:0; font-family:var(--font-mono);
|
.trimitere-slim .c-err { grid-column:3 / -1; min-width:0; font-family:var(--font-mono);
|
||||||
font-size:var(--fs-xs); color:var(--err); white-space:nowrap; overflow:hidden;
|
font-size:var(--fs-xs); color:var(--err); white-space:nowrap; overflow:hidden;
|
||||||
text-overflow:ellipsis; }
|
text-overflow:ellipsis; }
|
||||||
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
||||||
@@ -850,12 +854,13 @@
|
|||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
.trimiteri-head { display:none; }
|
.trimiteri-head { display:none; }
|
||||||
.trimitere-slim { padding:12px 14px; column-gap:8px;
|
.trimitere-slim { padding:12px 14px; column-gap:8px;
|
||||||
grid-template-columns:22px minmax(0,1fr) auto auto auto;
|
grid-template-columns:22px auto minmax(0,1fr) auto auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"check veh act env status"
|
"check id veh act env status"
|
||||||
"check meta act env status"
|
"check meta meta act env status"
|
||||||
"err err err err err"; }
|
"err err err err err err"; }
|
||||||
.trimitere-slim .c-check { grid-area:check; }
|
.trimitere-slim .c-check { grid-area:check; }
|
||||||
|
.trimitere-slim .c-id { grid-area:id; align-self:center; }
|
||||||
.trimitere-slim .slim-vin { grid-area:veh; font-size:var(--fs-md); }
|
.trimitere-slim .slim-vin { grid-area:veh; font-size:var(--fs-md); }
|
||||||
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
|
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
|
||||||
gap:6px; min-width:0; margin-top:3px; }
|
gap:6px; min-width:0; margin-top:3px; }
|
||||||
|
|||||||
@@ -444,6 +444,26 @@ randul se RE-ACTIVEAZA (re-clasificat + creds actualizate) si raspunsul poarta
|
|||||||
care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
|
care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
|
||||||
`deduped: true` (neschimbat).
|
`deduped: true` (neschimbat).
|
||||||
|
|
||||||
|
**Forma raspunsului `POST /v1/prezentari` (2026-07-08).** Camp optional `raspuns` in
|
||||||
|
cerere: `"minim"` (implicit) sau `"complet"`. Rezultatele vin mereu in aceeasi ordine
|
||||||
|
cu lista `prezentari` din cerere.
|
||||||
|
|
||||||
|
- `minim` — 6 chei fixe per rezultat, usor de parsat de clienti simpli (VFP):
|
||||||
|
`index` (pozitia 0-based in cerere), `submission_id`, `vehicul` (nr. inmatriculare,
|
||||||
|
ecou normalizat), `status`, `motiv` (rezumat uman pe o linie; null pe queued curat),
|
||||||
|
`id_prezentare` (non-null doar pe dedup al unui rand deja trimis la RAR).
|
||||||
|
- `complet` — `SubmissionResult` intreg: in plus `erori`/`nemapate` (3 niveluri),
|
||||||
|
`deduped`, `reactivated`, `held`, `rar_env`, plus corelarea `index`/`vin`/
|
||||||
|
`nr_inmatriculare`. Formatul detaliat de dinainte, neschimbat.
|
||||||
|
|
||||||
|
**Sugestia principala pe coduri nemapate (2026-07-09).** Pe `needs_mapping`, `motiv`
|
||||||
|
include si propunerea de cod cand exista una: `Coduri fara mapare RAR: REP-MOT
|
||||||
|
(sugestie: OE-1 REPARATIE)`. In formatul `complet` (si in dry-run `/valideaza`),
|
||||||
|
fiecare element din `nemapate` poarta `sugestie: {cod_prestatie, nume_prestatie,
|
||||||
|
sursa} | null` (sursa: `gold_partajat` | `silver` | `embedding` | `fuzzy`).
|
||||||
|
SUGGESTION-ONLY: nu se aplica automat; confirmarea se face in tab-ul Mapari sau
|
||||||
|
prin `POST /v1/mapari`.
|
||||||
|
|
||||||
- `DELETE /v1/prezentari/{id}` — sterge o trimitere blocata a contului cheii API.
|
- `DELETE /v1/prezentari/{id}` — sterge o trimitere blocata a contului cheii API.
|
||||||
**200 + body JSON** `{ok, submission_id, status_anterior}` (NU 204 — clienti VFP string-parse).
|
**200 + body JSON** `{ok, submission_id, status_anterior}` (NU 204 — clienti VFP string-parse).
|
||||||
Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3);
|
Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def _body(**over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def test_prezentare_valida_queued(client):
|
def test_prezentare_valida_queued(client):
|
||||||
@@ -117,6 +117,66 @@ def test_raspuns_nu_reflecta_parola(client):
|
|||||||
assert "password" not in r.text
|
assert "password" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rezultate_poarta_index_si_ecou_vehicul(client):
|
||||||
|
"""Fiecare rezultat e corelabil cu cererea: index 0-based + ecou vin/nr,
|
||||||
|
inclusiv pe randuri blocate si pe dedup."""
|
||||||
|
body = _body()
|
||||||
|
prez2 = dict(body["prezentari"][0])
|
||||||
|
prez2.update({"vin": "WAUZZZ8K9BA123456", "nr_inmatriculare": "cj45xyz",
|
||||||
|
"data_prestatie": "2099-01-01"})
|
||||||
|
body["prezentari"].append(prez2)
|
||||||
|
r = client.post("/v1/prezentari", json=body)
|
||||||
|
res = r.json()["results"]
|
||||||
|
assert [x["index"] for x in res] == [0, 1]
|
||||||
|
assert res[0]["vin"] == "WVWZZZ1KZAW000123"
|
||||||
|
assert res[1]["nr_inmatriculare"] == "CJ45XYZ" # ecou normalizat (upper)
|
||||||
|
assert res[1]["status"] == "needs_data"
|
||||||
|
|
||||||
|
# Dedup: acelasi continut -> rezultatul pastreaza corelarea.
|
||||||
|
r2 = client.post("/v1/prezentari", json=body)
|
||||||
|
res2 = r2.json()["results"]
|
||||||
|
assert res2[0]["deduped"] is True
|
||||||
|
assert res2[0]["index"] == 0
|
||||||
|
assert res2[0]["vin"] == "WVWZZZ1KZAW000123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nemapat_primeste_sugestie(client):
|
||||||
|
"""Cod nemapat cu denumire apropiata de nomenclator -> sugestie fuzzy in
|
||||||
|
nemapate[].sugestie (complet) si in motiv (ambele forme)."""
|
||||||
|
prestatii = [{"cod_op_service": "REV-PER", "denumire": "Revizie periodica"}]
|
||||||
|
r = client.post("/v1/prezentari", json=_body(prestatii=prestatii))
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["status"] == "needs_mapping"
|
||||||
|
sug = res["nemapate"][0]["sugestie"]
|
||||||
|
assert sug["cod_prestatie"] == "OE-3" # seed: REVIZIE PERIODICA
|
||||||
|
assert sug["sursa"] == "fuzzy"
|
||||||
|
assert "sugestie: OE-3" in res["motiv"]
|
||||||
|
|
||||||
|
# Forma minima: sugestia ramane vizibila in motiv.
|
||||||
|
body = _body(prestatii=prestatii, vin="WAUZZZ8K9BA123456")
|
||||||
|
del body["raspuns"]
|
||||||
|
r2 = client.post("/v1/prezentari", json=body)
|
||||||
|
assert "sugestie: OE-3" in r2.json()["results"][0]["motiv"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_raspuns_minim_implicit(client):
|
||||||
|
"""Fara `raspuns` in cerere -> forma compacta cu chei fixe, fara campurile detaliate."""
|
||||||
|
body = _body(prestatii=[{"cod_op_service": "REP-MOT", "denumire": "Reparatie motor"}])
|
||||||
|
del body["raspuns"]
|
||||||
|
r = client.post("/v1/prezentari", json=body)
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert set(res) == {"index", "submission_id", "vehicul", "status", "motiv", "id_prezentare"}
|
||||||
|
assert res["index"] == 0
|
||||||
|
assert res["vehicul"] == "B999TST"
|
||||||
|
assert res["status"] == "needs_mapping"
|
||||||
|
assert "REP-MOT" in res["motiv"]
|
||||||
|
|
||||||
|
# 'minim' explicit = acelasi lucru cu default-ul.
|
||||||
|
body["raspuns"] = "minim"
|
||||||
|
r2 = client.post("/v1/prezentari", json=body)
|
||||||
|
assert set(r2.json()["results"][0]) == set(res)
|
||||||
|
|
||||||
|
|
||||||
def test_json_malformat_422(client):
|
def test_json_malformat_422(client):
|
||||||
# Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data).
|
# Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data).
|
||||||
bad = {"rar_credentials": {"email": "x", "password": "y"},
|
bad = {"rar_credentials": {"email": "x", "password": "y"},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _body(rar_env=None, **over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
if rar_env is not None:
|
if rar_env is not None:
|
||||||
body["rar_env"] = rar_env
|
body["rar_env"] = rar_env
|
||||||
return body
|
return body
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ def test_retry_idempotent_nu_consuma_cota(client):
|
|||||||
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
|
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
|
||||||
|
|
||||||
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
|
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
|
||||||
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
|
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic], "raspuns": "complet"})
|
||||||
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
|
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
|
||||||
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
|
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ def _body(**over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def test_api_dedup_dupa_t9(client):
|
def test_api_dedup_dupa_t9(client):
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _body(password="corecta", **over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def _force_status(sid, status):
|
def _force_status(sid, status):
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def _body(**over) -> dict:
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"prezentari": [prez]}
|
return {"prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ def _body(prestatii, **over):
|
|||||||
"prestatii": prestatii,
|
"prestatii": prestatii,
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def test_nomenclator_seed_la_boot(client):
|
def test_nomenclator_seed_la_boot(client):
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def _body(**over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"rar_credentials": {"email": "x@y.ro", "password": "HUNTER2SECRET"}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": "HUNTER2SECRET"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def test_422_does_not_echo_password(env):
|
def test_422_does_not_echo_password(env):
|
||||||
|
|||||||
@@ -259,5 +259,5 @@ def test_mobil_pastreaza_doua_linii_cu_bulina_si_litera(client):
|
|||||||
html = client.get("/?tab=coada").text
|
html = client.get("/?tab=coada").text
|
||||||
assert re.search(r'\.trimiteri-head\s*\{\s*display\s*:\s*none', html), \
|
assert re.search(r'\.trimiteri-head\s*\{\s*display\s*:\s*none', html), \
|
||||||
"Headerul de coloane nu e ascuns pe mobil"
|
"Headerul de coloane nu e ascuns pe mobil"
|
||||||
m = re.search(r'grid-template-areas\s*:\s*"check veh\s+act env status"\s*"check meta act env status"', html)
|
m = re.search(r'grid-template-areas\s*:\s*"check id\s+veh\s+act env status"\s*"check meta meta act env status"', html)
|
||||||
assert m, "Grid-areas pe 2 linii (veh/meta) lipseste din CSS-ul mobil"
|
assert m, "Grid-areas pe 2 linii (id+veh/meta) lipseste din CSS-ul mobil"
|
||||||
|
|||||||
Reference in New Issue
Block a user