diff --git a/app/idempotency.py b/app/idempotency.py index f2d704c..5555793 100644 --- a/app/idempotency.py +++ b/app/idempotency.py @@ -19,9 +19,25 @@ from __future__ import annotations import hashlib import json +import re 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: """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)) @@ -41,7 +57,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]: - prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii). """ 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 odo_raw = raw.get("odometru_final") diff --git a/app/models.py b/app/models.py index 2bb5d97..ee7da6c 100644 --- a/app/models.py +++ b/app/models.py @@ -11,6 +11,8 @@ from typing import Literal from pydantic import BaseModel, Field, field_validator, model_validator +from app.idempotency import normalize_nr_inmatriculare + class RarCredentials(BaseModel): """Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza.""" @@ -69,11 +71,16 @@ class PrezentareIn(BaseModel): obs: str | None = None b64_image: str | None = None - @field_validator("vin", "nr_inmatriculare") + @field_validator("vin") @classmethod def _norm_upper(cls, v: str) -> str: 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") @classmethod def _norm_strip(cls, v: str) -> str: diff --git a/app/validation.py b/app/validation.py index f8f0115..6dceaea 100644 --- a/app/validation.py +++ b/app/validation.py @@ -18,6 +18,7 @@ from datetime import date from zoneinfo import ZoneInfo 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_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") @@ -73,7 +74,7 @@ def validate_prezentare(content: dict) -> list[dict]: )) # --- nrInmatriculare --- - nrinm = _norm(content.get("nr_inmatriculare")) + nrinm = normalize_nr_inmatriculare(content.get("nr_inmatriculare")) if not NRINM_RE.match(nrinm): errors.append(_eroare( "NR_INMATRICULARE_FORMAT", diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py index f341025..1617160 100644 --- a/tests/test_idempotency.py +++ b/tests/test_idempotency.py @@ -2,7 +2,7 @@ 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(): @@ -26,3 +26,35 @@ def test_key_stabil_pe_env(): assert build_key(1, canon, "prod") == build_key(1, canon, "prod") # None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env 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") + diff --git a/tests/test_validation.py b/tests/test_validation.py index a6c20a1..fd4361a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -63,10 +63,28 @@ def test_nrinmatriculare_prea_lung(): 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) +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(): errors = validate_prezentare(_base(data_prestatie="2024-11-30")) assert "data_prestatie" in _fields(errors) @@ -135,7 +153,7 @@ def test_b64image_valid_ok(): 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) @@ -215,7 +233,7 @@ def test_back_compat_field_message(): """Fiecare eroare are inca field + message (forma veche).""" errs = validate_prezentare(_base( vin="BAD", - nr_inmatriculare="X-Y", + nr_inmatriculare="X_Y", data_prestatie="2024-01-01", odometru_final="abc", prestatii=[], @@ -232,7 +250,7 @@ def test_toate_codurile_in_catalog(): cazuri = [ _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="2024-11-30"), # DATA_PREA_VECHE _base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR