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:
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user