"""Validare de domeniu pentru prezentari (T3). Replica regulile RAR (docs/api-rar-contract.md sect. "Reguli de validare") ÎNAINTE de enqueue, ca sa nu primim 4xx de la RAR. Spre deosebire de validarea de SHAPE (Pydantic, da 422 la JSON malformat), aceasta e validare de CONTINUT: esecurile NU resping cererea, ci marcheaza submission-ul `needs_data` (plan.md sect. 3 — masina de stari + failure registry). Asa apar in dashboard cu motiv, corectabile. Functiile sunt pure (dict -> listă erori), unit-testabile fara DB/HTTP. Erorile au forma {field, message} — aceeasi ca raspunsul de eroare RAR. """ from __future__ import annotations import base64 import re from datetime import date from zoneinfo import ZoneInfo from app.errors import eroare as _eroare # VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract). VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") # Numar inmatriculare: max 10, litere + cifre majuscule. NRINM_RE = re.compile(r"^[A-Z0-9]{1,10}$") # Coduri care fac odometruInitial obligatoriu. ODOMETER_CODES = {"R-ODO", "I-ODO"} # Interval dataPrestatie. MIN_DATA_PRESTATIE = date(2024, 12, 1) TZ_BUCURESTI = ZoneInfo("Europe/Bucharest") def _norm(value: object) -> str: return str(value or "").strip().upper() def _codes(prestatii: list | None) -> list[str]: out: list[str] = [] for p in prestatii or []: cod = p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None) if cod: out.append(str(cod).strip().upper()) return out def _parse_int(value: object) -> int | None: s = str(value or "").strip() if s.isdigit(): return int(s) return None def today_bucuresti() -> date: from datetime import datetime return datetime.now(TZ_BUCURESTI).date() def validate_prezentare(content: dict) -> list[dict]: """Intoarce lista erorilor de continut [{field, message}]. Goala = valid. `content` = PrezentareIn.model_dump() (campuri snake_case interne). """ errors: list[dict] = [] # --- VIN --- vin = _norm(content.get("vin")) if not VIN_RE.match(vin): errors.append(_eroare( "VIN_FORMAT", field="vin", cauza="VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.", )) # --- nrInmatriculare --- nrinm = _norm(content.get("nr_inmatriculare")) if not NRINM_RE.match(nrinm): errors.append(_eroare( "NR_INMATRICULARE_FORMAT", field="nr_inmatriculare", cauza="Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.", )) # --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti --- raw_data = str(content.get("data_prestatie") or "").strip() try: d = date.fromisoformat(raw_data) except ValueError: errors.append(_eroare( "DATA_FORMAT", field="data_prestatie", cauza="Format data invalid; foloseste YYYY-MM-DD.", )) d = None if d is not None: if d < MIN_DATA_PRESTATIE: errors.append(_eroare( "DATA_PREA_VECHE", field="data_prestatie", cauza="Data prestatiei nu poate fi anterioara datei de 01.12.2024.", )) elif d > today_bucuresti(): errors.append(_eroare( "DATA_VIITOR", field="data_prestatie", cauza="Data prestatiei nu poate fi in viitor.", )) # --- odometruFinal (string numeric) --- odo_final = _parse_int(content.get("odometru_final")) if odo_final is None: errors.append(_eroare( "ODOMETRU_FINAL_FORMAT", field="odometru_final", cauza="odometruFinal trebuie sa fie un numar intreg (ca string).", )) # --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal --- codes = _codes(content.get("prestatii")) needs_initial = bool(set(codes) & ODOMETER_CODES) raw_initial = content.get("odometru_initial") has_initial = str(raw_initial or "").strip() != "" if needs_initial and not has_initial: errors.append(_eroare( "ODOMETRU_INITIAL_LIPSA", field="odometru_initial", cauza="odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.", )) if has_initial: odo_initial = _parse_int(raw_initial) if odo_initial is None: errors.append(_eroare( "ODOMETRU_INITIAL_FORMAT", field="odometru_initial", cauza="odometruInitial trebuie sa fie un numar intreg.", )) elif odo_final is not None and odo_initial > odo_final: errors.append(_eroare( "ODOMETRU_INITIAL_ORDINE", field="odometru_initial", cauza="odometruInitial trebuie sa fie <= odometruFinal.", )) # --- prestatii nevide --- if not codes: errors.append(_eroare( "PRESTATII_GOALE", field="prestatii", cauza="Lista de prestatii nu poate fi goala.", )) # --- b64Image: optional, dar daca e prezent trebuie base64 valid --- b64 = content.get("b64_image") if b64: if not _is_valid_base64(str(b64)): errors.append(_eroare( "B64_INVALID", field="b64_image", cauza="b64Image nu este base64 valid.", )) return errors def _is_valid_base64(value: str) -> bool: s = value.strip() if not s: return False try: base64.b64decode(s, validate=True) return True except (ValueError, base64.binascii.Error): # type: ignore[attr-defined] return False