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 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")

View File

@@ -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:

View File

@@ -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",