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.
175 lines
5.8 KiB
Python
175 lines
5.8 KiB
Python
"""Validare de domeniu pentru prezentari.
|
|
|
|
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
|
|
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}$")
|
|
# 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 = normalize_nr_inmatriculare(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
|