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.
146 lines
6.0 KiB
Python
146 lines
6.0 KiB
Python
"""Cheie de idempotenta = hash de continut canonic.
|
|
|
|
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra.
|
|
Hash stabil peste o reprezentare canonica a prezentarii.
|
|
|
|
canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
|
|
canalul import:
|
|
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
|
|
de validare si INAINTE de cheie.
|
|
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
|
|
Altfel acelasi rand logic din canale diferite (account_id None pe canalul API,
|
|
1 pe import) ar primi chei diferite -> al doilea FINALIZATA duplicat.
|
|
|
|
Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
|
|
already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
|
|
"""
|
|
|
|
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))
|
|
cod = (get("cod_prestatie", "") or "").strip().upper()
|
|
if cod:
|
|
return cod
|
|
return (get("cod_op_service", "") or "").strip()
|
|
|
|
|
|
def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalizare canonica a unui rand brut. Apelata INAINTE de validare si de build_key.
|
|
|
|
- VIN, nr_inmatriculare: strip + upper.
|
|
- odometru_final: strip ".0" (Excel coercion numeric 123456.0 -> "123456").
|
|
Necesar ca validation._parse_int (isdigit()) sa nu respinga float-string.
|
|
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
|
|
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
|
"""
|
|
vin = (raw.get("vin") or "").strip().upper()
|
|
nr = normalize_nr_inmatriculare(raw.get("nr_inmatriculare"))
|
|
|
|
# Odometru: strip ".0" Excel float coercion
|
|
odo_raw = raw.get("odometru_final")
|
|
if odo_raw is not None:
|
|
odo_s = str(odo_raw).strip()
|
|
# "123456.0" -> "123456"; "123456.50" nu (nu e coercion Excel pur)
|
|
if "." in odo_s:
|
|
before, after = odo_s.split(".", 1)
|
|
if after == "0" and before.lstrip("-").isdigit():
|
|
odo_s = before
|
|
else:
|
|
odo_s = ""
|
|
|
|
# Data (pastrata ca string; parsarea la YYYY-MM-DD e in parser)
|
|
data = str(raw.get("data_prestatie") or "").strip()
|
|
|
|
# Prestatii (copie superficiala; rezolvarea e upstream)
|
|
prestatii = list(raw.get("prestatii") or [])
|
|
|
|
return {
|
|
"vin": vin,
|
|
"nr_inmatriculare": nr,
|
|
"data_prestatie": data,
|
|
"odometru_final": odo_s,
|
|
"prestatii": prestatii,
|
|
}
|
|
|
|
|
|
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
|
|
"""SHA-256 partajat canal-API + canal-import, env-aware.
|
|
|
|
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
|
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
|
|
|
`rar_env` ('test'|'prod') intra in cheie: aceeasi prezentare la test si apoi la
|
|
prod sunt DOUA trimiteri reale distincte (sisteme RAR separate), nu un duplicat.
|
|
Default 'test' = back-compat cu apelantii care nu paseaza inca env-ul; toate
|
|
rutele de ingestie paseaza env-ul rezolvat explicit.
|
|
"""
|
|
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
|
from .mapping import account_or_default
|
|
acct = account_or_default(account_id)
|
|
canonic = {
|
|
"account_id": acct,
|
|
"rar_env": rar_env,
|
|
"vin": canon.get("vin", ""),
|
|
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
|
"data_prestatie": canon.get("data_prestatie"),
|
|
"odometru_final": canon.get("odometru_final", ""),
|
|
"prestatii": sorted(_op_identity(p) for p in (canon.get("prestatii") or [])),
|
|
}
|
|
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def idempotency_key(account_id: int | None, prezentare: dict[str, Any], rar_env: str = "test") -> str:
|
|
"""SHA-256 peste (account_id + rar_env + campurile semnificative ale prezentarii).
|
|
|
|
Wrapper backward-compat peste canonicalize_row + build_key.
|
|
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
|
|
|
NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
|
|
account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
|
|
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
|
"""
|
|
canon = canonicalize_row(prezentare)
|
|
return build_key(account_id, canon, rar_env)
|
|
|
|
|
|
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
|
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
|
|
|
|
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
|
|
Nu folosi pentru randuri noi.
|
|
"""
|
|
canonic = {
|
|
"account_id": account_id,
|
|
"vin": (prezentare.get("vin") or "").strip().upper(),
|
|
"nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
|
"data_prestatie": prezentare.get("data_prestatie"),
|
|
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
|
|
"prestatii": sorted(_op_identity(p) for p in (prezentare.get("prestatii") or [])),
|
|
}
|
|
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|