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
|
||||
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _op_identity(p: Any) -> str:
|
||||
"""Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO."""
|
||||
get = p.get if isinstance(p, dict) else (lambda k, d=None: getattr(p, k, d))
|
||||
cod = (get("cod_prestatie", "") or "").strip().upper()
|
||||
if cod:
|
||||
return cod
|
||||
return (get("cod_op_service", "") or "").strip()
|
||||
|
||||
|
||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
||||
|
||||
@@ -22,10 +31,10 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
||||
"data_prestatie": prezentare.get("data_prestatie"),
|
||||
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
|
||||
"prestatii": sorted(
|
||||
str(p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", ""))
|
||||
for p in (prezentare.get("prestatii") or [])
|
||||
),
|
||||
# Identitatea operatiei = codul RAR daca exista, altfel codul intern ROAAUTO
|
||||
# (hibrid): doua trimiteri ale aceleiasi comenzi dedup corect indiferent de
|
||||
# forma in care vin codurile.
|
||||
"prestatii": sorted(_op_identity(p) for p in (prezentare.get("prestatii") or [])),
|
||||
}
|
||||
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
301
app/mapping.py
Normal file
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 pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class RarCredentials(BaseModel):
|
||||
@@ -19,12 +19,33 @@ class RarCredentials(BaseModel):
|
||||
|
||||
|
||||
class PrestatieItem(BaseModel):
|
||||
cod_prestatie: str = Field(..., description="cod din nomenclator RAR, ex. OE-1")
|
||||
"""O operatie de declarat. Contract hibrid (decis 2026-06-15):
|
||||
|
||||
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
|
||||
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
|
||||
mapeaza in cod RAR prin operations_mapping. Cel putin unul dintre
|
||||
cod_prestatie / cod_op_service e obligatoriu (shape -> 422 daca lipsesc ambele).
|
||||
"""
|
||||
|
||||
cod_prestatie: str | None = Field(None, description="cod din nomenclator RAR, ex. OE-1")
|
||||
cod_op_service: str | None = Field(None, description="cod intern operatie ROAAUTO (mapat -> cod RAR)")
|
||||
denumire: str | None = Field(None, description="denumirea operatiei ROAAUTO (pentru fuzzy lookup la mapare)")
|
||||
|
||||
@field_validator("cod_prestatie")
|
||||
@classmethod
|
||||
def _norm_cod(cls, v: str) -> str:
|
||||
return v.strip().upper()
|
||||
def _norm_cod(cls, v: str | None) -> str | None:
|
||||
return v.strip().upper() if v else None
|
||||
|
||||
@field_validator("cod_op_service", "denumire")
|
||||
@classmethod
|
||||
def _norm_op(cls, v: str | None) -> str | None:
|
||||
return v.strip() if v else None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_one(self) -> "PrestatieItem":
|
||||
if not self.cod_prestatie and not self.cod_op_service:
|
||||
raise ValueError("fiecare prestatie are nevoie de cod_prestatie sau cod_op_service")
|
||||
return self
|
||||
|
||||
|
||||
class PrezentareIn(BaseModel):
|
||||
|
||||
33
app/nomenclator_seed.py
Normal file
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,
|
||||
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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
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);}
|
||||
.muted { color:var(--muted); }
|
||||
a { color:var(--accent); }
|
||||
.flash { background:#16241c; border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
|
||||
margin:0 0 12px; font-size:13px; }
|
||||
.maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line);
|
||||
flex-wrap:wrap; }
|
||||
.maprow:last-child { border-bottom:0; }
|
||||
.mapcol.grow { flex:1 1 280px; min-width:240px; }
|
||||
.sugg { color:var(--accent); }
|
||||
select, button, input[type=text] { font:inherit; background:var(--bg); color:var(--ink);
|
||||
border:1px solid var(--line); border-radius:6px; padding:6px 10px; }
|
||||
select { max-width:340px; }
|
||||
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.08); }
|
||||
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- incarcat o data; NU poll (sa nu stergem o selectie in curs). Se re-randeaza la salvare. -->
|
||||
<div hx-get="/_fragments/mapari" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="card"><div class="empty">se incarca mapari…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:14px; margin:0 0 12px;">Coada submissions</h2>
|
||||
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
|
||||
@@ -30,6 +30,7 @@ import httpx
|
||||
|
||||
from ..config import Settings, get_settings, load_test_credentials
|
||||
from ..db import get_connection, init_db, write_heartbeat
|
||||
from ..mapping import upsert_nomenclator
|
||||
from ..payload import build_rar_payload
|
||||
from ..reconcile import match_finalizata
|
||||
from ..rar_client import RarAuthError, RarClient, RarError
|
||||
@@ -202,6 +203,16 @@ def _queue_depth(conn) -> int:
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def _refresh_nomenclator(conn, rar: RarClient, token: str) -> None:
|
||||
"""Ia nomenclatorul live din RAR si il upsert-eaza local. Erorile NU opresc worker-ul."""
|
||||
try:
|
||||
items = rar.get_nomenclator(token)
|
||||
n = upsert_nomenclator(conn, items)
|
||||
print(f"[worker] nomenclator refresh: {n} coduri", flush=True)
|
||||
except Exception as exc: # noqa: BLE001 — refresh best-effort, nu blocheaza trimiterea
|
||||
print(f"[worker] nomenclator refresh esuat (continui): {exc}", flush=True)
|
||||
|
||||
|
||||
def run() -> int:
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
@@ -231,6 +242,9 @@ def run() -> int:
|
||||
rar = RarClient(settings)
|
||||
token = rar.login(creds["email"], creds["password"])
|
||||
write_heartbeat(conn, rar_login_ok=True, detail="login RAR ok")
|
||||
# Refresh nomenclator live (autoritativ) la fiecare login proaspat —
|
||||
# alimenteaza fuzzy lookup-ul din editorul de mapari.
|
||||
_refresh_nomenclator(conn, rar, token)
|
||||
|
||||
recover_orphans(conn, settings, rar, token)
|
||||
|
||||
|
||||
@@ -265,6 +265,35 @@ Fiecare item din `content` (live):
|
||||
7. **URL nomenclator confirmat** = `/nomenclator/getNomenclatorPrestatii` (nu varianta din
|
||||
operationId Swagger). Constatarea #5 din `plan-eng-review` (URL-uri din VFP, nu din spec) — confirmată.
|
||||
|
||||
## API gateway (ROAAUTO -> gateway): mapare operatii (hibrid, 2026-06-15)
|
||||
|
||||
Aceasta e suprafata **gateway-ului**, nu RAR. Un item din `prestatii` la
|
||||
`POST /v1/prezentari` poate veni in doua forme (cel putin una obligatorie):
|
||||
|
||||
| Camp item | Note |
|
||||
|---|---|
|
||||
| `cod_prestatie` | cod RAR direct (ex. `OE-1`). Trece neatins -> validare T3 -> coada. |
|
||||
| `cod_op_service` | cod intern ROAAUTO. Gateway-ul il traduce in cod RAR prin `operations_mapping`. |
|
||||
| `denumire` | denumirea operatiei ROAAUTO; folosita pentru fuzzy lookup in editor. |
|
||||
|
||||
Daca lipsesc **ambele** coduri -> `422` (shape). Op cu `cod_op_service` necunoscut
|
||||
(fara mapare) -> submission `needs_mapping` (NU se trimite la RAR), apare in editorul
|
||||
web. La salvarea maparii, submission-urile blocate pe acel cod se re-rezolva automat
|
||||
(-> `queued`, sau `needs_data` daca regula odometru/continut cere asta). Codul RAR
|
||||
rezolvat se scrie inapoi in `payload_json`, deci payload builder + worker raman
|
||||
code-driven.
|
||||
|
||||
Endpointuri noi:
|
||||
- `GET /v1/mapari/pending` — operatii nemapate distincte + sugestii fuzzy (`{cod_prestatie, nume_prestatie, score}`).
|
||||
- `POST /v1/mapari` `{account_id?, cod_op_service, cod_prestatie, auto_send}` — upsert mapare + re-rezolvare. Respinge `cod_prestatie` inexistent in nomenclator (422).
|
||||
- Web: `GET /_fragments/mapari` (editor HTMX), `POST /mapari` (form, salveaza + re-randeaza).
|
||||
|
||||
Fuzzy: `rapidfuzz.token_sort_ratio` pe denumire normalizata (fara diacritice, upper).
|
||||
Nomenclatorul se ia **live** din RAR (worker upsert la fiecare login); seed fallback
|
||||
de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline.
|
||||
Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit
|
||||
contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural.
|
||||
|
||||
## Open questions rămase (actualizat)
|
||||
|
||||
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
||||
|
||||
@@ -7,6 +7,8 @@ jinja2==3.1.*
|
||||
pydantic==2.8.2
|
||||
pydantic-settings==2.*
|
||||
python-multipart==0.0.*
|
||||
# Fuzzy lookup pentru editorul de mapari operatii (app/mapping.py). Pur Python/C, fara build extern.
|
||||
rapidfuzz==3.14.5
|
||||
|
||||
# Migrare DBF (tools/import_dbf.py — T5). Necesar doar pentru import, nu pentru runtime.
|
||||
# Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime.
|
||||
dbfread==2.0.7
|
||||
|
||||
163
tests/test_mapping.py
Normal file
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