Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Agent
0a9ad71cdd fix(ping): raporteaza mediul RAR efectiv al contului, nu ancora globala
GET /v1/ping intorcea settings.rar_env (mediul global al serverului, ex.
"prod") in campul `mediu`, desi contul poate opera pe alt mediu. Un cont cu
doar creds de test primea "prod", iar clientul ROAAUTO afisa badge rosu
PRODUCTIE desi prezentarile ruleaza in test. Acum `mediu` = rar_env_efectiv
al contului (doar din mediile disponibile = activate + cu creds), cu fallback
pe ancora globala doar cand contul nu are niciun mediu disponibil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:03:23 +00:00
Claude Agent
37172ae2f9 fix(nrinmatriculare): normalizeaza spatii/cratime/puncte consecvent pe toate canalele
Doar 13.8% din numerele de inmatriculare istorice treceau validarea stricta
^[A-Z0-9]{1,10}$ (spike SQL), majoritatea din cauza formatelor reale cu
spatii/cratime ("B 99 XYZ", "B-99-XYZ") pe care normalizarea actuala
(strip+upper) nu le curata.

Adauga normalize_nr_inmatriculare() in app.idempotency (deja punctul comun
API+import pentru canonicalize_row) si o foloseste consecvent in:
- models.PrezentareIn (validare shape / normalizare API)
- validation.validate_prezentare (validare continut)
- idempotency.canonicalize_row (cheia de dedup)

Fara aceasta consecventa, validarea si idempotency ar diverge pe acelasi
numar de inmatriculare in formate echivalente.
2026-07-10 13:43:37 +00:00
6 changed files with 94 additions and 11 deletions

View File

@@ -21,6 +21,7 @@ from ...auth import _extract_key, account_for_key, resolve_account_id
from ...config import get_settings from ...config import get_settings
from ...db import get_connection from ...db import get_connection
from ...mapping import account_or_default from ...mapping import account_or_default
from ...rar_env import rar_env_efectiv
router = APIRouter(prefix="/v1", tags=["integrare"]) router = APIRouter(prefix="/v1", tags=["integrare"])
@@ -35,7 +36,8 @@ def ping(
Intoarce: Intoarce:
account_id — contul rezolvat din cheie (sau 1 in dev fara cheie) account_id — contul rezolvat din cheie (sau 1 in dev fara cheie)
mediu — "test" / "prod" (settings.rar_env) mediu — mediul RAR efectiv al contului ("test"/"prod"),
nu ancora globala settings.rar_env
autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida
are_creds_rar — True daca contul are creds RAR stocate pe cel putin un mediu (test sau prod) are_creds_rar — True daca contul are creds RAR stocate pe cel putin un mediu (test sau prod)
are_creds_test — True daca contul are creds RAR pentru mediul Testare are_creds_test — True daca contul are creds RAR pentru mediul Testare
@@ -62,7 +64,8 @@ def ping(
conn = get_connection() conn = get_connection()
try: try:
row = conn.execute( row = conn.execute(
"SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (aid,) "SELECT rar_test_enabled, rar_prod_enabled, rar_creds_test_enc, "
"rar_creds_prod_enc, rar_env_default FROM accounts WHERE id=?", (aid,)
).fetchone() ).fetchone()
finally: finally:
conn.close() conn.close()
@@ -71,9 +74,15 @@ def ping(
are_creds_prod = bool(row and row["rar_creds_prod_enc"]) are_creds_prod = bool(row and row["rar_creds_prod_enc"])
are_creds_rar = are_creds_test or are_creds_prod are_creds_rar = are_creds_test or are_creds_prod
# mediu = mediul RAR EFECTIV al contului (REQ-DEFAULT), nu ancora globala
# settings.rar_env: un cont cu doar creds de test trebuie sa raporteze "test"
# chiar daca serverul ruleaza global pe "prod". Fallback pe ancora globala doar
# cand contul nu are niciun mediu disponibil (rar_env_efectiv -> None).
mediu_efectiv = (rar_env_efectiv(row) if row else None) or settings.rar_env
return JSONResponse({ return JSONResponse({
"account_id": aid, "account_id": aid,
"mediu": settings.rar_env, "mediu": mediu_efectiv,
"autentificat_cu_cheie": autentificat_cu_cheie, "autentificat_cu_cheie": autentificat_cu_cheie,
"are_creds_rar": are_creds_rar, "are_creds_rar": are_creds_rar,
"are_creds_test": are_creds_test, "are_creds_test": are_creds_test,

View File

@@ -19,9 +19,25 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
from typing import Any from typing import Any
NRINM_STRIP_RE = re.compile(r"[\s\-.]+")
def normalize_nr_inmatriculare(value: object) -> str:
"""Normalizare canonica nr. inmatriculare: strip, upper, elimina spatii/cratime/puncte.
Formate reale ("B 99 XYZ", "B-99-XYZ") nu treceau NRINM_RE (app.validation) fara
aceasta curatare (spike SQL: doar 13.8% din numerele istorice treceau strict
^[A-Z0-9]{1,10}$). Folosita consecvent in models.PrezentareIn, validation._norm
si canonicalize_row de mai jos ca sa nu diverga validarea de cheia de dedup.
"""
s = str(value or "").strip().upper()
return NRINM_STRIP_RE.sub("", s)
def _op_identity(p: Any) -> str: def _op_identity(p: Any) -> str:
"""Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO.""" """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)) get = p.get if isinstance(p, dict) else (lambda k, d=None: getattr(p, k, d))
@@ -41,7 +57,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii). - prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
""" """
vin = (raw.get("vin") or "").strip().upper() vin = (raw.get("vin") or "").strip().upper()
nr = (raw.get("nr_inmatriculare") or "").strip().upper() nr = normalize_nr_inmatriculare(raw.get("nr_inmatriculare"))
# Odometru: strip ".0" Excel float coercion # Odometru: strip ".0" Excel float coercion
odo_raw = raw.get("odometru_final") odo_raw = raw.get("odometru_final")

View File

@@ -11,6 +11,8 @@ from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from app.idempotency import normalize_nr_inmatriculare
class RarCredentials(BaseModel): class RarCredentials(BaseModel):
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza.""" """Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
@@ -69,11 +71,16 @@ class PrezentareIn(BaseModel):
obs: str | None = None obs: str | None = None
b64_image: str | None = None b64_image: str | None = None
@field_validator("vin", "nr_inmatriculare") @field_validator("vin")
@classmethod @classmethod
def _norm_upper(cls, v: str) -> str: def _norm_upper(cls, v: str) -> str:
return v.strip().upper() return v.strip().upper()
@field_validator("nr_inmatriculare")
@classmethod
def _norm_nrinm(cls, v: str) -> str:
return normalize_nr_inmatriculare(v)
@field_validator("data_prestatie", "odometru_final") @field_validator("data_prestatie", "odometru_final")
@classmethod @classmethod
def _norm_strip(cls, v: str) -> str: def _norm_strip(cls, v: str) -> str:

View File

@@ -18,6 +18,7 @@ from datetime import date
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from app.errors import eroare as _eroare from app.errors import eroare as _eroare
from app.idempotency import normalize_nr_inmatriculare
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract). # VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
@@ -73,7 +74,7 @@ def validate_prezentare(content: dict) -> list[dict]:
)) ))
# --- nrInmatriculare --- # --- nrInmatriculare ---
nrinm = _norm(content.get("nr_inmatriculare")) nrinm = normalize_nr_inmatriculare(content.get("nr_inmatriculare"))
if not NRINM_RE.match(nrinm): if not NRINM_RE.match(nrinm):
errors.append(_eroare( errors.append(_eroare(
"NR_INMATRICULARE_FORMAT", "NR_INMATRICULARE_FORMAT",

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.idempotency import build_key, canonicalize_row from app.idempotency import build_key, canonicalize_row, normalize_nr_inmatriculare
def _canon(): def _canon():
@@ -26,3 +26,35 @@ def test_key_stabil_pe_env():
assert build_key(1, canon, "prod") == build_key(1, canon, "prod") assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env # None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
assert build_key(None, canon, "test") == build_key(1, canon, "test") assert build_key(None, canon, "test") == build_key(1, canon, "test")
def test_normalize_nr_inmatriculare_elimina_spatii_cratime_puncte():
assert normalize_nr_inmatriculare("B 99 XYZ") == "B99XYZ"
assert normalize_nr_inmatriculare("B-99-XYZ") == "B99XYZ"
assert normalize_nr_inmatriculare("B.99.XYZ") == "B99XYZ"
assert normalize_nr_inmatriculare(" b99xyz ") == "B99XYZ"
def test_canonicalize_row_normalizeaza_nr_inmatriculare():
raw = {"nr_inmatriculare": "B 99 XYZ"}
assert canonicalize_row(raw)["nr_inmatriculare"] == "B99XYZ"
def test_build_key_acelasi_pentru_formate_echivalente_nrinm():
""""B 99 XYZ" si "B99XYZ" trebuie sa produca aceeasi cheie de dedup."""
raw_cu_spatii = {
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B 99 XYZ",
"data_prestatie": "2026-01-10", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
raw_fara_spatii = {
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B99XYZ",
"data_prestatie": "2026-01-10", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
canon1 = canonicalize_row(raw_cu_spatii)
canon1["prestatii"] = raw_cu_spatii["prestatii"]
canon2 = canonicalize_row(raw_fara_spatii)
canon2["prestatii"] = raw_fara_spatii["prestatii"]
assert build_key(1, canon1, "test") == build_key(1, canon2, "test")

View File

@@ -63,10 +63,28 @@ def test_nrinmatriculare_prea_lung():
def test_nrinmatriculare_caracter_special(): def test_nrinmatriculare_caracter_special():
errors = validate_prezentare(_base(nr_inmatriculare="B-99")) errors = validate_prezentare(_base(nr_inmatriculare="B_99"))
assert "nr_inmatriculare" in _fields(errors) assert "nr_inmatriculare" in _fields(errors)
def test_nrinmatriculare_normalizeaza_spatii():
c = _base(nr_inmatriculare="B 99 XYZ")
assert c["nr_inmatriculare"] == "B99XYZ"
assert validate_prezentare(c) == []
def test_nrinmatriculare_normalizeaza_cratime():
c = _base(nr_inmatriculare="B-99-XYZ")
assert c["nr_inmatriculare"] == "B99XYZ"
assert validate_prezentare(c) == []
def test_nrinmatriculare_normalizeaza_puncte():
c = _base(nr_inmatriculare="B.99.XYZ")
assert c["nr_inmatriculare"] == "B99XYZ"
assert validate_prezentare(c) == []
def test_data_prea_veche(): def test_data_prea_veche():
errors = validate_prezentare(_base(data_prestatie="2024-11-30")) errors = validate_prezentare(_base(data_prestatie="2024-11-30"))
assert "data_prestatie" in _fields(errors) assert "data_prestatie" in _fields(errors)
@@ -135,7 +153,7 @@ def test_b64image_valid_ok():
def test_erori_multiple_cumulate(): def test_erori_multiple_cumulate():
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01")) errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X_Y", data_prestatie="2024-01-01"))
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors) assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
@@ -215,7 +233,7 @@ def test_back_compat_field_message():
"""Fiecare eroare are inca field + message (forma veche).""" """Fiecare eroare are inca field + message (forma veche)."""
errs = validate_prezentare(_base( errs = validate_prezentare(_base(
vin="BAD", vin="BAD",
nr_inmatriculare="X-Y", nr_inmatriculare="X_Y",
data_prestatie="2024-01-01", data_prestatie="2024-01-01",
odometru_final="abc", odometru_final="abc",
prestatii=[], prestatii=[],
@@ -232,7 +250,7 @@ def test_toate_codurile_in_catalog():
cazuri = [ cazuri = [
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT _base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT _base(nr_inmatriculare="X_Y"), # NR_INMATRICULARE_FORMAT
_base(data_prestatie="15-06-2026"), # DATA_FORMAT _base(data_prestatie="15-06-2026"), # DATA_FORMAT
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE _base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR _base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR