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.
This commit is contained in:
Claude Agent
2026-07-10 13:43:37 +00:00
parent 1091745789
commit 37172ae2f9
5 changed files with 82 additions and 8 deletions

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