"""Cheie de idempotenta = hash de continut canonic. 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 import hashlib import json from typing import Any 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 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, "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()