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.
192 lines
7.5 KiB
Python
192 lines
7.5 KiB
Python
"""Modele Pydantic pentru suprafata API.
|
|
|
|
Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
|
|
continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
|
|
odometru) este in app.validation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
from app.idempotency import normalize_nr_inmatriculare
|
|
|
|
|
|
class RarCredentials(BaseModel):
|
|
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
|
|
|
|
email: str
|
|
# repr=False: str(creds) / loguri care fac repr pe model NU expun parola.
|
|
password: str = Field(..., repr=False)
|
|
|
|
|
|
class PrestatieItem(BaseModel):
|
|
"""O operatie de declarat. Contract hibrid:
|
|
|
|
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
|
|
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
|
|
mapeaza in cod RAR prin operations_mapping. Cel putin unul dintre
|
|
cod_prestatie / cod_op_service e obligatoriu (shape -> 422 daca lipsesc ambele).
|
|
"""
|
|
|
|
cod_prestatie: str | None = Field(None, description="cod din nomenclator RAR, ex. OE-1")
|
|
cod_op_service: str | None = Field(None, description="cod intern operatie ROAAUTO (mapat -> cod RAR)")
|
|
denumire: str | None = Field(None, description="denumirea operatiei ROAAUTO (pentru fuzzy lookup la mapare)")
|
|
|
|
@field_validator("cod_prestatie")
|
|
@classmethod
|
|
def _norm_cod(cls, v: str | None) -> str | None:
|
|
return v.strip().upper() if v else None
|
|
|
|
@field_validator("cod_op_service", "denumire")
|
|
@classmethod
|
|
def _norm_op(cls, v: str | None) -> str | None:
|
|
return v.strip() if v else None
|
|
|
|
@model_validator(mode="after")
|
|
def _require_one(self) -> "PrestatieItem":
|
|
if not self.cod_prestatie and not self.cod_op_service:
|
|
raise ValueError("fiecare prestatie are nevoie de cod_prestatie sau cod_op_service")
|
|
return self
|
|
|
|
|
|
class PrezentareIn(BaseModel):
|
|
"""O prezentare de declarat la RAR.
|
|
|
|
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
|
|
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
|
|
app.validation.validate_prezentare si NU resping cererea — marcheaza
|
|
`needs_data`.
|
|
"""
|
|
|
|
vin: str
|
|
nr_inmatriculare: str
|
|
data_prestatie: str # YYYY-MM-DD
|
|
odometru_final: str # string per contract
|
|
odometru_initial: str | None = None
|
|
prestatii: list[PrestatieItem]
|
|
sistem_reparat: str = "null"
|
|
obs: str | None = None
|
|
b64_image: str | None = None
|
|
|
|
@field_validator("vin")
|
|
@classmethod
|
|
def _norm_upper(cls, v: str) -> str:
|
|
return v.strip().upper()
|
|
|
|
@field_validator("nr_inmatriculare")
|
|
@classmethod
|
|
def _norm_nrinm(cls, v: str) -> str:
|
|
return normalize_nr_inmatriculare(v)
|
|
|
|
@field_validator("data_prestatie", "odometru_final")
|
|
@classmethod
|
|
def _norm_strip(cls, v: str) -> str:
|
|
return v.strip()
|
|
|
|
|
|
class PrezentareRequest(BaseModel):
|
|
"""Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR.
|
|
|
|
`rar_credentials` e OPTIONAL: daca lipseste, worker-ul foloseste creds-urile RAR
|
|
durabile salvate pe cont (per-env: `accounts.rar_creds_{env}_enc`, via POST /v1/conturi/rar-creds).
|
|
Trimite-le explicit doar cand vrei sa suprascrii creds-urile contului pe acea cerere.
|
|
"""
|
|
|
|
rar_credentials: RarCredentials | None = None
|
|
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
|
# Optional: override per-cerere al comportamentului la cod necunoscut/nemapat.
|
|
# True -> respinge cererea fara enqueue (status 'error');
|
|
# False -> submission 'needs_mapping' (intra in editorul de mapare);
|
|
# None -> se foloseste accounts.on_unmapped_error_default (implicit False).
|
|
on_unmapped_error: bool | None = None
|
|
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
|
|
rar_env: Literal["test", "prod"] | None = None
|
|
# Forma raspunsului: 'minim' (implicit) = 6 campuri fixe per rezultat
|
|
# (index, submission_id, vehicul, status, motiv, id_prezentare);
|
|
# 'complet' = SubmissionResult intreg (erori/nemapate/deduped/held/...).
|
|
raspuns: Literal["minim", "complet"] | None = None
|
|
|
|
|
|
class SubmissionResultMinim(BaseModel):
|
|
"""Rezultat compact per prezentare — forma implicita a raspunsului.
|
|
|
|
Chei FIXE (mereu prezente), usor de parsat de clienti simpli (ex. VFP):
|
|
`motiv` e null cand randul e curat in coada; `id_prezentare` e non-null
|
|
doar pe dedup al unui rand deja trimis la RAR.
|
|
"""
|
|
|
|
index: int
|
|
submission_id: int | None = None
|
|
vehicul: str
|
|
status: str
|
|
motiv: str | None = None
|
|
id_prezentare: int | None = None
|
|
|
|
|
|
class SubmissionResult(BaseModel):
|
|
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
|
|
submission_id: int | None = None
|
|
# Corelare cu cererea (campuri aditive): pozitia 0-based in `prezentari`
|
|
# + ecoul vehiculului trimis, ca integratorul sa lege fiecare rezultat
|
|
# de randul lui fara sa se bazeze doar pe ordine.
|
|
index: int | None = None
|
|
vin: str | None = None
|
|
nr_inmatriculare: str | None = None
|
|
status: str
|
|
id_prezentare: int | None = None
|
|
deduped: bool = False # True daca idempotency a intors un submission existent
|
|
# Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost
|
|
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
|
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
|
reactivated: bool = False
|
|
# Ecou PUR de coada: True cand randul e `queued AND held=1`
|
|
# (tinut manual, cont Auto OFF) -> NU a plecat la RAR, asteapta eliberarea umana.
|
|
# NU influenteaza payload/idempotenta; onestitate fata de integratorul API (invariant 5.7).
|
|
held: bool = False
|
|
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
|
|
rar_env: str = "test"
|
|
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
|
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
|
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
|
|
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
|
|
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
|
|
# motiv = rezumat uman pe o linie (None cand status='queued').
|
|
erori: list[dict] = []
|
|
nemapate: list[dict] = []
|
|
motiv: str | None = None
|
|
|
|
|
|
class PrezentariResponse(BaseModel):
|
|
# Uniune per element: instantele sunt deja modelul potrivit (smart union
|
|
# pydantic v2 pastreaza tipul exact), deci serializarea nu amesteca formele.
|
|
results: list[SubmissionResult | SubmissionResultMinim]
|
|
|
|
|
|
class ValidarePrezentariRequest(BaseModel):
|
|
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue."""
|
|
|
|
rar_credentials: RarCredentials | None = None
|
|
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
|
on_unmapped_error: bool | None = None
|
|
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului.
|
|
rar_env: Literal["test", "prod"] | None = None
|
|
|
|
|
|
class ValidareResult(BaseModel):
|
|
"""Verdictul dry-run per prezentare."""
|
|
|
|
index: int
|
|
valid: bool
|
|
status_estimat: str # "queued" | "needs_data" | "needs_mapping"
|
|
rar_env: str = "test" # mediul RAR tinta efectiv (ecou din rezolvare)
|
|
erori: list[dict] = []
|
|
nemapate: list[dict] = []
|
|
prestatii_rezolvate: list[dict] = []
|
|
|
|
|
|
class ValidareResponse(BaseModel):
|
|
results: list[ValidareResult]
|