feat(import): T9 canonicalize_row + build_key partajat (idempotency)
- canonicalize_row: VIN upper, odometru strip ".0" (Excel float coercion), data strip — INAINTE de validare si cheie (§3.4bis) - build_key: aplica account_or_default(None->1) inainte de hash (OV-2): canal API (None) si canal import (1) produc aceeasi cheie - build_key_legacy: helper dual-lookup pentru randuri DB vechi (pre-T9) - router.py: POST /v1/prezentari foloseste build_key(account_id, canonicalize_row(content)) - 14 teste: canonicalizare, cross-canal, dedup float/int odometru, legacy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,23 @@
|
||||
|
||||
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra
|
||||
(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii.
|
||||
|
||||
Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice
|
||||
partajate intre canalul API si canalul import.
|
||||
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
|
||||
de validare (§3.4bis) si INAINTE de cheie.
|
||||
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
|
||||
|
||||
OV-2 — skew account_id: routerul vechi pasa account_id AS-PASSED (None pe canal API
|
||||
fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None.
|
||||
Acelasi rand logic din import (account_id=1) dadea cheie diferita -> already_sent
|
||||
rata -> al doilea FINALIZATA. Fix: build_key normalizeaza INTOTDEAUNA la
|
||||
account_or_default inainte de hash.
|
||||
|
||||
Migrare DB productie (OV-2): randurile existente cu cheie-None nu mai sunt gasite de
|
||||
build_key nou. Strategie documentata: dual-lookup la already_sent (incearca cheia
|
||||
noua, apoi cheia legacy). In dev nu exista date reale; la first-deploy productie
|
||||
se poate face recompute-keys o singura data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,10 +37,88 @@ def _op_identity(p: Any) -> str:
|
||||
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
|
||||
vin = (raw.get("vin") or "").strip().upper()
|
||||
|
||||
# Nr. inmatriculare
|
||||
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
|
||||
|
||||
# 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]) -> str:
|
||||
"""SHA-256 partajat canal-API + canal-import.
|
||||
|
||||
Aplica account_or_default inainte de hash (OV-2): None si 1 colapseaza la
|
||||
aceeasi cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||
"""
|
||||
# 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,
|
||||
"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]) -> str:
|
||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
||||
|
||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||
|
||||
NOTA: dupa OV-2, 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)
|
||||
|
||||
|
||||
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
|
||||
(dinainte de T9). Nu folosi pentru randuri noi.
|
||||
"""
|
||||
canonic = {
|
||||
"account_id": account_id,
|
||||
@@ -31,9 +126,6 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
||||
"data_prestatie": prezentare.get("data_prestatie"),
|
||||
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
|
||||
# Identitatea operatiei = codul RAR daca exista, altfel codul intern ROAAUTO
|
||||
# (hibrid): doua trimiteri ale aceleiasi comenzi dedup corect indiferent de
|
||||
# forma in care vin codurile.
|
||||
"prestatii": sorted(_op_identity(p) for p in (prezentare.get("prestatii") or [])),
|
||||
}
|
||||
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
Reference in New Issue
Block a user