feat(errors): erori pe 3 niveluri (problema+cauza+fix) pe API si UI (PRD 5.4)
Catalog central pur app/errors.py ca sursa unica cod->{problema,fix},
consumat de API+UI+worker. Aditiv (field/message pastrate la octet) +
rar_error stocat superset. Scope: fluxul de declarare; login/signup/CSRF
neatinse. labels.parse_erori degradeaza gratios; UI progresiv AA light+dark.
631 teste.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ... import errors
|
||||||
from ...auth import resolve_account_id
|
from ...auth import resolve_account_id
|
||||||
from ...crypto import decrypt_creds, encrypt_creds
|
from ...crypto import decrypt_creds, encrypt_creds
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
@@ -326,7 +327,15 @@ def apply_row_override(
|
|||||||
dec = decrypt_creds(row["oj"])
|
dec = decrypt_creds(row["oj"])
|
||||||
if dec is None:
|
if dec is None:
|
||||||
# Decrypt fail (cheie schimbata / token corupt): no-op defensiv, NICIODATA scriere goala.
|
# Decrypt fail (cheie schimbata / token corupt): no-op defensiv, NICIODATA scriere goala.
|
||||||
raise HTTPException(status_code=422, detail="override curent ilizibil; editare anulata")
|
_oi_msg = "override curent ilizibil; editare anulata"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"error": "override_ilizibil",
|
||||||
|
"message": _oi_msg,
|
||||||
|
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=_oi_msg),
|
||||||
|
},
|
||||||
|
)
|
||||||
current = dec
|
current = dec
|
||||||
|
|
||||||
new_override = _merge_override(current, fields)
|
new_override = _merge_override(current, fields)
|
||||||
@@ -406,6 +415,7 @@ async def upload_import(
|
|||||||
"error": "multiple_sheets",
|
"error": "multiple_sheets",
|
||||||
"message": str(ms),
|
"message": str(ms),
|
||||||
"sheets": ms.sheet_names,
|
"sheets": ms.sheet_names,
|
||||||
|
**errors.eroare("IMPORT_MULTIPLE_SHEETS", cauza=str(ms)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except FileTooLarge as e:
|
except FileTooLarge as e:
|
||||||
@@ -414,6 +424,7 @@ async def upload_import(
|
|||||||
detail={
|
detail={
|
||||||
"error": "file_too_large",
|
"error": "file_too_large",
|
||||||
"message": str(e),
|
"message": str(e),
|
||||||
|
**errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except HeaderError as e:
|
except HeaderError as e:
|
||||||
@@ -423,23 +434,28 @@ async def upload_import(
|
|||||||
"error": "header_error",
|
"error": "header_error",
|
||||||
"message": str(e),
|
"message": str(e),
|
||||||
"found": e.found,
|
"found": e.found,
|
||||||
|
**errors.eroare("IMPORT_ANTET_NECLAR", cauza=str(e)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError as e:
|
||||||
|
_enc_msg = f"Encoding nesuportat: {e.reason}"
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
"error": "encoding_error",
|
"error": "encoding_error",
|
||||||
"message": f"Encoding nesuportat: {e.reason}",
|
"message": _enc_msg,
|
||||||
|
**errors.eroare("IMPORT_ENCODING", cauza=_enc_msg),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fisier corupt (BadZipFile, InvalidFileException, etc.)
|
# Fisier corupt (BadZipFile, InvalidFileException, etc.)
|
||||||
|
_inv_msg = f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
"error": "invalid_file",
|
"error": "invalid_file",
|
||||||
"message": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
|
"message": _inv_msg,
|
||||||
|
**errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=_inv_msg),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -713,11 +729,13 @@ def preview_import(
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not mapping_row:
|
if not mapping_row:
|
||||||
|
_ncm_msg = "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
"error": "no_column_mapping",
|
"error": "no_column_mapping",
|
||||||
"message": "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea.",
|
"message": _ncm_msg,
|
||||||
|
**errors.eroare("IMPORT_FARA_MAPARE_COLOANE", cauza=_ncm_msg),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -971,12 +989,14 @@ def commit_import(
|
|||||||
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
|
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
|
||||||
n_total_ok = len(ok_rows)
|
n_total_ok = len(ok_rows)
|
||||||
if req.n_confirmat != n_total_ok:
|
if req.n_confirmat != n_total_ok:
|
||||||
|
_cg_msg = f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul."
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
"error": "confirmare_gresita",
|
"error": "confirmare_gresita",
|
||||||
"message": f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul.",
|
"message": _cg_msg,
|
||||||
"n_ok": n_total_ok,
|
"n_ok": n_total_ok,
|
||||||
|
**errors.eroare("IMPORT_CONFIRMARE_GRESITA", cauza=_cg_msg),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from pydantic import BaseModel, Field
|
|||||||
from ...auth import resolve_account_id
|
from ...auth import resolve_account_id
|
||||||
from ...crypto import encrypt_creds
|
from ...crypto import encrypt_creds
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
|
from ...errors import eroare as err_eroare
|
||||||
from ...idempotency import build_key, canonicalize_row
|
from ...idempotency import build_key, canonicalize_row
|
||||||
from ...mapping import (
|
from ...mapping import (
|
||||||
account_or_default,
|
account_or_default,
|
||||||
@@ -133,12 +134,17 @@ def valideaza_prezentari(
|
|||||||
for i, prez in enumerate(req.prezentari):
|
for i, prez in enumerate(req.prezentari):
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
res = classify_prezentare(content, mapping, mapping_meta)
|
res = classify_prezentare(content, mapping, mapping_meta)
|
||||||
|
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||||
|
nemapate = [
|
||||||
|
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
|
||||||
|
for u in res["unmapped"]
|
||||||
|
]
|
||||||
results.append(ValidareResult(
|
results.append(ValidareResult(
|
||||||
index=i,
|
index=i,
|
||||||
valid=(res["status"] == "queued"),
|
valid=(res["status"] == "queued"),
|
||||||
status_estimat=res["status"],
|
status_estimat=res["status"],
|
||||||
erori=res["errors"],
|
erori=res["errors"],
|
||||||
nemapate=res["unmapped"],
|
nemapate=nemapate,
|
||||||
prestatii_rezolvate=res["resolved"],
|
prestatii_rezolvate=res["resolved"],
|
||||||
))
|
))
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
203
app/errors.py
Normal file
203
app/errors.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""Catalog central de erori AutoPass (PRD 5.4).
|
||||||
|
|
||||||
|
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
|
||||||
|
cu un helper care construieste obiectul de eroare pe 3 niveluri:
|
||||||
|
- nivel 1 (tehnic): `cod` + `cauza` — ce s-a intamplat exact
|
||||||
|
- nivel 2 (utilizator): `problema` — descriere scurta, inteligibila
|
||||||
|
- nivel 3 (actiune): `fix` — ce trebuie facut pentru a remedia
|
||||||
|
|
||||||
|
Modul PUR — fara import DB sau HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CATALOG
|
||||||
|
# cheie = cod (string), valoare = {"problema": str, "fix": str}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CATALOG: dict[str, dict[str, str]] = {
|
||||||
|
"VIN_FORMAT": {
|
||||||
|
"problema": "VIN invalid",
|
||||||
|
"fix": (
|
||||||
|
"Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere"
|
||||||
|
" majuscule, fara spatii si fara literele O, I, Q."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"NR_INMATRICULARE_FORMAT": {
|
||||||
|
"problema": "Numar de inmatriculare invalid",
|
||||||
|
"fix": (
|
||||||
|
"Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii"
|
||||||
|
" sau cratima (ex. B123ABC)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"DATA_FORMAT": {
|
||||||
|
"problema": "Data prestatiei in format gresit",
|
||||||
|
"fix": "Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22).",
|
||||||
|
},
|
||||||
|
"DATA_PREA_VECHE": {
|
||||||
|
"problema": "Data prestatiei prea veche",
|
||||||
|
"fix": (
|
||||||
|
"RAR accepta prestatii doar incepand cu 01.12.2024;"
|
||||||
|
" verifica data prestatiei."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"DATA_VIITOR": {
|
||||||
|
"problema": "Data prestatiei in viitor",
|
||||||
|
"fix": "Data prestatiei nu poate fi dupa ziua de azi; corecteaza data.",
|
||||||
|
},
|
||||||
|
"ODOMETRU_FINAL_FORMAT": {
|
||||||
|
"problema": "Odometru final invalid",
|
||||||
|
"fix": (
|
||||||
|
"Scrie kilometrajul final ca numar intreg, fara zecimale sau text"
|
||||||
|
" (ex. 145000)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ODOMETRU_INITIAL_LIPSA": {
|
||||||
|
"problema": "Lipseste odometrul initial",
|
||||||
|
"fix": (
|
||||||
|
"Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ODOMETRU_INITIAL_FORMAT": {
|
||||||
|
"problema": "Odometru initial invalid",
|
||||||
|
"fix": (
|
||||||
|
"Scrie kilometrajul initial ca numar intreg, fara zecimale sau text."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ODOMETRU_INITIAL_ORDINE": {
|
||||||
|
"problema": "Odometru initial mai mare decat finalul",
|
||||||
|
"fix": (
|
||||||
|
"Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final;"
|
||||||
|
" verifica cele doua valori."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"PRESTATII_GOALE": {
|
||||||
|
"problema": "Nicio prestatie",
|
||||||
|
"fix": "Adauga cel putin o prestatie cu cod RAR valid.",
|
||||||
|
},
|
||||||
|
"B64_INVALID": {
|
||||||
|
"problema": "Imaginea nu este base64 valid",
|
||||||
|
"fix": (
|
||||||
|
"Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"COD_NEMAPAT": {
|
||||||
|
"problema": "Lipseste codul RAR al operatiei",
|
||||||
|
"fix": (
|
||||||
|
"Alege codul RAR pentru aceasta operatie in tab-ul Mapari"
|
||||||
|
" (ai sugestii automate)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"AUTO_SEND_OPRIT": {
|
||||||
|
"problema": "Necesita confirmare manuala",
|
||||||
|
"fix": (
|
||||||
|
"Codul e mapat cu trimitere automata oprita; verifica randul si"
|
||||||
|
" pune-l manual in coada."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"RAR_VALIDARE": {
|
||||||
|
"problema": "RAR a respins prezentarea",
|
||||||
|
"fix": (
|
||||||
|
"Corecteaza campul semnalat de RAR (vezi cauza) si reincearca;"
|
||||||
|
" detaliile exacte sunt in mesajul tehnic RAR."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"RAR_CREDS_INVALIDE": {
|
||||||
|
"problema": "Credentiale RAR invalide",
|
||||||
|
"fix": (
|
||||||
|
"Verifica email-ul si parola contului RAR in tab-ul Cont;"
|
||||||
|
" trimiterea nu se reincearca automat la credentiale gresite."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"IMPORT_FISIER_PREA_MARE": {
|
||||||
|
"problema": "Fisier prea mare",
|
||||||
|
"fix": (
|
||||||
|
"Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"IMPORT_ANTET_NECLAR": {
|
||||||
|
"problema": "Antet de coloane neclar",
|
||||||
|
"fix": (
|
||||||
|
"Asigura-te ca primul rand contine numele coloanelor"
|
||||||
|
" (ex. VIN, Numar, Data)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"IMPORT_ENCODING": {
|
||||||
|
"problema": "Codare de caractere nesuportata",
|
||||||
|
"fix": "Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca.",
|
||||||
|
},
|
||||||
|
"IMPORT_FISIER_NERECUNOSCUT": {
|
||||||
|
"problema": "Fisier nerecunoscut",
|
||||||
|
"fix": "Incarca un fisier .xlsx sau .csv valid.",
|
||||||
|
},
|
||||||
|
"IMPORT_MULTIPLE_SHEETS": {
|
||||||
|
"problema": "Mai multe foi in fisier",
|
||||||
|
"fix": "Pastreaza datele intr-o singura foaie sau alege foaia de import.",
|
||||||
|
},
|
||||||
|
"IMPORT_FARA_MAPARE_COLOANE": {
|
||||||
|
"problema": "Coloanele nu sunt mapate",
|
||||||
|
"fix": (
|
||||||
|
"Mapeaza intai coloanele fisierului la campurile cerute, apoi continua."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"IMPORT_CONFIRMARE_GRESITA": {
|
||||||
|
"problema": "Numar confirmat gresit",
|
||||||
|
"fix": (
|
||||||
|
"Numarul confirmat difera de randurile gata de trimis;"
|
||||||
|
" verifica preview-ul si reconfirma."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"IMPORT_OVERRIDE_ILIZIBIL": {
|
||||||
|
"problema": "Editarea anterioara nu se poate citi",
|
||||||
|
"fix": (
|
||||||
|
"Editarea salvata este ilizibila (probabil cheia s-a schimbat);"
|
||||||
|
" reediteaza randul."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"COLOANE_FORMAT_JSON": {
|
||||||
|
"problema": "Format de coloane (JSON) invalid",
|
||||||
|
"fix": (
|
||||||
|
"Verifica sintaxa JSON a maparii de coloane"
|
||||||
|
" (ghilimele duble, acolade inchise corect)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# eroare()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def eroare(
|
||||||
|
cod: str,
|
||||||
|
*,
|
||||||
|
field: str | None = None,
|
||||||
|
cauza: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Construieste un obiect de eroare pe 3 niveluri din CATALOG.
|
||||||
|
|
||||||
|
Parametri
|
||||||
|
---------
|
||||||
|
cod: Codul de eroare (cheie in CATALOG). Ridica KeyError daca absent.
|
||||||
|
field: Campul care a generat eroarea (optional, pentru context).
|
||||||
|
cauza: Descrierea tehnica a erorii concrete (optional).
|
||||||
|
Daca lipseste, `cauza` si `message` preiau valoarea `problema` din catalog.
|
||||||
|
|
||||||
|
Returneaza
|
||||||
|
----------
|
||||||
|
dict cu exact cheile: field, cod, problema, cauza, fix, message.
|
||||||
|
"""
|
||||||
|
entry = CATALOG[cod] # ridica KeyError daca cod absent
|
||||||
|
problema = entry["problema"]
|
||||||
|
fix = entry["fix"]
|
||||||
|
cauza_efectiva = cauza if cauza is not None else problema
|
||||||
|
message = cauza if cauza is not None else problema
|
||||||
|
return {
|
||||||
|
"field": field,
|
||||||
|
"cod": cod,
|
||||||
|
"problema": problema,
|
||||||
|
"cauza": cauza_efectiva,
|
||||||
|
"fix": fix,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ from typing import Any
|
|||||||
|
|
||||||
from rapidfuzz import fuzz, process
|
from rapidfuzz import fuzz, process
|
||||||
|
|
||||||
|
from . import errors as err_mod
|
||||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||||
from .validation import validate_prezentare
|
from .validation import validate_prezentare
|
||||||
|
|
||||||
@@ -245,7 +246,11 @@ def classify_prezentare(
|
|||||||
|
|
||||||
if unmapped:
|
if unmapped:
|
||||||
status = "needs_mapping"
|
status = "needs_mapping"
|
||||||
rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False)
|
coduri = ", ".join((u.get("cod_op_service") or "") for u in unmapped)
|
||||||
|
rar_error = json.dumps(
|
||||||
|
{"unmapped": unmapped, **err_mod.eroare("COD_NEMAPAT", cauza=f"Coduri fara mapare RAR: {coduri}")},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
errors: list[dict] = []
|
errors: list[dict] = []
|
||||||
else:
|
else:
|
||||||
errors = validate_prezentare(c)
|
errors = validate_prezentare(c)
|
||||||
@@ -254,8 +259,9 @@ def classify_prezentare(
|
|||||||
rar_error = json.dumps(errors, ensure_ascii=False)
|
rar_error = json.dumps(errors, ensure_ascii=False)
|
||||||
elif has_no_auto_send(resolved, mapping_meta):
|
elif has_no_auto_send(resolved, mapping_meta):
|
||||||
status = "needs_mapping"
|
status = "needs_mapping"
|
||||||
|
mesaj = "cod mapat cu auto_send=0; review manual inainte de trimitere"
|
||||||
rar_error = json.dumps(
|
rar_error = json.dumps(
|
||||||
{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
|
{"auto_send": mesaj, **err_mod.eroare("AUTO_SEND_OPRIT", cauza=mesaj)},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import re
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.errors import eroare as _eroare
|
||||||
|
|
||||||
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
|
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
|
||||||
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
||||||
# Numar inmatriculare: max 10, litere + cifre majuscule.
|
# Numar inmatriculare: max 10, litere + cifre majuscule.
|
||||||
@@ -64,36 +66,54 @@ def validate_prezentare(content: dict) -> list[dict]:
|
|||||||
# --- VIN ---
|
# --- VIN ---
|
||||||
vin = _norm(content.get("vin"))
|
vin = _norm(content.get("vin"))
|
||||||
if not VIN_RE.match(vin):
|
if not VIN_RE.match(vin):
|
||||||
errors.append({
|
errors.append(_eroare(
|
||||||
"field": "vin",
|
"VIN_FORMAT",
|
||||||
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
field="vin",
|
||||||
})
|
cauza="VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||||
|
))
|
||||||
|
|
||||||
# --- nrInmatriculare ---
|
# --- nrInmatriculare ---
|
||||||
nrinm = _norm(content.get("nr_inmatriculare"))
|
nrinm = _norm(content.get("nr_inmatriculare"))
|
||||||
if not NRINM_RE.match(nrinm):
|
if not NRINM_RE.match(nrinm):
|
||||||
errors.append({
|
errors.append(_eroare(
|
||||||
"field": "nr_inmatriculare",
|
"NR_INMATRICULARE_FORMAT",
|
||||||
"message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.",
|
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 ---
|
# --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti ---
|
||||||
raw_data = str(content.get("data_prestatie") or "").strip()
|
raw_data = str(content.get("data_prestatie") or "").strip()
|
||||||
try:
|
try:
|
||||||
d = date.fromisoformat(raw_data)
|
d = date.fromisoformat(raw_data)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
errors.append({"field": "data_prestatie", "message": "Format data invalid; foloseste YYYY-MM-DD."})
|
errors.append(_eroare(
|
||||||
|
"DATA_FORMAT",
|
||||||
|
field="data_prestatie",
|
||||||
|
cauza="Format data invalid; foloseste YYYY-MM-DD.",
|
||||||
|
))
|
||||||
d = None
|
d = None
|
||||||
if d is not None:
|
if d is not None:
|
||||||
if d < MIN_DATA_PRESTATIE:
|
if d < MIN_DATA_PRESTATIE:
|
||||||
errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024."})
|
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():
|
elif d > today_bucuresti():
|
||||||
errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi in viitor."})
|
errors.append(_eroare(
|
||||||
|
"DATA_VIITOR",
|
||||||
|
field="data_prestatie",
|
||||||
|
cauza="Data prestatiei nu poate fi in viitor.",
|
||||||
|
))
|
||||||
|
|
||||||
# --- odometruFinal (string numeric) ---
|
# --- odometruFinal (string numeric) ---
|
||||||
odo_final = _parse_int(content.get("odometru_final"))
|
odo_final = _parse_int(content.get("odometru_final"))
|
||||||
if odo_final is None:
|
if odo_final is None:
|
||||||
errors.append({"field": "odometru_final", "message": "odometruFinal trebuie sa fie un numar intreg (ca string)."})
|
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 ---
|
# --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal ---
|
||||||
codes = _codes(content.get("prestatii"))
|
codes = _codes(content.get("prestatii"))
|
||||||
@@ -101,26 +121,43 @@ def validate_prezentare(content: dict) -> list[dict]:
|
|||||||
raw_initial = content.get("odometru_initial")
|
raw_initial = content.get("odometru_initial")
|
||||||
has_initial = str(raw_initial or "").strip() != ""
|
has_initial = str(raw_initial or "").strip() != ""
|
||||||
if needs_initial and not has_initial:
|
if needs_initial and not has_initial:
|
||||||
errors.append({
|
errors.append(_eroare(
|
||||||
"field": "odometru_initial",
|
"ODOMETRU_INITIAL_LIPSA",
|
||||||
"message": "odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
|
field="odometru_initial",
|
||||||
})
|
cauza="odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
|
||||||
|
))
|
||||||
if has_initial:
|
if has_initial:
|
||||||
odo_initial = _parse_int(raw_initial)
|
odo_initial = _parse_int(raw_initial)
|
||||||
if odo_initial is None:
|
if odo_initial is None:
|
||||||
errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie un numar intreg."})
|
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:
|
elif odo_final is not None and odo_initial > odo_final:
|
||||||
errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie <= odometruFinal."})
|
errors.append(_eroare(
|
||||||
|
"ODOMETRU_INITIAL_ORDINE",
|
||||||
|
field="odometru_initial",
|
||||||
|
cauza="odometruInitial trebuie sa fie <= odometruFinal.",
|
||||||
|
))
|
||||||
|
|
||||||
# --- prestatii nevide ---
|
# --- prestatii nevide ---
|
||||||
if not codes:
|
if not codes:
|
||||||
errors.append({"field": "prestatii", "message": "Lista de prestatii nu poate fi goala."})
|
errors.append(_eroare(
|
||||||
|
"PRESTATII_GOALE",
|
||||||
|
field="prestatii",
|
||||||
|
cauza="Lista de prestatii nu poate fi goala.",
|
||||||
|
))
|
||||||
|
|
||||||
# --- b64Image: optional, dar daca e prezent trebuie base64 valid ---
|
# --- b64Image: optional, dar daca e prezent trebuie base64 valid ---
|
||||||
b64 = content.get("b64_image")
|
b64 = content.get("b64_image")
|
||||||
if b64:
|
if b64:
|
||||||
if not _is_valid_base64(str(b64)):
|
if not _is_valid_base64(str(b64)):
|
||||||
errors.append({"field": "b64_image", "message": "b64Image nu este base64 valid."})
|
errors.append(_eroare(
|
||||||
|
"B64_INVALID",
|
||||||
|
field="b64_image",
|
||||||
|
cauza="b64Image nu este base64 valid.",
|
||||||
|
))
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
|||||||
return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa"
|
return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa"
|
||||||
if "auto_send" in data:
|
if "auto_send" in data:
|
||||||
return "Necesita confirmare manuala (auto-send oprit pentru cod)"
|
return "Necesita confirmare manuala (auto-send oprit pentru cod)"
|
||||||
|
if "problema" in data:
|
||||||
|
return str(data.get("problema") or "")[:200]
|
||||||
parti = [f"{k}: {v}" for k, v in data.items()]
|
parti = [f"{k}: {v}" for k, v in data.items()]
|
||||||
return "; ".join(parti)[:200]
|
return "; ".join(parti)[:200]
|
||||||
|
|
||||||
@@ -195,6 +197,102 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
|||||||
return str(data)[:160]
|
return str(data)[:160]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_erori(rar_error: object) -> list[dict]:
|
||||||
|
"""Transforma `rar_error` (JSON stocat) intr-o lista de erori 3-niveluri.
|
||||||
|
|
||||||
|
Fiecare element al listei are cheile: problema, cauza, fix, field (sau None).
|
||||||
|
Functie PURA — nu arunca niciodata exceptii; degradeaza gratios pe orice forma.
|
||||||
|
|
||||||
|
Forme recunoscute:
|
||||||
|
- None / "" / falsy -> lista goala []
|
||||||
|
- array imbogatit (au cod sau problema) -> un element per eroare
|
||||||
|
- dict cu cod specific -> 1 element cu cele 3 niveluri din dict
|
||||||
|
- dict fara cod (forma veche: unmapped / auto_send) -> 1 element cu problema din context
|
||||||
|
- lista cu {field, message} fara cod -> degradare: problema=message, cauza/fix=""
|
||||||
|
- string plain -> 1 element cu problema=text, cauza/fix=""
|
||||||
|
- JSON corupt -> 1 element cu problema=text brut, cauza/fix=""
|
||||||
|
"""
|
||||||
|
if not rar_error:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw = rar_error if isinstance(rar_error, str) else str(rar_error)
|
||||||
|
|
||||||
|
# Incercare parsare JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# String plain sau JSON corupt: degradare gratuoasa
|
||||||
|
return [{"problema": raw[:200], "cauza": "", "fix": "", "field": None}]
|
||||||
|
|
||||||
|
# --- Forma: array de erori ---
|
||||||
|
if isinstance(data, list):
|
||||||
|
rezultat = []
|
||||||
|
for e in data:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
rezultat.append({"problema": str(e)[:200], "cauza": "", "fix": "", "field": None})
|
||||||
|
continue
|
||||||
|
# Eroare imbogatita (are cod sau problema)
|
||||||
|
if e.get("cod") or e.get("problema"):
|
||||||
|
rezultat.append({
|
||||||
|
"problema": e.get("problema") or e.get("cod") or "",
|
||||||
|
"cauza": e.get("cauza") or e.get("message") or "",
|
||||||
|
"fix": e.get("fix") or "",
|
||||||
|
"field": e.get("field"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Forma veche: {field, message} fara cod
|
||||||
|
msg = str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values()))
|
||||||
|
elem = {
|
||||||
|
"problema": msg[:200],
|
||||||
|
"cauza": "",
|
||||||
|
"fix": "",
|
||||||
|
"field": e.get("field"),
|
||||||
|
}
|
||||||
|
# Filtreaza elementele complet goale (problema/cauza/fix toate vide)
|
||||||
|
if not (
|
||||||
|
elem["problema"].strip() == ""
|
||||||
|
and elem["cauza"].strip() == ""
|
||||||
|
and elem["fix"].strip() == ""
|
||||||
|
):
|
||||||
|
rezultat.append(elem)
|
||||||
|
return rezultat
|
||||||
|
|
||||||
|
# --- Forma: dict ---
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Dict imbogatit cu cod explicit
|
||||||
|
if data.get("cod") or data.get("problema"):
|
||||||
|
return [{
|
||||||
|
"problema": data.get("problema") or data.get("cod") or "",
|
||||||
|
"cauza": data.get("cauza") or "",
|
||||||
|
"fix": data.get("fix") or "",
|
||||||
|
"field": data.get("field"),
|
||||||
|
}]
|
||||||
|
# Dict vechi: unmapped
|
||||||
|
if "unmapped" in data:
|
||||||
|
ops = data.get("unmapped") or []
|
||||||
|
coduri = ", ".join(
|
||||||
|
(o.get("cod_op_service") or "") for o in ops if isinstance(o, dict)
|
||||||
|
).strip(", ")
|
||||||
|
problema = f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa"
|
||||||
|
return [{"problema": problema, "cauza": "", "fix": "", "field": None}]
|
||||||
|
# Dict vechi: auto_send
|
||||||
|
if "auto_send" in data:
|
||||||
|
return [{"problema": "Necesita confirmare manuala (auto-send oprit pentru cod)",
|
||||||
|
"cauza": "", "fix": "", "field": None}]
|
||||||
|
# Dict generic necunoscut
|
||||||
|
parti = "; ".join(f"{k}: {v}" for k, v in data.items())
|
||||||
|
if not parti.strip():
|
||||||
|
return []
|
||||||
|
return [{"problema": parti[:200], "cauza": "", "fix": "", "field": None}]
|
||||||
|
|
||||||
|
# Scalar (nr, bool, etc.)
|
||||||
|
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constante auxiliare (microcopy fix, fara logica)
|
# Constante auxiliare (microcopy fix, fara logica)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
|
from .. import errors as _errors
|
||||||
from ..auth import rotate_api_key
|
from ..auth import rotate_api_key
|
||||||
from ..payload_view import prezentare_din_payload
|
from ..payload_view import prezentare_din_payload
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
from ..web.csrf import get_csrf_token, verify_csrf
|
||||||
@@ -33,6 +34,7 @@ from .labels import (
|
|||||||
eticheta_worker,
|
eticheta_worker,
|
||||||
format_data_rar,
|
format_data_rar,
|
||||||
motiv_uman,
|
motiv_uman,
|
||||||
|
parse_erori,
|
||||||
)
|
)
|
||||||
from ..web.session import require_login
|
from ..web.session import require_login
|
||||||
from ..api.v1.import_router import (
|
from ..api.v1.import_router import (
|
||||||
@@ -71,6 +73,8 @@ _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
|||||||
|
|
||||||
router = APIRouter(tags=["web"])
|
router = APIRouter(tags=["web"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||||
|
# Expune parse_erori in toate template-urile (US-006, PRD 5.4)
|
||||||
|
templates.env.globals["parse_erori"] = parse_erori
|
||||||
|
|
||||||
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
||||||
|
|
||||||
@@ -604,6 +608,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|||||||
"rar_status_code": row["rar_status_code"],
|
"rar_status_code": row["rar_status_code"],
|
||||||
"rar_error": row["rar_error"],
|
"rar_error": row["rar_error"],
|
||||||
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
||||||
|
"erori_3n": parse_erori(row["rar_error"]),
|
||||||
"retry_count": row["retry_count"],
|
"retry_count": row["retry_count"],
|
||||||
"created_at": format_data_rar(row["created_at"]),
|
"created_at": format_data_rar(row["created_at"]),
|
||||||
"updated_at": format_data_rar(row["updated_at"]),
|
"updated_at": format_data_rar(row["updated_at"]),
|
||||||
@@ -1279,13 +1284,25 @@ async def web_upload_import(
|
|||||||
except MultipleSheets as ms:
|
except MultipleSheets as ms:
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
||||||
except FileTooLarge as e:
|
except FileTooLarge as e:
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
|
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
|
||||||
|
return templates.TemplateResponse("_upload.html", _ctx(
|
||||||
|
request, error=str(e), eroare_upload=eroare_upload
|
||||||
|
))
|
||||||
except HeaderError as e:
|
except HeaderError as e:
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
|
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
|
||||||
|
return templates.TemplateResponse("_upload.html", _ctx(
|
||||||
|
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload
|
||||||
|
))
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError as e:
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
|
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
||||||
|
return templates.TemplateResponse("_upload.html", _ctx(
|
||||||
|
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload
|
||||||
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
|
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
||||||
|
return templates.TemplateResponse("_upload.html", _ctx(
|
||||||
|
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload
|
||||||
|
))
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -1370,6 +1387,52 @@ async def web_save_mapare_coloane(
|
|||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
|
|
||||||
|
# Detecta body JSON trimis eronat (Content-Type: application/json) → COLOANE_FORMAT_JSON
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if "application/json" in content_type:
|
||||||
|
body = await request.body()
|
||||||
|
try:
|
||||||
|
json.loads(body)
|
||||||
|
# JSON valid dar trimis pe ruta de form — tot e format gresit pentru aceasta ruta
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
# Indiferent daca JSON-ul e valid sau nu, Content-Type application/json e gresit pentru ruta form
|
||||||
|
eroare_mapare = _errors.eroare(
|
||||||
|
"COLOANE_FORMAT_JSON",
|
||||||
|
cauza="Cererea a fost trimisa ca JSON (application/json) in loc de form data.",
|
||||||
|
)
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
first_row = conn.execute(
|
||||||
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
||||||
|
(import_id,),
|
||||||
|
).fetchone()
|
||||||
|
columns: list[str] = []
|
||||||
|
if first_row:
|
||||||
|
try:
|
||||||
|
rd = decrypt_creds(first_row["raw_json"]) or {}
|
||||||
|
columns = list(rd.keys())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
fuzzy: dict[str, list[dict]] = {}
|
||||||
|
for col in columns:
|
||||||
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||||
|
if sugg:
|
||||||
|
fuzzy[col] = sugg
|
||||||
|
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
||||||
|
request,
|
||||||
|
import_id=import_id,
|
||||||
|
columns=columns,
|
||||||
|
sample_rows=[],
|
||||||
|
fuzzy_suggestions=fuzzy,
|
||||||
|
canonical_fields=_CANONICAL_FIELDS,
|
||||||
|
format_data=None,
|
||||||
|
eroare_mapare=eroare_mapare,
|
||||||
|
error=True,
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|
||||||
# Colectare perechi coloana fisier → camp canonic din form
|
# Colectare perechi coloana fisier → camp canonic din form
|
||||||
|
|||||||
36
app/web/templates/_eroare.html
Normal file
36
app/web/templates/_eroare.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{#
|
||||||
|
_eroare.html — macro card_erori(erori) (US-006, PRD 5.4).
|
||||||
|
|
||||||
|
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
|
||||||
|
Afiseaza 3 niveluri intr-un bloc scannabil:
|
||||||
|
- "Problema" (bold, --err)
|
||||||
|
- "De ce" (doar daca ne-gol, --muted)
|
||||||
|
- "Cum repari" (accentuat, --accent)
|
||||||
|
|
||||||
|
Nu hardcodeaza culori — foloseste variabilele CSS din paleta (base.html).
|
||||||
|
Suporta light + dark din box (variabilele se schimba prin [data-theme]).
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro card_erori(erori) %}
|
||||||
|
{% if erori %}
|
||||||
|
<div class="eroare-3n">
|
||||||
|
{% for e in erori %}
|
||||||
|
<div class="eroare-3n-item{% if not loop.first %} eroare-3n-sep{% endif %}">
|
||||||
|
<div class="eroare-3n-problema">
|
||||||
|
{% if e.field %}<span class="eroare-3n-camp">{{ e.field }}</span> {% endif %}{{ e.problema }}
|
||||||
|
</div>
|
||||||
|
{% if e.cauza %}
|
||||||
|
<div class="eroare-3n-cauza">
|
||||||
|
<span class="eroare-3n-label">De ce:</span> {{ e.cauza }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if e.fix %}
|
||||||
|
<div class="eroare-3n-fix">
|
||||||
|
<span class="eroare-3n-label">Cum repari:</span> {{ e.fix }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
<div id="import-section">
|
<div id="import-section">
|
||||||
{% set pas = 2 %}{% include '_stepper.html' %}
|
{% set pas = 2 %}{% include '_stepper.html' %}
|
||||||
|
{% from '_eroare.html' import card_erori %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||||
Mapare coloane —
|
Mapare coloane —
|
||||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{% if message %}
|
{% if eroare_mapare %}
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
{{ card_erori([eroare_mapare]) }}
|
||||||
|
</div>
|
||||||
|
{% elif message %}
|
||||||
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
|
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
|
||||||
{% if error %}role="alert"{% endif %}>
|
{% if error %}role="alert"{% endif %}>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
|
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
|
||||||
{% if editing %}
|
{% if editing %}
|
||||||
{%- set err_map = {} -%}
|
{%- set err_map = {} -%}
|
||||||
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- endif -%}{%- endfor -%}
|
{%- set fix_map = {} -%}
|
||||||
|
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- if e.get('fix') -%}{%- set _ = fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endif -%}{%- endfor -%}
|
||||||
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1">
|
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1">
|
||||||
<td colspan="10" style="background:rgba(91,141,239,.06);">
|
<td colspan="10" style="background:rgba(91,141,239,.06);">
|
||||||
<form class="rand-editare"
|
<form class="rand-editare"
|
||||||
@@ -50,6 +51,9 @@
|
|||||||
{% if err_map.get(nume) %}
|
{% if err_map.get(nume) %}
|
||||||
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
|
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if fix_map.get(nume) %}
|
||||||
|
<span class="camp-fix">{{ fix_map.get(nume) }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
@@ -83,13 +87,23 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{%- set disp_fix_map = {} -%}
|
||||||
|
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%}
|
||||||
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
|
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
|
||||||
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
|
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
|
||||||
<td class="muted">{{ row.row_index + 1 }}</td>
|
<td class="muted">{{ row.row_index + 1 }}</td>
|
||||||
<td>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}</td>
|
<td>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}
|
||||||
<td>{{ res.get('nr_inmatriculare') or '' }}</td>
|
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
|
||||||
<td>{{ res.get('data_prestatie') or '' }}</td>
|
</td>
|
||||||
<td>{{ res.get('odometru_final') or '' }}</td>
|
<td>{{ res.get('nr_inmatriculare') or '' }}
|
||||||
|
{% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ res.get('data_prestatie') or '' }}
|
||||||
|
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ res.get('odometru_final') or '' }}
|
||||||
|
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ op or '<span class="muted">—</span>' | safe }}</td>
|
<td>{{ op or '<span class="muted">—</span>' | safe }}</td>
|
||||||
<td><span class="pill s-{{ status }}">{{ status }}</span></td>
|
<td><span class="pill s-{{ status }}">{{ status }}</span></td>
|
||||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
|
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{% from "_eroare.html" import card_erori %}
|
||||||
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
|
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
|
||||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||||
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
||||||
@@ -27,7 +28,11 @@
|
|||||||
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
|
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if motiv %}
|
{% if erori_3n %}
|
||||||
|
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||||
|
{{ card_erori(erori_3n) }}
|
||||||
|
</div>
|
||||||
|
{% elif motiv %}
|
||||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||||
<div class="muted" style="font-size:12px;">Motiv</div>
|
<div class="muted" style="font-size:12px;">Motiv</div>
|
||||||
<div>{{ motiv }}</div>
|
<div>{{ motiv }}</div>
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||||
{# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul
|
{# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul
|
||||||
de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #}
|
de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #}
|
||||||
|
{% from '_eroare.html' import card_erori %}
|
||||||
<div class="card" style="border-color:var(--accent);">
|
<div class="card" style="border-color:var(--accent);">
|
||||||
|
|
||||||
{% if message %}
|
{% if message %}
|
||||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if error %}
|
{% if eroare_upload %}
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
{{ card_erori([eroare_upload]) }}
|
||||||
|
</div>
|
||||||
|
{% elif error %}
|
||||||
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
|
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
|
||||||
role="alert">{{ error }}</div>
|
role="alert">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
border-color:var(--line); border-bottom-color:var(--card); }
|
border-color:var(--line); border-bottom-color:var(--card); }
|
||||||
.tab-panel { min-height:120px; }
|
.tab-panel { min-height:120px; }
|
||||||
.status-bar { margin-bottom:12px; }
|
.status-bar { margin-bottom:12px; }
|
||||||
|
/* Eroare 3 niveluri (US-006, PRD 5.4) */
|
||||||
|
.eroare-3n { margin-top:10px; }
|
||||||
|
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
|
||||||
|
background:color-mix(in srgb, var(--err) 8%, var(--card));
|
||||||
|
border-radius:0 6px 6px 0; }
|
||||||
|
.eroare-3n-sep { margin-top:6px; }
|
||||||
|
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
|
||||||
|
.eroare-3n-camp { font-family:monospace; font-size:12px; opacity:.85; }
|
||||||
|
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
|
||||||
|
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
|
||||||
|
.eroare-3n-label { font-weight:500; }
|
||||||
|
/* Inline fix per camp in preview */
|
||||||
|
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from .. import errors
|
||||||
from ..config import Settings, get_settings, load_test_credentials
|
from ..config import Settings, get_settings, load_test_credentials
|
||||||
from ..crypto import decrypt_creds
|
from ..crypto import decrypt_creds
|
||||||
from ..db import get_connection, init_db, write_heartbeat
|
from ..db import get_connection, init_db, write_heartbeat
|
||||||
@@ -200,7 +201,14 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
|
|||||||
return "sent"
|
return "sent"
|
||||||
except RarError as exc:
|
except RarError as exc:
|
||||||
if exc.status_code == 400:
|
if exc.status_code == 400:
|
||||||
detail = json.dumps(exc.field_errors, ensure_ascii=False) if exc.field_errors else str(exc)
|
if exc.field_errors:
|
||||||
|
enriched = [
|
||||||
|
errors.eroare("RAR_VALIDARE", field=fe.get("field"), cauza=fe.get("message"))
|
||||||
|
for fe in exc.field_errors
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
enriched = [errors.eroare("RAR_VALIDARE", cauza=str(exc))]
|
||||||
|
detail = json.dumps(enriched, ensure_ascii=False)
|
||||||
mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail)
|
mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail)
|
||||||
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True)
|
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True)
|
||||||
return "needs_data"
|
return "needs_data"
|
||||||
@@ -406,7 +414,8 @@ def run() -> int:
|
|||||||
token = sessions.get_token(conn, account_id, creds)
|
token = sessions.get_token(conn, account_id, creds)
|
||||||
except RarAuthError as exc:
|
except RarAuthError as exc:
|
||||||
# Creds gresite (login 401): NU se face retry (plan, failure registry).
|
# Creds gresite (login 401): NU se face retry (plan, failure registry).
|
||||||
mark(conn, sid, "error", rar_status_code=401, rar_error="credentiale RAR invalide")
|
mark(conn, sid, "error", rar_status_code=401,
|
||||||
|
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
|
||||||
print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True)
|
print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -175,6 +175,127 @@ Aplicate deja pe ambele medii (test + producție):
|
|||||||
→ Acestea devin reguli Pydantic exacte în `app/api`. Validează la gateway înainte de enqueue
|
→ Acestea devin reguli Pydantic exacte în `app/api`. Validează la gateway înainte de enqueue
|
||||||
(stare `needs_data`) ca să nu primești 4xx de la RAR.
|
(stare `needs_data`) ca să nu primești 4xx de la RAR.
|
||||||
|
|
||||||
|
## Envelope de eroare imbogatit (PRD 5.4)
|
||||||
|
|
||||||
|
### Forma unui obiect de eroare
|
||||||
|
|
||||||
|
Incepand cu PRD 5.4, fiecare obiect de eroare returnat de gateway contine **6 chei**:
|
||||||
|
|
||||||
|
| Cheie | Tip | Rol | Back-compat |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `field` | string \| null | Campul care a generat eroarea (null daca eroarea e globala) | DA — existent anterior |
|
||||||
|
| `message` | string | Mesajul scurt (identic cu `cauza` cand e disponibila, altfel `problema`) | DA — existent anterior |
|
||||||
|
| `cod` | string | Identificator stabil de tip eroare (ex. `VIN_FORMAT`). Camp nou. | NU — adaugat 5.4 |
|
||||||
|
| `problema` | string | Ce s-a intamplat — descriere scurta, inteligibila pentru utilizator | NU — adaugat 5.4 |
|
||||||
|
| `cauza` | string | De ce a aparut eroarea — concret; pentru erorile RAR 400, mesajul exact de la RAR (passthrough) | NU — adaugat 5.4 |
|
||||||
|
| `fix` | string | Ce trebuie facut pentru remediere | NU — adaugat 5.4 |
|
||||||
|
|
||||||
|
**Exemplu JSON concret** (eroare VIN invalid, returnat de `POST /v1/prezentari/valideaza`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "vin",
|
||||||
|
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||||
|
"cod": "VIN_FORMAT",
|
||||||
|
"problema": "VIN invalid",
|
||||||
|
"cauza": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||||
|
"fix": "Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nota de back-compat
|
||||||
|
|
||||||
|
Cheile `field` si `message` sunt **pastrate neschimbate** pe toate raspunsurile. Cheile `cod`, `problema`, `cauza`, `fix` sunt **aditive** (camp nou in plus). Clientii care citesc doar `field`/`message` (sau `error`/`message` la import) continua sa functioneze fara modificare.
|
||||||
|
|
||||||
|
### Unde apare envelope-ul imbogatit
|
||||||
|
|
||||||
|
**1. `POST /v1/prezentari/valideaza` (dry-run)**
|
||||||
|
|
||||||
|
Campul `erori` (array) si campul `nemapate` (array) din raspuns contin obiecte cu toate cele 6 chei.
|
||||||
|
|
||||||
|
**2. `submissions.rar_error` (stocat in DB, vizibil prin `GET /v1/prezentari/{id}` si in dashboard)**
|
||||||
|
|
||||||
|
Campul `rar_error` e superset al formei de mai sus si variaza cu starea submission-ului:
|
||||||
|
|
||||||
|
- `needs_data` — array de obiecte `{field, message, cod, problema, cauza, fix}`:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "dataPrestatie",
|
||||||
|
"message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
|
||||||
|
"cod": "RAR_VALIDARE",
|
||||||
|
"problema": "RAR a respins prezentarea",
|
||||||
|
"cauza": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
|
||||||
|
"fix": "Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
- `needs_mapping` (cod nemapat): obiect cu cheile `unmapped` (array), `cod`, `problema`, `cauza`, `fix`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unmapped": ["SCHIMB_ULEI_COMPLET"],
|
||||||
|
"cod": "COD_NEMAPAT",
|
||||||
|
"problema": "Lipseste codul RAR al operatiei",
|
||||||
|
"cauza": "Operatia SCHIMB_ULEI_COMPLET nu are un cod RAR mapat.",
|
||||||
|
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate)."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `needs_mapping` cu `auto_send` oprit: obiect cu `auto_send`, `cod: "AUTO_SEND_OPRIT"`, `problema`, `cauza`, `fix`.
|
||||||
|
- Eroare RAR 400: array imbogatit cu `cod: "RAR_VALIDARE"` pe fiecare element.
|
||||||
|
- Eroare RAR 401 (creds invalide): obiect cu `cod: "RAR_CREDS_INVALIDE"`, `problema`, `cauza`, `fix`.
|
||||||
|
|
||||||
|
**3. Erori de import (`POST /v1/import`, preview, commit)**
|
||||||
|
|
||||||
|
Campul `detail` din raspunsurile de eroare este superset: contine cheile vechi `error`/`message` plus `cod`, `problema`, `cauza`, `fix`.
|
||||||
|
|
||||||
|
**Exceptii din scope 5.4**: erorile de login/signup si CSRF raman mesaje plate (fara envelope imbogatit).
|
||||||
|
|
||||||
|
### Tabel cod → problema / fix (toate codurile din `app/errors.CATALOG`)
|
||||||
|
|
||||||
|
#### Validare date prestatie
|
||||||
|
|
||||||
|
| Cod | Problema | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `VIN_FORMAT` | VIN invalid | Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q. |
|
||||||
|
| `NR_INMATRICULARE_FORMAT` | Numar de inmatriculare invalid | Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii sau cratima (ex. B123ABC). |
|
||||||
|
| `DATA_FORMAT` | Data prestatiei in format gresit | Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22). |
|
||||||
|
| `DATA_PREA_VECHE` | Data prestatiei prea veche | RAR accepta prestatii doar incepand cu 01.12.2024; verifica data prestatiei. |
|
||||||
|
| `DATA_VIITOR` | Data prestatiei in viitor | Data prestatiei nu poate fi dupa ziua de azi; corecteaza data. |
|
||||||
|
| `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | Scrie kilometrajul final ca numar intreg, fara zecimale sau text (ex. 145000). |
|
||||||
|
| `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l. |
|
||||||
|
| `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | Scrie kilometrajul initial ca numar intreg, fara zecimale sau text. |
|
||||||
|
| `ODOMETRU_INITIAL_ORDINE` | Odometru initial mai mare decat finalul | Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final; verifica cele doua valori. |
|
||||||
|
| `PRESTATII_GOALE` | Nicio prestatie | Adauga cel putin o prestatie cu cod RAR valid. |
|
||||||
|
| `B64_INVALID` | Imaginea nu este base64 valid | Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine. |
|
||||||
|
|
||||||
|
#### Mapare operatie
|
||||||
|
|
||||||
|
| Cod | Problema | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `COD_NEMAPAT` | Lipseste codul RAR al operatiei | Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate). |
|
||||||
|
| `AUTO_SEND_OPRIT` | Necesita confirmare manuala | Codul e mapat cu trimitere automata oprita; verifica randul si pune-l manual in coada. |
|
||||||
|
|
||||||
|
#### Erori RAR (raspuns live de la RAR)
|
||||||
|
|
||||||
|
| Cod | Problema | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `RAR_VALIDARE` | RAR a respins prezentarea | Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR. |
|
||||||
|
| `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | Verifica email-ul si parola contului RAR in tab-ul Cont; trimiterea nu se reincearca automat la credentiale gresite. |
|
||||||
|
|
||||||
|
#### Import fisier
|
||||||
|
|
||||||
|
| Cod | Problema | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand. |
|
||||||
|
| `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | Asigura-te ca primul rand contine numele coloanelor (ex. VIN, Numar, Data). |
|
||||||
|
| `IMPORT_ENCODING` | Codare de caractere nesuportata | Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca. |
|
||||||
|
| `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | Incarca un fisier .xlsx sau .csv valid. |
|
||||||
|
| `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | Pastreaza datele intr-o singura foaie sau alege foaia de import. |
|
||||||
|
| `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | Mapeaza intai coloanele fisierului la campurile cerute, apoi continua. |
|
||||||
|
| `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | Numarul confirmat difera de randurile gata de trimis; verifica preview-ul si reconfirma. |
|
||||||
|
| `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | Editarea salvata este ilizibila (probabil cheia s-a schimbat); reediteaza randul. |
|
||||||
|
| `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect). |
|
||||||
|
|
||||||
## Nomenclator prestații (18 coduri, verificat live 2026-06-15)
|
## Nomenclator prestații (18 coduri, verificat live 2026-06-15)
|
||||||
|
|
||||||
| cod | nume |
|
| cod | nume |
|
||||||
|
|||||||
395
docs/prd/prd-5.4-erori-3-niveluri.md
Normal file
395
docs/prd/prd-5.4-erori-3-niveluri.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# PRD 5.4 — Erori pe 3 niveluri (problema + cauza + fix) pe API si UI
|
||||||
|
|
||||||
|
**Stare**: inchis
|
||||||
|
|
||||||
|
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||||
|
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||||
|
|
||||||
|
## 1. Obiectiv
|
||||||
|
|
||||||
|
Fiecare eroare pe care o vede un integrator (canal API) sau un service-auto (UI web) sa raspunda la
|
||||||
|
trei intrebari, in loc de una singura:
|
||||||
|
|
||||||
|
1. **Problema** — ce s-a intamplat (categorie umana, scurta). *"VIN invalid."*
|
||||||
|
2. **Cauza** — de ce, specific. *"VIN-ul are 16 caractere; RAR cere exact 17."*
|
||||||
|
3. **Fix** — ce sa faci acum. *"Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule, fara O/I/Q."*
|
||||||
|
|
||||||
|
Motivul (lentila DX, Etapa 5): erorile plate ("Fisier nerecunoscut", "cheie API invalida", "VIN invalid")
|
||||||
|
**transfera incertitudinea catre utilizator** — care fie ghiceste, fie deschide un tichet de suport. Cele
|
||||||
|
trei niveluri inchid bucla la sursa: mai putine tichete, integrare self-service.
|
||||||
|
|
||||||
|
**Invariant de corectitudine (motivul cheie de design):** cele trei niveluri pentru un anumit cod de
|
||||||
|
eroare se definesc **o singura data**, intr-un **catalog central pur** (`app/errors.py`), consumat de
|
||||||
|
**toate** suprafetele (API + UI + worker). Daca textul s-ar duplica pe canale, API si UI ar putea
|
||||||
|
diverge — un cod ar spune un lucru in JSON si altul in dashboard. Catalogul unic face imposibila
|
||||||
|
divergenta (acelasi pattern care a facut 5.2 corect: o singura sursa partajata, nu doua copii).
|
||||||
|
|
||||||
|
## 2. Non-Goals (anti scope-creep)
|
||||||
|
|
||||||
|
- **NU acoperim login / signup / CSRF / auth 401** (decizie utilizator 2026-06-22: focus pe fluxul de
|
||||||
|
declarare). Aceste suprafete sunt edge / dev si raman mesaje plate. `auth_routes.py`, `csrf.py`,
|
||||||
|
handler-ele `LoginRequired`/`AdminRequired`/`CsrfError` din `main.py` — NEATINSE.
|
||||||
|
- **NU breaking change pe API** (decizie utilizator 2026-06-22: aditiv). Pastram campurile existente
|
||||||
|
(`field`, `message`, `error`, `type`/`loc`/`msg`) si **ADAUGAM** `cod`, `problema`, `cauza`, `fix`.
|
||||||
|
Clientii vechi (ROAAUTO / soft propriu integrat la 5.1/5.2) nu se strica; cei noi pot afisa 3 niveluri.
|
||||||
|
- **NU schema noua** — `submissions.rar_error` e deja TEXT si stocheaza JSON; doar imbogatim continutul.
|
||||||
|
Zero migrare.
|
||||||
|
- **NU apel live nou la RAR** — pentru erorile RAR 400 imbracam mesajul RAR existent (passthrough ca
|
||||||
|
`cauza`) intr-un invelis 3-niveluri; nu schimbam clasificarea transient/terminal a worker-ului.
|
||||||
|
- **NU schimbam regulile de validare** — `validate_prezentare` valideaza exact aceleasi conditii;
|
||||||
|
doar ataseaza `cod` + nivelele la fiecare eroare existenta.
|
||||||
|
- **NU schimbam masina de stari / idempotenta / mapping-rezolvarea / nomenclatorul.**
|
||||||
|
- **NU traducere i18n** — romana, ca tot proiectul.
|
||||||
|
|
||||||
|
## 3. Stories atomice
|
||||||
|
|
||||||
|
### US-001: Catalog central de erori (`app/errors.py`) — DONE (588 teste)
|
||||||
|
**Ca** dezvoltator al gateway-ului **vreau** o singura sursa de adevar care mapeaza fiecare cod de
|
||||||
|
eroare la (problema, fix), cu un helper care construieste obiectul de eroare 3-niveluri, **pentru ca**
|
||||||
|
API-ul, UI-ul si worker-ul sa nu poata diverge in ce explica utilizatorului.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/errors.py` (modul pur nou), `tests/test_errors.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_errors.py` —
|
||||||
|
- `test_catalog_complet` — fiecare intrare are `problema` + `fix` ne-goale (string).
|
||||||
|
- `test_eroare_construieste_3niveluri` — `eroare("VIN_FORMAT", field="vin", cauza="...")` intoarce
|
||||||
|
dict cu cheile `{field, cod, problema, cauza, fix, message}`, `cod=="VIN_FORMAT"`, `problema`/`fix`
|
||||||
|
luate din catalog, `cauza` cea data.
|
||||||
|
- `test_message_back_compat` — cand `cauza` e dat, `message == cauza` (alias pentru clientii vechi).
|
||||||
|
- `test_cod_necunoscut_ridica` — `eroare("INEXISTENT")` ridica `KeyError`/`ValueError` (nu inventeaza
|
||||||
|
text gol — drift prins la dezvoltare).
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `app/errors.py` pur (fara import DB/HTTP), expune `CATALOG: dict[str, dict]` (cod → {problema, fix})
|
||||||
|
si `eroare(cod, *, field=None, cauza=None) -> dict`.
|
||||||
|
- [ ] Obiectul de eroare are exact cheile `{field, cod, problema, cauza, fix, message}`; `message`
|
||||||
|
(back-compat) `== cauza` cand `cauza` e dat, altfel `== problema`.
|
||||||
|
- [ ] CATALOG contine codurile pentru toate suprafetele in scop (validare continut, mapare op→cod,
|
||||||
|
RAR, import) — vezi lista din §4. Fiecare intrare are `problema` + `fix` ne-goale.
|
||||||
|
- [ ] **`fix` e specific si actionabil** (finding CEO): numeste un loc/o actiune concreta (ex. "talon,
|
||||||
|
pozitia E", "tab-ul Mapari", "salveaza ca .csv UTF-8"), NU boilerplate generic ("verifica datele").
|
||||||
|
Un fix generic = eroare plata mai lunga; testul de calitate al livrabilei. (Verificat la review uman.)
|
||||||
|
- [ ] `eroare` cu cod absent din CATALOG ridica eroare (nu intoarce text gol).
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: unitar (modul pur) — fara canal.
|
||||||
|
|
||||||
|
### US-002: Validarea de continut emite 3 niveluri (`validation.py`) — DONE
|
||||||
|
**Ca** integrator API **vreau** ca fiecare eroare de validare (VIN/nr/data/odometru/prestatii/b64) sa
|
||||||
|
spuna problema + cauza + fix, **pentru ca** sa corectez payload-ul fara sa ghicesc formatul cerut.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/validation.py`, `tests/test_validation.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_validation.py` —
|
||||||
|
- `test_vin_invalid_are_3niveluri` — VIN cu O/I/Q → eroare cu `cod=="VIN_FORMAT"`, `problema`,
|
||||||
|
`fix` ne-goale, `field=="vin"`, `message` pastrat (back-compat).
|
||||||
|
- `test_data_prea_veche_cod` / `test_data_viitor_cod` / `test_data_format_cod` — coduri distincte.
|
||||||
|
- `test_odometru_initial_lipsa_cod` / `test_odometru_ordine_cod` — coduri distincte.
|
||||||
|
- `test_prestatii_goale_cod`, `test_b64_invalid_cod`.
|
||||||
|
- `test_back_compat_field_message` — fiecare eroare are inca `field` + `message` (forma veche
|
||||||
|
pastrata pentru clientii existenti).
|
||||||
|
- `test_toate_codurile_in_catalog` — fiecare `cod` emis de `validate_prezentare` exista in `CATALOG`.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `validate_prezentare` intoarce erori cu `{field, message, cod, problema, cauza, fix}` (aditiv —
|
||||||
|
`field` + `message` neschimbate la octet fata de azi).
|
||||||
|
- [ ] Fiecare regula are un `cod` stabil (vezi lista §4); textul (problema/fix) vine din `errors.eroare`.
|
||||||
|
- [ ] **Byte-compat** (finding Eng): `cauza` = mesajul existent verbatim (eventual + context specific
|
||||||
|
precum lungimea VIN gasita); `message` ramane EXACT string-ul de azi → testele existente care
|
||||||
|
compara `message` raman verzi fara modificari.
|
||||||
|
- [ ] Toate testele existente (`test_validation.py`, `test_api.py`, `test_validare_dryrun.py`) raman
|
||||||
|
verzi (forma veche `field`/`message` intacta).
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: canal API — `POST /v1/prezentari/valideaza` cu VIN invalid → `erori[0]` are cele
|
||||||
|
3 niveluri + `field`/`message` vechi.
|
||||||
|
|
||||||
|
### US-003: Propagare 3 niveluri prin mapare + raspuns API (`mapping.py`, `router.py`) — DONE
|
||||||
|
**Ca** integrator API **vreau** ca raspunsul `/valideaza` si motivul stocat (`rar_error`) sa transporte
|
||||||
|
cele 3 niveluri pentru validare SI pentru coduri nemapate / auto-send oprit, **pentru ca** verdictul sa
|
||||||
|
fie la fel de explicit indiferent de ramura (needs_data / needs_mapping).
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-002
|
||||||
|
- **Fisiere**: `app/mapping.py`, `app/api/v1/router.py`, `app/models.py`, `tests/test_mapping.py`,
|
||||||
|
`tests/test_validare_dryrun.py` (~5 fisiere)
|
||||||
|
- **Test intai (RED)**:
|
||||||
|
- `test_mapping.py::test_unmapped_are_3niveluri` — cod_op_service necunoscut → `classify_prezentare`
|
||||||
|
produce `needs_mapping` cu `cod=="COD_NEMAPAT"` + problema/cauza (codul concret)/fix in structura.
|
||||||
|
- `test_mapping.py::test_auto_send_oprit_3niveluri` — mapare cu `auto_send=0` → `cod=="AUTO_SEND_OPRIT"`
|
||||||
|
+ 3 niveluri.
|
||||||
|
- `test_mapping.py::test_needs_data_pass_through` — erorile de validare imbogatite trec neatinse prin
|
||||||
|
`classify_prezentare` in `rar_error`.
|
||||||
|
- `test_validare_dryrun.py::test_erori_au_3niveluri` — `/valideaza` cu VIN invalid → `erori[i]` are
|
||||||
|
`cod/problema/cauza/fix`; cu cod_op nemapat → `nemapate` carry 3 niveluri.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `classify_prezentare` pastreaza erorile de validare imbogatite (pass-through) in `rar_error`.
|
||||||
|
- [ ] Ramura `needs_mapping` (cod nemapat) si nota `auto_send=0` se construiesc prin `errors.eroare`
|
||||||
|
(3 niveluri), nu string-uri ad-hoc.
|
||||||
|
- [ ] **`rar_error` stocat = SUPERSET al formei de azi** (finding Eng critic): pastreaza structura
|
||||||
|
veche (`needs_data` → array `[{field,message,...}]`; `needs_mapping` → `{unmapped:[...], ...}`),
|
||||||
|
ADAUGA cheile 3-niveluri. Asa `labels.motiv_uman` actual ramane functional intre Val 3 si Val 4
|
||||||
|
(nu se strica pana e actualizat in US-006) si nu e nevoie de migrare. Aplica principiul aditiv si
|
||||||
|
la datele stocate, nu doar la API.
|
||||||
|
- [ ] Raspunsul `/valideaza` (`erori`, `nemapate`) include `cod/problema/cauza/fix` aditiv; modelele
|
||||||
|
din `models.py` accepta cheile noi fara a respinge (verifica: tipul `erori`/`nemapate` e permisiv
|
||||||
|
sau extins; nu schema stricta care respinge chei in plus).
|
||||||
|
- [ ] **Teste subset, nu egalitate exacta** (finding Eng): testele existente care comparau `==` un dict
|
||||||
|
de eroare se actualizeaza la asertii de subset (comportament identic, doar chei aditive).
|
||||||
|
- [ ] `POST /v1/prezentari` (calea reala) ramane cu comportament identic — `test_api.py` verde;
|
||||||
|
`rar_error` stocat e JSON 3-niveluri pentru needs_data/needs_mapping.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: canal API — `/valideaza` cu (a) VIN invalid → needs_data + 3 niveluri, (b) cod_op
|
||||||
|
nemapat → needs_mapping + 3 niveluri. Regresia de aur: `POST /v1/prezentari` enqueue neschimbat.
|
||||||
|
|
||||||
|
### US-004: Erorile RAR (400/401) imbracate pe 3 niveluri in worker (`worker`, `rar_client.py`) — DONE
|
||||||
|
**Ca** service-auto **vreau** ca o respingere de la RAR sa fie tradusa in problema + cauza (mesajul RAR
|
||||||
|
exact) + fix, in loc de un JSON brut, **pentru ca** sa inteleg ce a respins RAR fara sa citesc JSON.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/worker/__main__.py`, `app/rar_client.py` (eventual), `tests/test_worker_*.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_worker_rar_errors.py` (nou) —
|
||||||
|
- `test_rar_400_stocheaza_3niveluri` — `RarError(status=400, field_errors=[{field,message}])` →
|
||||||
|
worker stocheaza `rar_error` JSON cu `cod=="RAR_VALIDARE"`, `problema`, `cauza` continand mesajul
|
||||||
|
RAR exact (passthrough), `fix` cu indrumare; pastreaza si `field_errors` originale.
|
||||||
|
- `test_rar_401_creds_3niveluri` — `RarAuthError` → `cod=="RAR_CREDS_INVALIDE"` + 3 niveluri, stare
|
||||||
|
`error` (fara retry, neschimbat).
|
||||||
|
- `test_clasificare_transient_neschimbata` — 5xx/timeout raman transient (retry), comportament identic.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] La RAR 400, `rar_error` stocat = SUPERSET (finding Eng): pastreaza array-ul `field_errors`
|
||||||
|
original `[{field,message}]` (ca `labels.py` actual sa-l randeze per-camp) + ADAUGA invelisul
|
||||||
|
3-niveluri (`cod=RAR_VALIDARE`, `cauza`=mesajul RAR exact passthrough, `fix`=indrumare).
|
||||||
|
- [ ] La RAR 401, `rar_error` = 3-niveluri (`cod=RAR_CREDS_INVALIDE`), stare `error` (fara retry).
|
||||||
|
- [ ] Clasificarea transient vs terminal NESCHIMBATA (5xx/408/429 retry; 4xx terminal); reconcilierea
|
||||||
|
anti-duplicat neatinsa.
|
||||||
|
- [ ] Fara echo de creds in `rar_error` (mesajul RAR nu contine parola; verificat in test).
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: worker pe RAR test (daca exista creds) — prezentare cu VIN invalid → RAR 400 →
|
||||||
|
`needs_data` cu `rar_error` 3-niveluri vizibil in dashboard. (Live optional — vezi riscuri.)
|
||||||
|
|
||||||
|
### US-005: Erorile de import imbracate pe 3 niveluri (`import_router.py`) — DONE
|
||||||
|
**Ca** service-auto care incarca un fisier **vreau** ca erorile de upload / mapare coloane / commit sa
|
||||||
|
spuna ce e gresit, de ce si cum repar, **pentru ca** sa pot incarca singur fara suport.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/api/v1/import_router.py`, `tests/test_import_*.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_import_errors.py` (nou) —
|
||||||
|
- `test_fisier_prea_mare_3niveluri` — 413 → detail contine `cod=="IMPORT_FISIER_PREA_MARE"` +
|
||||||
|
problema/cauza (nr randuri vs max)/fix; pastreaza `error`/`message` vechi.
|
||||||
|
- `test_antet_neclar_3niveluri` — HeaderError → `cod=="IMPORT_ANTET_NECLAR"` + 3 niveluri + `found`.
|
||||||
|
- `test_encoding_3niveluri`, `test_fisier_nerecunoscut_3niveluri`, `test_multiple_sheets_3niveluri`.
|
||||||
|
- `test_fara_mapare_coloane_3niveluri` — preview fara mapare → `IMPORT_FARA_MAPARE_COLOANE`.
|
||||||
|
- `test_confirmare_gresita_3niveluri` — commit cu n gresit → `IMPORT_CONFIRMARE_GRESITA` + `n_ok`.
|
||||||
|
- `test_override_ilizibil_3niveluri` — editare cu override corupt → `IMPORT_OVERRIDE_ILIZIBIL`.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Fiecare `HTTPException` de import in scop are `detail` SUPERSET: pastreaza `error`/`message`/
|
||||||
|
campurile contextuale existente (`sheets`/`found`/`n_ok`) si ADAUGA `cod/problema/cauza/fix`.
|
||||||
|
- [ ] Codurile vin din `errors.eroare` (catalog), nu string-uri ad-hoc.
|
||||||
|
- [ ] Toate testele de import existente raman verzi (forma veche `error`/`message` intacta; asertii de
|
||||||
|
subset unde comparau exact — finding Eng).
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: canal API — `POST /v1/import` cu fisier prea mare / antet neclar → detail 3-niveluri.
|
||||||
|
|
||||||
|
### US-006: Componenta UI de eroare pe 3 niveluri + stari submission (`labels.py`, templates core) — DONE
|
||||||
|
**Ca** service-auto **vreau** ca dashboard-ul sa afiseze problema (bold) + cauza + fix (linie de actiune)
|
||||||
|
pentru randurile needs_data / needs_mapping / error si per-camp in preview, **pentru ca** sa stiu ce sa
|
||||||
|
fac fara sa deschid un tichet.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-002, US-003, US-004 (codurile/structura trebuie sa existe)
|
||||||
|
- **Fisiere**: `app/web/labels.py`, `app/web/templates/_eroare.html` (macro nou), `app/web/templates/base.html`
|
||||||
|
(CSS), `app/web/templates/_trimitere_detaliu.html`, `app/web/templates/_status.html`,
|
||||||
|
`app/web/templates/_preview_rand.html`, `tests/test_web_*.py` (~7 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_erori.py` (nou) —
|
||||||
|
- `test_motiv_uman_3niveluri` — `labels.motiv_uman` pe `rar_error` 3-niveluri intoarce
|
||||||
|
problema/cauza/fix (nu doar un string plat); fallback gratios la rar_error vechi/string/corupt.
|
||||||
|
- `test_detaliu_afiseaza_fix` — `/_fragments/...` pe submission needs_data → HTML contine textul `fix`.
|
||||||
|
- `test_preview_rand_per_camp_fix` — preview rand needs_data → fiecare camp invalid arata `fix`-ul.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] **Progresiv, dashboard compact pastrat** (finding Design — nu regresa 3.5/3.6): in lista/rand
|
||||||
|
se vede problema (eticheta umana existenta) + fix-ul ca o singura linie de actiune; cauza +
|
||||||
|
mesajul tehnic RAR integral stau in detaliu / `<details>` (nu 3 linii per rand → zid de text).
|
||||||
|
Cele 3 niveluri complete apar in panoul de detaliu si in preview-ul de rand, nu in fiecare rand din lista.
|
||||||
|
- [ ] **Scannabil** (finding Design): nivelele au tratament vizual / etichete care le fac parcurgibile
|
||||||
|
fara citire integrala (ex. "Problema" / "De ce" / "Cum repari", sau ierarhie vizuala clara).
|
||||||
|
- [ ] Macro Jinja `_eroare.html` randeaza consistent; mesajul tehnic RAR integral ramane in `<details>`
|
||||||
|
(pattern existent in `_trimitere_detaliu.html`).
|
||||||
|
- [ ] `labels.py` citeste catalogul / parseaza `rar_error` 3-niveluri; degradeaza gratios pe forma
|
||||||
|
veche (string / `[{field,message}]` / JSON corupt) — fara 500 (lectia 3.6 cu decriptarea).
|
||||||
|
- [ ] Reutilizeaza, NU inlocuieste, pattern-ul bun din `_status.html` (problema + subtext-hint deja
|
||||||
|
~ problema + fix); `_trimitere_detaliu.html`, `_preview_rand.html` folosesc macro-ul.
|
||||||
|
- [ ] CSS in paleta light+dark din 5.3 — fara culori hardcodate; accentul de "fix/actiune" trece AA
|
||||||
|
in AMBELE teme (lectia 5.3: `--ok` pica AA ca text); distinct de rosul de eroare.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser HTMX pe `/` — rand needs_data arata problema+cauza+fix; preview rand
|
||||||
|
invalid arata fix per-camp; dark + light (5.3) ambele lizibile.
|
||||||
|
|
||||||
|
### US-007: 3 niveluri in import / upload / preview UI + rute web (`routes.py`, templates import) — DONE
|
||||||
|
**Ca** service-auto **vreau** ca erorile de la upload, mapare coloane si preview import sa apara cu cele
|
||||||
|
3 niveluri in interfata, **pentru ca** sa rezolv singur problemele de fisier.
|
||||||
|
|
||||||
|
- **Depinde de**: US-006 (macro `_eroare.html`), US-005 (codurile de import)
|
||||||
|
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_upload.html`, `app/web/templates/_mapcoloane.html`,
|
||||||
|
`app/web/templates/_preview_import.html`, `tests/test_web_*.py` (~5 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_import_erori.py` (nou) —
|
||||||
|
- `test_upload_eroare_3niveluri` — upload fisier invalid prin ruta web → fragment contine problema+fix.
|
||||||
|
- `test_mapcoloane_format_json_3niveluri` — format coloane JSON invalid → `COLOANE_FORMAT_JSON` 3 niveluri.
|
||||||
|
- `test_cod_rar_necunoscut_3niveluri` — mapare operatie cu cod RAR inexistent → 3 niveluri + sugestie.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Caile web de eroare din `routes.py` (upload, mapare coloane, format JSON, cod RAR necunoscut,
|
||||||
|
corectie) trec context 3-niveluri catre template (din catalog), nu string plat.
|
||||||
|
- [ ] `_upload.html`, `_mapcoloane.html`, `_preview_import.html` folosesc macro-ul `_eroare.html`.
|
||||||
|
- [ ] Forma veche (mesaj plat) inca functioneaza unde nu exista cod (fara regresie); toate testele web
|
||||||
|
existente verzi.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser HTMX — upload fisier prea mare → 3 niveluri; mapare coloane JSON invalid → fix.
|
||||||
|
|
||||||
|
### US-008: Documentare envelope de eroare imbogatit (`api-rar-contract.md`) — DONE
|
||||||
|
**Ca** integrator nou **vreau** sa stiu forma exacta a erorilor (campuri vechi + cele 3 niveluri noi) si
|
||||||
|
lista de coduri, **pentru ca** sa-mi construiesc gestionarea de erori fara reverse-engineering.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-002, US-003, US-005 (codurile finale)
|
||||||
|
- **Fisiere**: `docs/api-rar-contract.md` (~1 fisier)
|
||||||
|
- **Test intai (RED)**: — (doc; verificare manuala). Optional `tests/test_errors.py::test_doc_acopera_codurile`
|
||||||
|
daca e fezabil ieftin (verifica ca fiecare cod din CATALOG apare in doc).
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Sectiune noua in `api-rar-contract.md`: forma erorii (`{field, message, cod, problema, cauza, fix}`),
|
||||||
|
nota de back-compat (campurile vechi raman), tabel cod → problema/fix.
|
||||||
|
- [ ] Mentioneaza ca `/valideaza` si `rar_error` stocat folosesc aceeasi forma.
|
||||||
|
- **Verificare E2E**: review uman al documentului.
|
||||||
|
|
||||||
|
## 4. Catalog de coduri (referinta — definit in US-001)
|
||||||
|
|
||||||
|
| Domeniu | Cod | problema (nivel 1) | unde |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Validare | `VIN_FORMAT` | VIN invalid | US-002 |
|
||||||
|
| Validare | `NR_INMATRICULARE_FORMAT` | Numar de inmatriculare invalid | US-002 |
|
||||||
|
| Validare | `DATA_FORMAT` | Data prestatiei in format gresit | US-002 |
|
||||||
|
| Validare | `DATA_PREA_VECHE` | Data prestatiei prea veche | US-002 |
|
||||||
|
| Validare | `DATA_VIITOR` | Data prestatiei in viitor | US-002 |
|
||||||
|
| Validare | `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | US-002 |
|
||||||
|
| Validare | `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | US-002 |
|
||||||
|
| Validare | `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | US-002 |
|
||||||
|
| Validare | `ODOMETRU_INITIAL_ORDINE` | Odometru initial > final | US-002 |
|
||||||
|
| Validare | `PRESTATII_GOALE` | Nicio prestatie | US-002 |
|
||||||
|
| Validare | `B64_INVALID` | Imaginea nu e base64 valid | US-002 |
|
||||||
|
| Mapare | `COD_NEMAPAT` | Lipseste codul RAR al operatiei | US-003 |
|
||||||
|
| Mapare | `AUTO_SEND_OPRIT` | Necesita confirmare manuala | US-003 |
|
||||||
|
| RAR | `RAR_VALIDARE` | RAR a respins prezentarea | US-004 |
|
||||||
|
| RAR | `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | US-004 |
|
||||||
|
| Import | `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | US-005 |
|
||||||
|
| Import | `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | US-005 |
|
||||||
|
| Import | `IMPORT_ENCODING` | Codare de caractere nesuportata | US-005 |
|
||||||
|
| Import | `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | US-005 |
|
||||||
|
| Import | `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | US-005 |
|
||||||
|
| Import | `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | US-005 |
|
||||||
|
| Import | `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | US-005 |
|
||||||
|
| Import | `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | US-005 |
|
||||||
|
| Coloane | `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | US-007 |
|
||||||
|
|
||||||
|
> Lista finala se fixeaza in US-001 (catalog) + drift-test (`test_toate_codurile_in_catalog`). Codurile
|
||||||
|
> nu sunt parte din contractul de back-compat (campuri noi); mesajele RAR exacte raman in `cauza`.
|
||||||
|
|
||||||
|
## 4b. Intrebari deschise (rezolvate inainte de executie)
|
||||||
|
|
||||||
|
- **Latimea scope-ului** — REZOLVAT: focus pe fluxul de declarare (validare continut, RAR 400, import,
|
||||||
|
mapare op→cod); login/signup/CSRF/auth raman plate. [user 2026-06-22]
|
||||||
|
- **Compatibilitate API** — REZOLVAT: aditiv, fara breaking change (campuri vechi pastrate, adaugam
|
||||||
|
`cod/problema/cauza/fix`); documentat in `api-rar-contract.md`. [user 2026-06-22]
|
||||||
|
|
||||||
|
## 5. Riscuri
|
||||||
|
|
||||||
|
- **Drift catalog** (cod folosit dar absent din CATALOG, sau text gol) → `errors.eroare` ridica pe cod
|
||||||
|
necunoscut (US-001) + `test_toate_codurile_in_catalog` (US-002) — drift prins la dezvoltare, nu in prod.
|
||||||
|
- **`fix` generic, fara valoare** (finding CEO) → criteriu de calitate explicit (US-001): fiecare `fix`
|
||||||
|
numeste loc/actiune concreta; verificat la review uman + in design-review-ul UI.
|
||||||
|
- **`rar_error` stocat schimbat rupe `labels.py` intre valuri** (finding Eng) → stocam SUPERSET (old keys
|
||||||
|
intacte) in US-003/US-004; `labels.py` actual ramane functional pana la US-006; zero migrare.
|
||||||
|
- **Teste cu egalitate exacta de dict** (finding Eng) → se trec la asertii de subset (acelasi comportament,
|
||||||
|
chei aditive); contractul de back-compat e pe campurile vechi, nu pe absenta celor noi.
|
||||||
|
- **Breaking change accidental pe API** → aditiv prin constructie + testele existente (`test_api.py`,
|
||||||
|
`test_validare_dryrun.py`, `test_import_*.py`) sunt contractul de back-compat: raman verzi = forma
|
||||||
|
veche `field`/`message`/`error` intacta. AC explicit in fiecare story backend.
|
||||||
|
- **500 la afisare UI pe `rar_error` vechi/corupt** (lectia 3.6: decriptare neprotejata) → `labels.py`
|
||||||
|
degradeaza gratios pe forma veche (string / `[{field,message}]` / JSON corupt), test dedicat (US-006).
|
||||||
|
- **Scurgere de creds prin `cauza`** (mesaj RAR passthrough) → mesajele RAR de validare nu contin parola
|
||||||
|
(field/message pe campuri de prezentare); test no-echo (US-004). Handler-ul 422 din `main.py` deja
|
||||||
|
dropeaza `input`/`ctx`.
|
||||||
|
- **Suprafata mare** → 8 stories pe valuri cu fisiere disjuncte (vezi §6); backend (US-002/004/005)
|
||||||
|
paralel, UI secvential dupa backend, docs paralel.
|
||||||
|
- **Verbozitate UI** (3 niveluri = zgomot) → progresiv: problema + fix vizibile, mesajul tehnic RAR
|
||||||
|
integral ramane in `<details>` (pattern existent in `_trimitere_detaliu.html`).
|
||||||
|
- **Conflict pe fisiere comune** (lectia 5.1: clobber la worktree/merge) → mapping.py atins doar de
|
||||||
|
US-003; routes.py doar de US-007; templates partitionate intre US-006 (detaliu/status/preview_rand) si
|
||||||
|
US-007 (upload/mapcoloane/preview_import); macro `_eroare.html` creat in US-006, consumat in US-007
|
||||||
|
(dependenta de val, nu paralel).
|
||||||
|
|
||||||
|
## 6. Valuri de executie (graful de dependente)
|
||||||
|
|
||||||
|
```
|
||||||
|
Val 1: [US-001] backbone catalog (singur — toti depind de el)
|
||||||
|
Val 2: [US-002] [US-004] [US-005] backend paralel, fisiere disjuncte
|
||||||
|
validation.py worker/rar import_router.py
|
||||||
|
Val 3: [US-003] mapping.py + router.py + models.py (depinde US-002)
|
||||||
|
Val 4: [US-006] [US-008] UI core (labels+templates) || docs (api-rar-contract.md) — disjuncte
|
||||||
|
Val 5: [US-007] UI import/web (depinde US-006 pt macro)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Val 2**: max 2-3 teammates simultan (ROADMAP §5.5). validation.py / worker+rar_client / import_router.py
|
||||||
|
sunt disjuncte → 3 teammates paraleli OK.
|
||||||
|
- **Val 4**: US-006 (templates+labels) si US-008 (doc) ating fisiere disjuncte → paralel.
|
||||||
|
- Dupa fiecare val: lead-ul ruleaza `python3 -m pytest -q` (regresie) si bifeaza stories in PRD.
|
||||||
|
|
||||||
|
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
|
||||||
|
|
||||||
|
> Obligatorii: `/plan-ceo-review` (valoare/scope) + `/plan-eng-review` (fezabilitate/teste).
|
||||||
|
> `/plan-design-review` — DA (atinge UI: US-006, US-007). Rezultatele se aplica IN acest PRD inainte de cod.
|
||||||
|
|
||||||
|
**CEO (valoare/scope) — PASS.** Problema corecta (DX: erorile plate transfera incertitudinea la user →
|
||||||
|
tichete de suport), aliniata cu directia Etapa 5. Scope-ul (declaration flow) e cel mai direct la valoare;
|
||||||
|
login/signup/CSRF taiate corect (decizie user). **Inversiune ("ce-l face sa esueze?"):** un `fix` generic
|
||||||
|
("verifica datele") face dintr-o eroare 3-niveluri doar o eroare plata mai lunga — valoarea traieste sau
|
||||||
|
moare in specificitatea fix-ului. → Aplicat ca criteriu de calitate explicit (US-001 AC + risc): fiecare
|
||||||
|
`fix` numeste loc/actiune concreta. **Deferare constienta:** `POST /v1/prezentari` real intoarce doar
|
||||||
|
`status`, nu `erori` inline — integratorul afla "de ce" printr-un GET sau prin `/valideaza`; a adauga
|
||||||
|
`erori` inline pe ruta reala ar fi aditiv + util, dar e scope creep peste 5.4 → notat ca oportunitate
|
||||||
|
viitoare, nu in scope acum.
|
||||||
|
|
||||||
|
**Eng (fezabilitate/teste) — PASS cu 3 conditii (aplicate in PRD).** Catalogul pur + helper = backbone
|
||||||
|
fezabil, drift prins la dev. **(1) Critic — `rar_error` stocat trebuie SUPERSET** (old keys intacte): altfel
|
||||||
|
`labels.motiv_uman` se strica intre Val 3 (backend schimba forma) si Val 4 (UI o citeste); superset =
|
||||||
|
zero migrare + degradare gratioasa (lectia 3.6). Aplicat in US-003/US-004 AC + risc. **(2) Byte-compat**:
|
||||||
|
validarea pune mesajul existent verbatim ca `cauza`, `message` ramane identic → testele pe `message`
|
||||||
|
raman verzi (US-002). **(3) Teste subset, nu egalitate exacta** de dict (chei aditive) — aplicat in
|
||||||
|
US-002/003/005. `models.py` trebuie sa accepte chei in plus pe `erori`/`nemapate` (verificat in US-003).
|
||||||
|
Worker testabil cu `rar_client` mock-uit (fara live RAR).
|
||||||
|
|
||||||
|
**Design (UI — US-006/US-007) — PASS cu 3 conditii (aplicate in PRD).** **(1) Progresiv, nu regresa
|
||||||
|
dashboard-ul compact din 3.5/3.6**: in lista/rand → problema + fix pe o linie; cauza + tehnic RAR in
|
||||||
|
detaliu/`<details>`; cele 3 niveluri complete doar in panoul de detaliu + preview-ul de rand. **(2)
|
||||||
|
Scannabil**: etichete/ierarhie vizuala ("Problema"/"De ce"/"Cum repari") ca user-ul sa parcurga fara
|
||||||
|
citire integrala. **(3) AA in ambele teme** (lectia 5.3): accentul de "fix/actiune" trece AA light+dark,
|
||||||
|
fara culori hardcodate, distinct de rosul de eroare. Reutilizeaza pattern-ul bun din `_status.html`
|
||||||
|
(problema + subtext = ~ problema + fix), nu il inlocui.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raport VERIFY
|
||||||
|
|
||||||
|
Verificator independent (context curat, rol qa-only), 2026-06-22. **VERDICT GLOBAL: PASS.**
|
||||||
|
|
||||||
|
**1. Suita — PASS.** `python3 -m pytest -q` → 628 passed, 234 warnings.
|
||||||
|
|
||||||
|
**2. Acceptance criteria US-001..US-008 — toate PASS.** Verificate direct pe cod + probe live (TestClient + SQLite temp):
|
||||||
|
- US-001: catalog pur, 24 coduri (`MISSING:[]`, `EMPTY:[]`), `eroare('INEXISTENT')`→KeyError, chei `{field,cod,problema,cauza,fix,message}`, `message==cauza`/`==problema` corect.
|
||||||
|
- US-002: **byte-compat confirmat** — `message` VIN identic la octet cu `git show HEAD:app/validation.py` (textul vechi pus ca `cauza`); erori complete pe 6 chei.
|
||||||
|
- US-003: **`rar_error` SUPERSET confirmat** — needs_mapping pastreaza `unmapped`; auto_send pastreaza `auto_send`; needs_data ramane array cu `field`+`message`; `nemapate` din `/valideaza` poarta 3 niveluri; `models.py:113-114` permisiv (`list[dict]`).
|
||||||
|
- US-004: RAR 400→`RAR_VALIDARE` (`field`/`message` pastrate, cauza=mesaj RAR passthrough), RAR 401→`RAR_CREDS_INVALIDE` fara retry fara echo creds; `_is_transient` neschimbat.
|
||||||
|
- US-005: detalii import superset (`error`/`message`/`sheets`/`found`/`n_ok` + 3 niveluri).
|
||||||
|
- US-006: `parse_erori` degradeaza gratios (string plat / `[{field,message}]` fara cod / JSON invalid / None — fara exceptie); detaliu randeaza Problema/De ce/Cum repari + `<details>` tehnic; CSS doar variabile paleta, AA in ambele teme (accent 5.17/5.33, err 4.83/5.06), accent ≠ rosu.
|
||||||
|
- US-007: upload/mapcoloane prin macro; per-camp `camp-fix`.
|
||||||
|
- US-008: contract documentat (forma 6 chei, back-compat, tabel cod→problema/fix complet).
|
||||||
|
- **Calitate `fix` (finding CEO) — PASS**: specifice/actionabile ("talon pozitia E", "tab-ul Mapari", "CSV UTF-8", "maxim 5000 randuri"); niciun fix generic.
|
||||||
|
- **Non-Goal — PASS**: `auth_routes.py`/`csrf.py`/`main.py` NEATINSE (confirmat `git status`).
|
||||||
|
|
||||||
|
**3. E2E canal API — PASS.** `/valideaza`: (a) VIN invalid → `erori[0]` cu `cod/problema/cauza/fix` + `field`/`message` vechi; (b) cod_op nemapat → `nemapate[0]` cu `cod_op_service`/`denumire` + 3 niveluri. `POST /v1/prezentari` real → `200 {status:queued}`.
|
||||||
|
|
||||||
|
**4. E2E canal web (UI, TestClient pe fragmente) — PASS.** Upload invalid → `_upload.html` cu `eroare-3n` + "Cum repari" + fix; submission needs_data → `_trimitere_detaliu.html` cu 3 niveluri + `<details>` tehnic. (Browser Playwright neutilizat — fragmentele TestClient acopera criteriile.)
|
||||||
|
|
||||||
|
**5. Regresia de aur — PASS (live neprobat, conform asteptarii).** `POST /v1/prezentari`→queued + `test_api.py` verde. Flux LIVE RAR (worker→FINALIZATA pe RAR test) NEPROBAT — lipsesc `AUTOPASS_CREDS_KEY`+creds test+`--send` in mediu; NU e FAIL al 5.4 (endpoint-urile/UI noi nu ating trimiterea; worker-ul doar imbogateste `rar_error`).
|
||||||
|
|
||||||
|
**Observatie minora (ne-blocanta), REPARATA la CLOSE:** exemplul JSON din `api-rar-contract.md` avea `message`/`cauza` cu un text VIN usor diferit de cel real emis de `validation.py:72`. Corectat (ambele linii) sa coincida verbatim cu codul.
|
||||||
77
tests/test_errors.py
Normal file
77
tests/test_errors.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Teste pentru app/errors.py — catalog central de erori (US-001 / PRD 5.4).
|
||||||
|
|
||||||
|
Urmeaza TDD: testele sunt scrise INAINTE de implementare.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.errors import CATALOG, eroare
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_catalog_complet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_catalog_complet():
|
||||||
|
"""Fiecare intrare din CATALOG are 'problema' si 'fix' ne-goale."""
|
||||||
|
assert len(CATALOG) >= 24, "CATALOG trebuie sa contina cel putin 24 de coduri"
|
||||||
|
for cod, entry in CATALOG.items():
|
||||||
|
assert "problema" in entry, f"Lipseste 'problema' pentru codul {cod!r}"
|
||||||
|
assert "fix" in entry, f"Lipseste 'fix' pentru codul {cod!r}"
|
||||||
|
assert isinstance(entry["problema"], str) and entry["problema"].strip(), (
|
||||||
|
f"'problema' goala pentru codul {cod!r}"
|
||||||
|
)
|
||||||
|
assert isinstance(entry["fix"], str) and entry["fix"].strip(), (
|
||||||
|
f"'fix' gol pentru codul {cod!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_eroare_construieste_3niveluri
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_eroare_construieste_3niveluri():
|
||||||
|
"""eroare() intoarce dict cu exact cheile asteptate si valorile corecte."""
|
||||||
|
rezultat = eroare("VIN_FORMAT", field="vin", cauza="VIN-ul are 15 caractere")
|
||||||
|
|
||||||
|
chei_asteptate = {"field", "cod", "problema", "cauza", "fix", "message"}
|
||||||
|
assert set(rezultat.keys()) == chei_asteptate, (
|
||||||
|
f"Cheile obtinute: {set(rezultat.keys())} — asteptate: {chei_asteptate}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rezultat["cod"] == "VIN_FORMAT"
|
||||||
|
assert rezultat["field"] == "vin"
|
||||||
|
assert rezultat["cauza"] == "VIN-ul are 15 caractere"
|
||||||
|
assert rezultat["problema"] == CATALOG["VIN_FORMAT"]["problema"]
|
||||||
|
assert rezultat["fix"] == CATALOG["VIN_FORMAT"]["fix"]
|
||||||
|
# message == cauza cand cauza este dat
|
||||||
|
assert rezultat["message"] == "VIN-ul are 15 caractere"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_message_back_compat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_message_back_compat():
|
||||||
|
"""message == cauza cand cauza e dat; message == problema cand cauza lipseste."""
|
||||||
|
# Cu cauza
|
||||||
|
cu_cauza = eroare("DATA_FORMAT", cauza="data_primita=31/06/2026")
|
||||||
|
assert cu_cauza["message"] == "data_primita=31/06/2026"
|
||||||
|
|
||||||
|
# Fara cauza
|
||||||
|
fara_cauza = eroare("DATA_FORMAT")
|
||||||
|
assert fara_cauza["message"] == CATALOG["DATA_FORMAT"]["problema"]
|
||||||
|
# cauza din dict e None sau egala cu problema
|
||||||
|
assert fara_cauza["cauza"] == CATALOG["DATA_FORMAT"]["problema"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_cod_necunoscut_ridica
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cod_necunoscut_ridica():
|
||||||
|
"""eroare() pe cod absent din CATALOG ridica KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
eroare("INEXISTENT")
|
||||||
335
tests/test_import_errors.py
Normal file
335
tests/test_import_errors.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""Teste US-005 (PRD 5.4): erori de import imbracate pe 3 niveluri.
|
||||||
|
|
||||||
|
Verifica ca fiecare HTTPException de import in scop are `detail` superset:
|
||||||
|
- cheile vechi: `error`, `message`, context specific (sheets/found/n_ok)
|
||||||
|
- cheile noi din catalog: `cod`, `problema`, `cauza`, `fix`
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixture client #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
"""Client FastAPI cu DB temporara izolata per test."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "err.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.crypto import reset_cache
|
||||||
|
reset_cache()
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
get_settings.cache_clear()
|
||||||
|
reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpere #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
|
||||||
|
_ROW_OK = ["WVWZZZ1KZAW000123", "B999TST", "2026-06-15", "123456", "Revizie"]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_xlsx(rows: list[list], extra_sheets: list[str] | None = None) -> bytes:
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sheet1"
|
||||||
|
for row in rows:
|
||||||
|
ws.append(row)
|
||||||
|
if extra_sheets:
|
||||||
|
for name in extra_sheets:
|
||||||
|
ws2 = wb.create_sheet(name)
|
||||||
|
ws2.append(_HEADER)
|
||||||
|
ws2.append(_ROW_OK)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(client: TestClient, data: bytes, filename: str = "test.xlsx"):
|
||||||
|
return client.post(
|
||||||
|
"/v1/import",
|
||||||
|
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_3niveluri(detail: dict, cod: str) -> None:
|
||||||
|
"""Verifica prezenta cheilor de pe 3 niveluri."""
|
||||||
|
assert detail.get("cod") == cod, f"Asteptat cod={cod!r}, primit {detail.get('cod')!r}"
|
||||||
|
assert "problema" in detail, "Lipseste cheia 'problema'"
|
||||||
|
assert "cauza" in detail, "Lipseste cheia 'cauza'"
|
||||||
|
assert "fix" in detail, "Lipseste cheia 'fix'"
|
||||||
|
assert "error" in detail, "Lipseste cheia veche 'error'"
|
||||||
|
assert "message" in detail, "Lipseste cheia veche 'message'"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 1. Fisier prea mare -> IMPORT_FISIER_PREA_MARE #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestFisierPreaMare3Niveluri:
|
||||||
|
def test_fisier_prea_mare_3niveluri(self, client):
|
||||||
|
"""Upload fisier >5MB -> 413 cu cod IMPORT_FISIER_PREA_MARE + superset."""
|
||||||
|
# 5 MB + 100 bytes de junk (nu e un fisier valid, dar dimensiunea declanseaza
|
||||||
|
# FileTooLarge inainte de parsare xlsx)
|
||||||
|
data = b"PK" + b"X" * (5 * 1024 * 1024 + 100)
|
||||||
|
r = _upload(client, data, "mare.xlsx")
|
||||||
|
# Poate sa returneze 413 sau 422 (depinde daca e prins ca FileTooLarge sau altceva)
|
||||||
|
# Dupa implementare trebuie sa fie 413 pentru FileTooLarge
|
||||||
|
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "file_too_large"
|
||||||
|
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
|
||||||
|
|
||||||
|
def test_fisier_prea_mare_peste_5000_randuri(self, client):
|
||||||
|
"""Upload xlsx cu >5000 randuri -> 413 cu cod IMPORT_FISIER_PREA_MARE."""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(_HEADER)
|
||||||
|
for i in range(5001):
|
||||||
|
ws.append([f"WVWZZZ1KZAW{i:06d}", f"B{i:04d}TST", "2026-06-15", str(100000 + i), "Revizie"])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
r = _upload(client, buf.getvalue(), "mare.xlsx")
|
||||||
|
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "file_too_large"
|
||||||
|
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 2. Antet neclar -> IMPORT_ANTET_NECLAR #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestAntetNeclar3Niveluri:
|
||||||
|
def test_antet_neclar_3niveluri(self, client):
|
||||||
|
"""CSV fara antet recunoscut -> 422 cu IMPORT_ANTET_NECLAR + 'found' pastrat."""
|
||||||
|
# CSV cu un singur camp pe prima linie - declanseaza HeaderError
|
||||||
|
csv_data = b"ValoareAleatoare\n123\n456\n"
|
||||||
|
r = _upload(client, csv_data, "test.csv")
|
||||||
|
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "header_error"
|
||||||
|
assert "found" in detail, "Cheia 'found' trebuie pastrata"
|
||||||
|
_assert_3niveluri(detail, "IMPORT_ANTET_NECLAR")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 3. Encoding nesuportat -> IMPORT_ENCODING #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestEncoding3Niveluri:
|
||||||
|
def test_encoding_3niveluri(self, client):
|
||||||
|
"""CSV in encoding nesuportat (UTF-16 fara BOM detectabil) -> 422 IMPORT_ENCODING."""
|
||||||
|
# UTF-16 Little Endian fara BOM — va esua la decodare
|
||||||
|
text = "VIN;Nr\nABC;DEF\n"
|
||||||
|
data_utf16 = text.encode("utf-16-le") # fara BOM, encodingul va esua
|
||||||
|
r = _upload(client, data_utf16, "test.csv")
|
||||||
|
# Fie 422 encoding_error, fie parsare ciudata (depinde de sniff)
|
||||||
|
# Daca nu declanseaza UnicodeDecodeError (parsatorul e robust),
|
||||||
|
# testam direct prin injectie: fisier cu bytes invalizi UTF
|
||||||
|
data_latin = b"VIN;Nr\n" + bytes([0xFF, 0xFE, 0x00]) + b"\n"
|
||||||
|
r2 = _upload(client, data_latin, "test2.csv")
|
||||||
|
if r2.status_code == 422:
|
||||||
|
detail = r2.json()["detail"]
|
||||||
|
if detail.get("error") == "encoding_error":
|
||||||
|
_assert_3niveluri(detail, "IMPORT_ENCODING")
|
||||||
|
return
|
||||||
|
# Alternativa: fisier cu bytes complet invalizi pentru toate encodingurile incercate
|
||||||
|
invalid_bytes = b"\xff\xfe" + bytes(range(128, 256)) * 10
|
||||||
|
r3 = _upload(client, invalid_bytes, "test3.csv")
|
||||||
|
if r3.status_code == 422:
|
||||||
|
detail = r3.json()["detail"]
|
||||||
|
if detail.get("error") == "encoding_error":
|
||||||
|
_assert_3niveluri(detail, "IMPORT_ENCODING")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 4. Fisier nerecunoscut -> IMPORT_FISIER_NERECUNOSCUT #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestFisierNerecunoscut3Niveluri:
|
||||||
|
def test_fisier_nerecunoscut_3niveluri(self, client):
|
||||||
|
"""Fisier binar junk -> 422 cu IMPORT_FISIER_NERECUNOSCUT."""
|
||||||
|
r = _upload(client, b"\x00\x01\x02\x03binar", "test.xlsx")
|
||||||
|
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "invalid_file"
|
||||||
|
_assert_3niveluri(detail, "IMPORT_FISIER_NERECUNOSCUT")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 5. Multiple sheets -> IMPORT_MULTIPLE_SHEETS #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestMultipleSheets3Niveluri:
|
||||||
|
def test_multiple_sheets_3niveluri(self, client):
|
||||||
|
"""Xlsx cu >1 sheet non-gol -> 422 cu IMPORT_MULTIPLE_SHEETS + 'sheets' pastrat."""
|
||||||
|
data = _make_xlsx([_HEADER, _ROW_OK], extra_sheets=["Iulie"])
|
||||||
|
r = _upload(client, data, "multi.xlsx")
|
||||||
|
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "multiple_sheets"
|
||||||
|
assert "sheets" in detail, "Cheia 'sheets' trebuie pastrata"
|
||||||
|
assert "Sheet1" in detail["sheets"] or "Iulie" in detail["sheets"]
|
||||||
|
_assert_3niveluri(detail, "IMPORT_MULTIPLE_SHEETS")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 6. Fara mapare coloane -> IMPORT_FARA_MAPARE_COLOANE #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestFaraMapareColoane3Niveluri:
|
||||||
|
def test_fara_mapare_coloane_3niveluri(self, client):
|
||||||
|
"""Preview fara mapare configurata -> 422 cu IMPORT_FARA_MAPARE_COLOANE."""
|
||||||
|
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||||
|
r_up = _upload(client, data, "test.xlsx")
|
||||||
|
assert r_up.status_code == 200, r_up.text
|
||||||
|
import_id = r_up.json()["import_id"]
|
||||||
|
|
||||||
|
# Preview fara a salva maparea de coloane
|
||||||
|
r = client.get(f"/v1/import/{import_id}/preview")
|
||||||
|
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "no_column_mapping"
|
||||||
|
_assert_3niveluri(detail, "IMPORT_FARA_MAPARE_COLOANE")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 7. Confirmare gresita -> IMPORT_CONFIRMARE_GRESITA #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestConfirmareGresita3Niveluri:
|
||||||
|
def _seed_op(self) -> None:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
|
||||||
|
"VALUES ('OE-1','Verificare')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO operations_mapping "
|
||||||
|
"(account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||||
|
"VALUES (1, 'Revizie', 'OE-1', 1)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_confirmare_gresita_3niveluri(self, client):
|
||||||
|
"""Commit cu n_confirmat gresit -> 422 cu IMPORT_CONFIRMARE_GRESITA + n_ok pastrat."""
|
||||||
|
self._seed_op()
|
||||||
|
|
||||||
|
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||||
|
r_up = _upload(client, data, "test.xlsx")
|
||||||
|
assert r_up.status_code == 200, r_up.text
|
||||||
|
import_id = r_up.json()["import_id"]
|
||||||
|
|
||||||
|
# Salveaza maparea
|
||||||
|
client.post(f"/v1/import/{import_id}/column-mapping", json={
|
||||||
|
"json_mapare": {
|
||||||
|
"VIN": "vin",
|
||||||
|
"Nr inmatriculare": "nr_inmatriculare",
|
||||||
|
"Data prestatie": "data_prestatie",
|
||||||
|
"Odometru final": "odometru_final",
|
||||||
|
"Operatie": "operatie",
|
||||||
|
},
|
||||||
|
"format_data": "YYYY-MM-DD",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Preview pentru a rezolva randurile
|
||||||
|
r_prev = client.get(f"/v1/import/{import_id}/preview")
|
||||||
|
assert r_prev.status_code == 200, r_prev.text
|
||||||
|
|
||||||
|
# Commit cu numarul GRESIT (0 in loc de cel real)
|
||||||
|
r = client.post(f"/v1/import/{import_id}/commit", json={
|
||||||
|
"n_confirmat": 99, # gresit
|
||||||
|
"reviewed_rows": [],
|
||||||
|
})
|
||||||
|
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["error"] == "confirmare_gresita"
|
||||||
|
assert "n_ok" in detail, "Cheia 'n_ok' trebuie pastrata"
|
||||||
|
_assert_3niveluri(detail, "IMPORT_CONFIRMARE_GRESITA")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 8. Override ilizibil -> IMPORT_OVERRIDE_ILIZIBIL #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestOverrideIlizibil3Niveluri:
|
||||||
|
def test_override_ilizibil_forma_dict(self):
|
||||||
|
"""Verifica direct ca detail-ul generat pentru override ilizibil are forma dict corecta.
|
||||||
|
|
||||||
|
In loc sa simulam un override corupt in DB (care necesita manipulare Fernet directa),
|
||||||
|
testam ca forma asteptata a detail-ului este un dict superset corect.
|
||||||
|
Testul de integrare completa este omis deoarece necesita injectie directs in DB.
|
||||||
|
"""
|
||||||
|
from app import errors
|
||||||
|
|
||||||
|
# Simuleaza ce face router-ul la eroarea de override ilizibil
|
||||||
|
msg = "override curent ilizibil; editare anulata"
|
||||||
|
detail = {
|
||||||
|
"error": "override_ilizibil",
|
||||||
|
"message": msg,
|
||||||
|
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=msg),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert detail["error"] == "override_ilizibil"
|
||||||
|
assert detail["message"] == msg
|
||||||
|
assert detail["cod"] == "IMPORT_OVERRIDE_ILIZIBIL"
|
||||||
|
assert "problema" in detail
|
||||||
|
assert "cauza" in detail
|
||||||
|
assert detail["cauza"] == msg
|
||||||
|
assert "fix" in detail
|
||||||
|
|
||||||
|
def test_override_ilizibil_via_api(self, client, monkeypatch):
|
||||||
|
"""Test integrare: override ilizibil returnat ca dict superset (nu string)."""
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
# Upload + mapare
|
||||||
|
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||||
|
r_up = _upload(client, data, "test.xlsx")
|
||||||
|
assert r_up.status_code == 200, r_up.text
|
||||||
|
import_id = r_up.json()["import_id"]
|
||||||
|
|
||||||
|
# Injecteaza direct un override_json corupt in DB
|
||||||
|
import sqlite3
|
||||||
|
from app.config import get_settings
|
||||||
|
db_path = get_settings().db_path
|
||||||
|
conn_raw = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn_raw.execute(
|
||||||
|
"UPDATE import_rows SET override_json=? WHERE batch_id=? AND row_index=0",
|
||||||
|
("TOKEN_CORUPT_INVALID", import_id),
|
||||||
|
)
|
||||||
|
conn_raw.commit()
|
||||||
|
finally:
|
||||||
|
conn_raw.close()
|
||||||
|
|
||||||
|
# Cerere de editare pe randul cu override corupt
|
||||||
|
r = client.post(f"/v1/import/{import_id}/rand/0/editeaza", json={"vin": "WVWZZZ1KZAW000999"})
|
||||||
|
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
|
||||||
|
# Dupa US-005: detail trebuie sa fie dict, nu string
|
||||||
|
assert isinstance(detail, dict), f"detail trebuie sa fie dict, primit: {type(detail)}"
|
||||||
|
assert detail.get("error") == "override_ilizibil"
|
||||||
|
assert detail.get("cod") == "IMPORT_OVERRIDE_ILIZIBIL"
|
||||||
|
assert "problema" in detail
|
||||||
|
assert "fix" in detail
|
||||||
@@ -161,3 +161,89 @@ def test_op_mapat_declanseaza_regula_odometru(client):
|
|||||||
def test_item_fara_cod_si_fara_op_e_422(client):
|
def test_item_fara_cod_si_fara_op_e_422(client):
|
||||||
r = client.post("/v1/prezentari", json=_body([{"denumire": "doar text"}]))
|
r = client.post("/v1/prezentari", json=_body([{"denumire": "doar text"}]))
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# US-003: 3 niveluri in classify_prezentare (needs_mapping) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_unmapped_are_3niveluri(client):
|
||||||
|
"""cod_op_service necunoscut -> needs_mapping; rar_error are cheie 'unmapped'
|
||||||
|
PASTRATA + campurile COD_NEMAPAT (cod/problema/cauza/fix)."""
|
||||||
|
import json
|
||||||
|
from app.mapping import classify_prezentare
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123",
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_op_service": "OP_NECUNOSCUT", "denumire": "Reparatie necunoscuta"}],
|
||||||
|
}
|
||||||
|
mapping = {}
|
||||||
|
mapping_meta = {}
|
||||||
|
res = classify_prezentare(content, mapping, mapping_meta)
|
||||||
|
assert res["status"] == "needs_mapping"
|
||||||
|
err = json.loads(res["rar_error"])
|
||||||
|
# Cheia originala pastrata
|
||||||
|
assert "unmapped" in err
|
||||||
|
assert len(err["unmapped"]) == 1
|
||||||
|
assert err["unmapped"][0]["cod_op_service"] == "OP_NECUNOSCUT"
|
||||||
|
# 3 niveluri prezente
|
||||||
|
assert err["cod"] == "COD_NEMAPAT"
|
||||||
|
assert err["problema"]
|
||||||
|
assert err["cauza"]
|
||||||
|
assert err["fix"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_send_oprit_3niveluri(client):
|
||||||
|
"""Mapare cu auto_send=0 -> needs_mapping; rar_error are cheie 'auto_send'
|
||||||
|
PASTRATA + campurile AUTO_SEND_OPRIT (cod/problema/cauza/fix)."""
|
||||||
|
import json
|
||||||
|
from app.mapping import classify_prezentare
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123",
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_op_service": "OP_REVIEW", "denumire": "Operatie cu review"}],
|
||||||
|
}
|
||||||
|
mapping = {"OP_REVIEW": "OE-1"}
|
||||||
|
mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
|
||||||
|
res = classify_prezentare(content, mapping, mapping_meta)
|
||||||
|
assert res["status"] == "needs_mapping"
|
||||||
|
err = json.loads(res["rar_error"])
|
||||||
|
# Cheia originala pastrata
|
||||||
|
assert "auto_send" in err
|
||||||
|
# 3 niveluri prezente
|
||||||
|
assert err["cod"] == "AUTO_SEND_OPRIT"
|
||||||
|
assert err["problema"]
|
||||||
|
assert err["cauza"]
|
||||||
|
assert err["fix"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_data_pass_through(client):
|
||||||
|
"""VIN invalid -> needs_data; rar_error = array cu erori care au cod/problema/fix (US-002)."""
|
||||||
|
import json
|
||||||
|
from app.mapping import classify_prezentare
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"vin": "VIN_INVALID_XXXXXXXXX", # nu trece regex
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
mapping = {}
|
||||||
|
mapping_meta = {}
|
||||||
|
res = classify_prezentare(content, mapping, mapping_meta)
|
||||||
|
assert res["status"] == "needs_data"
|
||||||
|
erori = json.loads(res["rar_error"])
|
||||||
|
assert isinstance(erori, list)
|
||||||
|
assert len(erori) >= 1
|
||||||
|
# Fiecare eroare are cele 3 niveluri (pass-through US-002)
|
||||||
|
for e in erori:
|
||||||
|
assert "cod" in e, f"lipseste 'cod' in {e}"
|
||||||
|
assert "problema" in e, f"lipseste 'problema' in {e}"
|
||||||
|
assert "fix" in e, f"lipseste 'fix' in {e}"
|
||||||
|
|||||||
@@ -157,3 +157,44 @@ def test_shape_invalid_422(client):
|
|||||||
for err in body.get("detail", []):
|
for err in body.get("detail", []):
|
||||||
assert "input" not in err
|
assert "input" not in err
|
||||||
assert "ctx" not in err
|
assert "ctx" not in err
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# US-003: 3 niveluri in raspunsul /valideaza #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_erori_au_3niveluri(client):
|
||||||
|
"""/valideaza cu VIN invalid -> erori[i] au cod/problema/cauza/fix (pass-through US-002)."""
|
||||||
|
r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["status_estimat"] == "needs_data"
|
||||||
|
erori = res["erori"]
|
||||||
|
assert len(erori) >= 1
|
||||||
|
for e in erori:
|
||||||
|
assert "cod" in e, f"lipseste 'cod' in {e}"
|
||||||
|
assert "problema" in e, f"lipseste 'problema' in {e}"
|
||||||
|
assert "cauza" in e, f"lipseste 'cauza' in {e}"
|
||||||
|
assert "fix" in e, f"lipseste 'fix' in {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nemapate_au_3niveluri(client):
|
||||||
|
"""/valideaza cu cod_op nemapat -> nemapate[i] au cod_op_service+denumire PASTRATE
|
||||||
|
+ cod==COD_NEMAPAT + cele 3 niveluri."""
|
||||||
|
prez = _prez()
|
||||||
|
prez["prestatii"] = [{"cod_op_service": "OP_TEST_NEMAPAT", "denumire": "Operatie test"}]
|
||||||
|
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["status_estimat"] == "needs_mapping"
|
||||||
|
nemapate = res["nemapate"]
|
||||||
|
assert len(nemapate) == 1
|
||||||
|
n = nemapate[0]
|
||||||
|
# Campuri originale pastrate
|
||||||
|
assert n["cod_op_service"] == "OP_TEST_NEMAPAT"
|
||||||
|
assert n["denumire"] == "Operatie test"
|
||||||
|
# 3 niveluri adaugate
|
||||||
|
assert n["cod"] == "COD_NEMAPAT"
|
||||||
|
assert n["problema"]
|
||||||
|
assert n["cauza"]
|
||||||
|
assert n["fix"]
|
||||||
|
|||||||
@@ -137,3 +137,114 @@ def test_b64image_valid_ok():
|
|||||||
def test_erori_multiple_cumulate():
|
def test_erori_multiple_cumulate():
|
||||||
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01"))
|
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01"))
|
||||||
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
|
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# US-002: 3 niveluri — coduri stabile, forma aditiva, back-compat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from app import errors as err_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_vin_invalid_are_3niveluri():
|
||||||
|
"""VIN cu O/I/Q => eroarea are cod, problema, fix, field, message neschimbat."""
|
||||||
|
errs = validate_prezentare(_base(vin="WVWZZZ1OZAW000123"))
|
||||||
|
vin_errs = [e for e in errs if e.get("field") == "vin"]
|
||||||
|
assert vin_errs, "Trebuie cel putin o eroare cu field==vin"
|
||||||
|
e = vin_errs[0]
|
||||||
|
assert e["cod"] == "VIN_FORMAT"
|
||||||
|
assert e["problema"], "problema trebuie sa fie ne-goala"
|
||||||
|
assert e["fix"], "fix trebuie sa fie ne-gol"
|
||||||
|
assert e["field"] == "vin"
|
||||||
|
# message trebuie sa fie mesajul existent (back-compat)
|
||||||
|
assert "17" in e["message"] or "O, I, Q" in e["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_prea_veche_cod():
|
||||||
|
errs = validate_prezentare(_base(data_prestatie="2024-11-30"))
|
||||||
|
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||||
|
assert dp
|
||||||
|
assert dp[0]["cod"] == "DATA_PREA_VECHE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_viitor_cod():
|
||||||
|
from datetime import timedelta
|
||||||
|
maine = (today_bucuresti() + timedelta(days=1)).isoformat()
|
||||||
|
errs = validate_prezentare(_base(data_prestatie=maine))
|
||||||
|
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||||
|
assert dp
|
||||||
|
assert dp[0]["cod"] == "DATA_VIITOR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_format_cod():
|
||||||
|
errs = validate_prezentare(_base(data_prestatie="15-06-2026"))
|
||||||
|
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||||
|
assert dp
|
||||||
|
assert dp[0]["cod"] == "DATA_FORMAT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_odometru_initial_lipsa_cod():
|
||||||
|
errs = validate_prezentare(_base(prestatii=[{"cod_prestatie": "R-ODO"}]))
|
||||||
|
oi = [e for e in errs if e.get("field") == "odometru_initial"]
|
||||||
|
assert oi
|
||||||
|
assert oi[0]["cod"] == "ODOMETRU_INITIAL_LIPSA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_odometru_ordine_cod():
|
||||||
|
c = _base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000")
|
||||||
|
errs = validate_prezentare(c)
|
||||||
|
oi = [e for e in errs if e.get("field") == "odometru_initial" and e.get("cod") == "ODOMETRU_INITIAL_ORDINE"]
|
||||||
|
assert oi, "Trebuie eroare cu cod ODOMETRU_INITIAL_ORDINE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prestatii_goale_cod():
|
||||||
|
errs = validate_prezentare(_base(prestatii=[]))
|
||||||
|
pr = [e for e in errs if e.get("field") == "prestatii"]
|
||||||
|
assert pr
|
||||||
|
assert pr[0]["cod"] == "PRESTATII_GOALE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_b64_invalid_cod():
|
||||||
|
errs = validate_prezentare(_base(b64_image="@@@not-base64@@@"))
|
||||||
|
b = [e for e in errs if e.get("field") == "b64_image"]
|
||||||
|
assert b
|
||||||
|
assert b[0]["cod"] == "B64_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_back_compat_field_message():
|
||||||
|
"""Fiecare eroare are inca field + message (forma veche)."""
|
||||||
|
errs = validate_prezentare(_base(
|
||||||
|
vin="BAD",
|
||||||
|
nr_inmatriculare="X-Y",
|
||||||
|
data_prestatie="2024-01-01",
|
||||||
|
odometru_final="abc",
|
||||||
|
prestatii=[],
|
||||||
|
))
|
||||||
|
for e in errs:
|
||||||
|
assert "field" in e, f"Lipseste 'field' in {e}"
|
||||||
|
assert "message" in e, f"Lipseste 'message' in {e}"
|
||||||
|
assert e["message"], f"'message' gol in {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_toate_codurile_in_catalog():
|
||||||
|
"""Fiecare cod emis de validate_prezentare exista in errors.CATALOG."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cazuri = [
|
||||||
|
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
|
||||||
|
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT
|
||||||
|
_base(data_prestatie="15-06-2026"), # DATA_FORMAT
|
||||||
|
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
|
||||||
|
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR
|
||||||
|
_base(odometru_final="abc"), # ODOMETRU_FINAL_FORMAT
|
||||||
|
_base(prestatii=[{"cod_prestatie": "R-ODO"}]), # ODOMETRU_INITIAL_LIPSA
|
||||||
|
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="abc"), # ODOMETRU_INITIAL_FORMAT
|
||||||
|
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000"), # ODOMETRU_INITIAL_ORDINE
|
||||||
|
_base(prestatii=[]), # PRESTATII_GOALE
|
||||||
|
_base(b64_image="@@@not-base64@@@"), # B64_INVALID
|
||||||
|
]
|
||||||
|
for caz in cazuri:
|
||||||
|
errs = validate_prezentare(caz)
|
||||||
|
for e in errs:
|
||||||
|
if "cod" in e:
|
||||||
|
assert e["cod"] in err_mod.CATALOG, f"Cod {e['cod']!r} absent din CATALOG"
|
||||||
|
|||||||
285
tests/test_web_erori.py
Normal file
285
tests/test_web_erori.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"""Teste US-006 (PRD 5.4): Componenta UI de eroare pe 3 niveluri.
|
||||||
|
|
||||||
|
Pasul A (RED): teste scrise inainte de implementare.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Teste pure pentru parse_erori (fara HTTP)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from app.web.labels import parse_erori # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_erori_array_3niveluri():
|
||||||
|
"""Array imbogatit cu cod/problema/cauza/fix -> lista cu toate 3 nivelurile."""
|
||||||
|
rar_error = json.dumps([
|
||||||
|
{
|
||||||
|
"field": "vin",
|
||||||
|
"cod": "VIN_FORMAT",
|
||||||
|
"problema": "VIN invalid",
|
||||||
|
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||||
|
"fix": "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule.",
|
||||||
|
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
rezultat = parse_erori(rar_error)
|
||||||
|
assert len(rezultat) == 1
|
||||||
|
e = rezultat[0]
|
||||||
|
assert e["problema"] == "VIN invalid"
|
||||||
|
assert "17" in e["cauza"]
|
||||||
|
assert e["fix"] # non-gol
|
||||||
|
assert e.get("field") == "vin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_erori_unmapped():
|
||||||
|
"""Dict unmapped 3-niveluri (cod=COD_NEMAPAT) -> 1 element corect."""
|
||||||
|
rar_error = json.dumps({
|
||||||
|
"cod": "COD_NEMAPAT",
|
||||||
|
"problema": "Lipseste codul RAR al operatiei",
|
||||||
|
"cauza": "Codul OP-99 nu are mapare RAR.",
|
||||||
|
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari.",
|
||||||
|
"unmapped": [{"cod_op_service": "OP-99", "denumire": "Operatie test"}],
|
||||||
|
})
|
||||||
|
rezultat = parse_erori(rar_error)
|
||||||
|
assert len(rezultat) == 1
|
||||||
|
e = rezultat[0]
|
||||||
|
assert e["problema"] == "Lipseste codul RAR al operatiei"
|
||||||
|
assert e["fix"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_erori_creds():
|
||||||
|
"""Dict cu cod=RAR_CREDS_INVALIDE -> 1 element corect."""
|
||||||
|
rar_error = json.dumps({
|
||||||
|
"cod": "RAR_CREDS_INVALIDE",
|
||||||
|
"problema": "Credentiale RAR invalide",
|
||||||
|
"cauza": "Autentificarea la RAR a esuat (401).",
|
||||||
|
"fix": "Verifica email-ul si parola contului RAR in tab-ul Cont.",
|
||||||
|
})
|
||||||
|
rezultat = parse_erori(rar_error)
|
||||||
|
assert len(rezultat) == 1
|
||||||
|
e = rezultat[0]
|
||||||
|
assert e["problema"] == "Credentiale RAR invalide"
|
||||||
|
assert e["fix"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_erori_forma_veche_si_corupt():
|
||||||
|
"""Forma veche [{ field, message }], string plain, None, invalid -> degradeaza fara exceptie."""
|
||||||
|
# Forma veche: lista cu field+message dar fara cod
|
||||||
|
vechi = json.dumps([{"field": "vin", "message": "VIN invalid"}])
|
||||||
|
r = parse_erori(vechi)
|
||||||
|
assert isinstance(r, list)
|
||||||
|
assert len(r) >= 1
|
||||||
|
# Nu arunca pentru niciun element
|
||||||
|
for e in r:
|
||||||
|
assert "problema" in e
|
||||||
|
|
||||||
|
# String plain
|
||||||
|
r2 = parse_erori("Eroare generica de la RAR")
|
||||||
|
assert isinstance(r2, list)
|
||||||
|
assert len(r2) >= 1
|
||||||
|
assert r2[0]["problema"]
|
||||||
|
|
||||||
|
# None
|
||||||
|
r3 = parse_erori(None)
|
||||||
|
assert isinstance(r3, list)
|
||||||
|
assert r3 == []
|
||||||
|
|
||||||
|
# JSON corupt
|
||||||
|
r4 = parse_erori("{invalid json[[[")
|
||||||
|
assert isinstance(r4, list)
|
||||||
|
assert len(r4) >= 1
|
||||||
|
# Nu arunca
|
||||||
|
|
||||||
|
# Dict fara cod (forma veche dict)
|
||||||
|
r5 = parse_erori(json.dumps({"auto_send": "cod-abc", "motiv": "auto_send oprit"}))
|
||||||
|
assert isinstance(r5, list)
|
||||||
|
assert len(r5) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture HTTP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.main import app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_bytes(rows: list[dict]) -> bytes:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
return buf.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_mapping(conn=None):
|
||||||
|
"""Mapeaza OP-1 -> R-FRANE (cont dev id=1)."""
|
||||||
|
from app.db import get_connection
|
||||||
|
c = conn or get_connection()
|
||||||
|
try:
|
||||||
|
c.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||||
|
"VALUES (1, 'OP-1', 'R-FRANE', 1)"
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
finally:
|
||||||
|
if conn is None:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _creeaza_submission_needs_data(client, fix_text=None):
|
||||||
|
"""Creeaza un submission in starea needs_data si returneaza id-ul."""
|
||||||
|
import json as _json
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
_seed_mapping()
|
||||||
|
|
||||||
|
# Insereaza direct un submission needs_data cu rar_error 3-niveluri
|
||||||
|
fix_folosit = fix_text or "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule."
|
||||||
|
rar_error_3n = _json.dumps([{
|
||||||
|
"field": "vin",
|
||||||
|
"cod": "VIN_FORMAT",
|
||||||
|
"problema": "VIN invalid",
|
||||||
|
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||||
|
"fix": fix_folosit,
|
||||||
|
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||||
|
}])
|
||||||
|
|
||||||
|
payload = _json.dumps({
|
||||||
|
"vin": "VIN16CARACT000000",
|
||||||
|
"nr_inmatriculare": "B001TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": 145000,
|
||||||
|
"cod_prestatie": "R-FRANE",
|
||||||
|
"prestatii": [{"cod_prestatie": "R-FRANE"}],
|
||||||
|
})
|
||||||
|
idem_key = f"test-erori-{fix_folosit[:20]}"
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO submissions
|
||||||
|
(account_id, idempotency_key, payload_json, status, rar_error, retry_count)
|
||||||
|
VALUES (1, ?, ?, 'needs_data', ?, 0)""",
|
||||||
|
(idem_key, payload, rar_error_3n)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_detaliu_afiseaza_fix(client):
|
||||||
|
"""Fragmentul de detaliu al unui submission needs_data contine textul fix-ului."""
|
||||||
|
sub_id = _creeaza_submission_needs_data(client, "Verifica VIN-ul pe talon (pozitia E)")
|
||||||
|
resp = client.get(f"/_fragments/trimitere/{sub_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.text
|
||||||
|
# Fix-ul trebuie sa apara in HTML
|
||||||
|
assert "pozitia E" in html, (
|
||||||
|
f"HTML-ul detaliu nu contine fix-ul ('pozitia E'). Primii 2000 chars:\n{html[:2000]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_preview_cu_vin_invalid(client):
|
||||||
|
"""Efectueaza un import cu un rand cu VIN invalid si returneaza HTML-ul preview."""
|
||||||
|
_seed_mapping()
|
||||||
|
rows = [
|
||||||
|
# rand cu VIN de 16 caractere (invalid, trebuie 17) -> needs_data
|
||||||
|
{"VIN": "VIN16CARACT00000", "Nr inmatriculare": "B001TST",
|
||||||
|
"Data prestatie": "15.06.2026", "Odometru final": "145000", "Operatie": "OP-1"},
|
||||||
|
]
|
||||||
|
data = _csv_bytes(rows)
|
||||||
|
|
||||||
|
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
|
||||||
|
assert r.status_code == 200
|
||||||
|
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||||
|
assert m, f"Nu am gasit import_id. Raspuns: {r.text[:1000]}"
|
||||||
|
import_id = int(m.group(1))
|
||||||
|
|
||||||
|
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
|
||||||
|
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||||
|
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||||
|
"format_data": "DD.MM.YYYY",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_rand_per_camp_fix(client):
|
||||||
|
"""Preview rand needs_data -> HTML contine fix-ul per camp (VIN)."""
|
||||||
|
html = _import_preview_cu_vin_invalid(client)
|
||||||
|
# Randul trebuie sa fie needs_data
|
||||||
|
assert "needs_data" in html or "needs_review" in html or "VIN" in html.upper(), (
|
||||||
|
f"HTML-ul preview nu contine starea asteptata. Primii 2000 chars:\n{html[:2000]}"
|
||||||
|
)
|
||||||
|
# Fix-ul pentru VIN trebuie sa apara in preview
|
||||||
|
assert "talon" in html.lower() or "majuscule" in html.lower() or "pozitia" in html.lower(), (
|
||||||
|
f"HTML-ul preview nu contine fix-ul VIN. Primii 3000 chars:\n{html[:3000]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Teste noi (BUG 1 + BUG 2) — adaugate TDD-style (RED inainte de fix)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from app.web.labels import motiv_uman # noqa: E402
|
||||||
|
from app.errors import eroare # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_motiv_uman_creds_3niveluri():
|
||||||
|
"""BUG 1: motiv_uman pe dict 3-niveluri (RAR_CREDS_INVALIDE) -> problema, NU text garbled."""
|
||||||
|
rar_err = json.dumps(eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"))
|
||||||
|
rezultat = motiv_uman("error", rar_err)
|
||||||
|
# Nu trebuie sa contina "field:" / "cod:" (text garbled)
|
||||||
|
assert "field:" not in rezultat, f"Text garbled cu 'field:': {rezultat!r}"
|
||||||
|
assert "cod:" not in rezultat, f"Text garbled cu 'cod:': {rezultat!r}"
|
||||||
|
# Trebuie sa returneze textul 'problema' (primul nivel)
|
||||||
|
data = json.loads(rar_err)
|
||||||
|
problema = data.get("problema") or ""
|
||||||
|
assert problema, "eroare() nu a returnat 'problema' — verifica error_codes.py"
|
||||||
|
assert rezultat == problema[:200], (
|
||||||
|
f"Asteptat {problema[:200]!r}, obtinut {rezultat!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_motiv_uman_unmapped_neschimbat():
|
||||||
|
"""Ramura unmapped inca functioneaza dupa adaugarea ramurii 3-niveluri."""
|
||||||
|
rar_err = json.dumps({"unmapped": [{"cod_op_service": "OP-99", "denumire": "Test"}]})
|
||||||
|
rezultat = motiv_uman("needs_mapping", rar_err)
|
||||||
|
assert rezultat.startswith("Cod RAR lipsa pentru:"), (
|
||||||
|
f"Ramura unmapped regresta. Obtinut: {rezultat!r}"
|
||||||
|
)
|
||||||
|
assert "OP-99" in rezultat
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_erori_gol_returneaza_lista_goala():
|
||||||
|
"""BUG 2: parse_erori pe dict/lista goala -> [], nu 1 element cu problema=''."""
|
||||||
|
r1 = parse_erori("{}")
|
||||||
|
assert r1 == [], f"parse_erori('{{}}') -> {r1!r}, asteptat []"
|
||||||
|
|
||||||
|
r2 = parse_erori("[{}]")
|
||||||
|
assert r2 == [], f"parse_erori('[{{}}]') -> {r2!r}, asteptat []"
|
||||||
180
tests/test_web_import_erori.py
Normal file
180
tests/test_web_import_erori.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""Teste US-007 (PRD 5.4): erori de import pe 3 niveluri in interfata web.
|
||||||
|
|
||||||
|
Verifica ca fragmentele HTML intoarse de rutele web /_import/* contin
|
||||||
|
textele structurate (problema + fix) din catalog pentru erorile de upload
|
||||||
|
si de mapare coloane.
|
||||||
|
|
||||||
|
Rutele testate sunt WEB (HTML), nu API JSON — raspunsul este HTML fragment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixture client cu WEB_AUTH_REQUIRED=false (dev mode, cont 1 implicit) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
"""Client FastAPI cu DB temporara izolata, auth web dezactivat (dev mode)."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori3n.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.crypto import reset_cache
|
||||||
|
reset_cache()
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
get_settings.cache_clear()
|
||||||
|
reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpere #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _make_xlsx_prea_mare(n_randuri: int = 5001) -> bytes:
|
||||||
|
"""Xlsx cu mai mult de 5000 de randuri de date (declanseaza FileTooLarge)."""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"])
|
||||||
|
for i in range(n_randuri):
|
||||||
|
ws.append([
|
||||||
|
f"WVWZZZ1KZA{i:07d}",
|
||||||
|
f"B{i:04d}TST",
|
||||||
|
"2026-06-15",
|
||||||
|
str(100000 + i),
|
||||||
|
"Revizie",
|
||||||
|
])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_web(client: TestClient, data: bytes, filename: str = "test.xlsx") -> object:
|
||||||
|
"""POST pe ruta web de upload (intoarce HTML fragment)."""
|
||||||
|
return client.post(
|
||||||
|
"/_import/upload",
|
||||||
|
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 1. Upload fisier prea mare → fragment cu fix din IMPORT_FISIER_PREA_MARE #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_upload_fisier_prea_mare_3niveluri(client):
|
||||||
|
"""Upload xlsx >5000 randuri → fragment HTML contine textul fix din catalog.
|
||||||
|
|
||||||
|
Textul asteptat din CATALOG['IMPORT_FISIER_PREA_MARE']['fix']:
|
||||||
|
'Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand.'
|
||||||
|
"""
|
||||||
|
from app.errors import CATALOG
|
||||||
|
fix_asteptat = CATALOG["IMPORT_FISIER_PREA_MARE"]["fix"]
|
||||||
|
|
||||||
|
data = _make_xlsx_prea_mare(5001)
|
||||||
|
r = _upload_web(client, data, "mare.xlsx")
|
||||||
|
|
||||||
|
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||||
|
html = r.text
|
||||||
|
assert fix_asteptat in html, (
|
||||||
|
f"Textul fix din IMPORT_FISIER_PREA_MARE nu apare in fragmentul HTML.\n"
|
||||||
|
f"Asteptat: {fix_asteptat!r}\n"
|
||||||
|
f"Fragment (primii 800 chars): {html[:800]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 2. Upload fisier nerecunoscut → fragment cu fix din IMPORT_FISIER_NERECUNOSCUT
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_upload_fisier_nerecunoscut_3niveluri(client):
|
||||||
|
"""Upload bytes invalizi (nu e xlsx/csv valid) → fragment HTML cu fix din catalog.
|
||||||
|
|
||||||
|
Textul asteptat din CATALOG['IMPORT_FISIER_NERECUNOSCUT']['fix']:
|
||||||
|
'Incarca un fisier .xlsx sau .csv valid.'
|
||||||
|
"""
|
||||||
|
from app.errors import CATALOG
|
||||||
|
fix_asteptat = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["fix"]
|
||||||
|
problema_asteptata = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["problema"]
|
||||||
|
|
||||||
|
# Bytes invalizi: nu este un zip/xlsx, nu este text CSV
|
||||||
|
date_invalide = b"\x00\x01\x02\x03\x04\x05binar_junk_non_xlsx"
|
||||||
|
r = _upload_web(client, date_invalide, "test.xlsx")
|
||||||
|
|
||||||
|
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||||
|
html = r.text
|
||||||
|
assert fix_asteptat in html, (
|
||||||
|
f"Textul fix din IMPORT_FISIER_NERECUNOSCUT nu apare in fragmentul HTML.\n"
|
||||||
|
f"Asteptat: {fix_asteptat!r}\n"
|
||||||
|
f"Fragment (primii 800 chars): {html[:800]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 3. Mapare coloane cu JSON invalid → fragment cu fix din COLOANE_FORMAT_JSON #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_mapcoloane_format_json_3niveluri(client):
|
||||||
|
"""POST direct pe ruta de mapare coloane cu corp JSON invalid → fragment cu fix din catalog.
|
||||||
|
|
||||||
|
Textul asteptat din CATALOG['COLOANE_FORMAT_JSON']['fix']:
|
||||||
|
'Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect).'
|
||||||
|
|
||||||
|
Ruta /_import/{id}/mapare-coloane accepta form data. Codul de test simuleaza
|
||||||
|
un batch_id invalid (0) pentru a forta ramura de eroare, sau injecteaza direct
|
||||||
|
un batch_id valid cu JSON invalid in campul de mapare. Deoarece ruta nu primeste
|
||||||
|
JSON direct, testam ramura din routes.py unde se face json.dumps si se valideaza
|
||||||
|
manual, sau modificam testul sa plaseze un batch valid si sa trimita date malformate.
|
||||||
|
|
||||||
|
Strategia: upload un fisier valid, obtine import_id, trimite un form cu valoare
|
||||||
|
json invalida in campul coloane (via Content-Type: application/json intentionat gresit
|
||||||
|
sau via parametru form malformat). In implementarea din routes.py, eroarea
|
||||||
|
COLOANE_FORMAT_JSON este randata cand json.loads esueaza pe un camp special.
|
||||||
|
"""
|
||||||
|
from app.errors import CATALOG
|
||||||
|
fix_asteptat = CATALOG["COLOANE_FORMAT_JSON"]["fix"]
|
||||||
|
|
||||||
|
# Primul pas: upload un fisier valid pentru a obtine un import_id real
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(["VIN", "Nr", "Data", "KM", "Op"])
|
||||||
|
ws.append(["WVWZZZ1KZAW000123", "B001TST", "2026-06-15", "100000", "Revizie"])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
xlsx_data = buf.getvalue()
|
||||||
|
|
||||||
|
r_upload = _upload_web(client, xlsx_data, "test.xlsx")
|
||||||
|
assert r_upload.status_code == 200, r_upload.text[:300]
|
||||||
|
|
||||||
|
# Extrage import_id din raspuns
|
||||||
|
import re
|
||||||
|
m = re.search(r"/_import/(\d+)/mapare-coloane", r_upload.text)
|
||||||
|
assert m, f"Nu s-a gasit import_id in raspuns: {r_upload.text[:500]}"
|
||||||
|
import_id = int(m.group(1))
|
||||||
|
|
||||||
|
# Trimite un request pe ruta de mapare coloane cu Content-Type: application/json
|
||||||
|
# si corp JSON invalid → ruta ar trebui sa intoarca eroarea COLOANE_FORMAT_JSON
|
||||||
|
r = client.post(
|
||||||
|
f"/_import/{import_id}/mapare-coloane",
|
||||||
|
content=b"{invalid json}",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||||
|
html = r.text
|
||||||
|
assert fix_asteptat in html, (
|
||||||
|
f"Textul fix din COLOANE_FORMAT_JSON nu apare in fragmentul HTML.\n"
|
||||||
|
f"Asteptat: {fix_asteptat!r}\n"
|
||||||
|
f"Fragment (primii 800 chars): {html[:800]}"
|
||||||
|
)
|
||||||
245
tests/test_worker_rar_errors.py
Normal file
245
tests/test_worker_rar_errors.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Teste US-004 — erorile RAR (400/401) imbracate pe 3 niveluri in worker.
|
||||||
|
|
||||||
|
process_one cu 400 -> rar_error superset [{field,message,cod,problema,cauza,fix}]
|
||||||
|
run() loop cu 401 la login -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE
|
||||||
|
Clasificare transient/terminal neschimbata.
|
||||||
|
Fara echo de credentiale in rar_error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.rar_client import RarAuthError, RarError
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fixtures DB (pattern din test_worker_reconcile.py) ---
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
conn = get_connection()
|
||||||
|
settings = get_settings()
|
||||||
|
yield conn, settings
|
||||||
|
conn.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
_CONTENT = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15", "odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(conn, status="queued", content=None, retry_count=0):
|
||||||
|
content = content or _CONTENT
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), retry_count),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(conn, sid):
|
||||||
|
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRar:
|
||||||
|
"""RAR mock cu comportament configurabil."""
|
||||||
|
def __init__(self, *, finalizate=None, post_result=None, post_exc=None):
|
||||||
|
self.finalizate = finalizate or []
|
||||||
|
self.post_result = post_result if post_result is not None else {"id": 1000}
|
||||||
|
self.post_exc = post_exc
|
||||||
|
self.post_calls = 0
|
||||||
|
self.finalizate_calls = 0
|
||||||
|
|
||||||
|
def get_finalizate(self, token):
|
||||||
|
self.finalizate_calls += 1
|
||||||
|
return self.finalizate
|
||||||
|
|
||||||
|
def post_prezentare(self, token, payload):
|
||||||
|
self.post_calls += 1
|
||||||
|
if self.post_exc is not None:
|
||||||
|
raise self.post_exc
|
||||||
|
return self.post_result
|
||||||
|
|
||||||
|
|
||||||
|
# --- Teste US-004 ---
|
||||||
|
|
||||||
|
def test_rar_400_stocheaza_3niveluri(env):
|
||||||
|
"""RAR 400 cu field_errors -> rar_error superset array cu cod/problema/fix/message."""
|
||||||
|
from app.worker.__main__ import process_one
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn)
|
||||||
|
exc = RarError("Validare esuata", status_code=400,
|
||||||
|
field_errors=[{"field": "vin", "message": "VIN invalid la RAR"}])
|
||||||
|
rar = FakeRar(post_exc=exc)
|
||||||
|
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||||
|
|
||||||
|
assert out == "needs_data"
|
||||||
|
row = _row(conn, sid)
|
||||||
|
assert row["status"] == "needs_data"
|
||||||
|
assert row["rar_status_code"] == 400
|
||||||
|
|
||||||
|
erori = json.loads(row["rar_error"])
|
||||||
|
assert isinstance(erori, list)
|
||||||
|
assert len(erori) == 1
|
||||||
|
|
||||||
|
elem = erori[0]
|
||||||
|
# Superset: field + message (vechi) + 3 niveluri (noi)
|
||||||
|
assert elem["field"] == "vin"
|
||||||
|
assert elem["message"] == "VIN invalid la RAR" # passthrough exact mesaj RAR
|
||||||
|
assert elem["cod"] == "RAR_VALIDARE"
|
||||||
|
assert elem["problema"] # ne-gol
|
||||||
|
assert elem["fix"] # ne-gol
|
||||||
|
assert elem["cauza"] == "VIN invalid la RAR" # cauza = mesajul RAR exact
|
||||||
|
|
||||||
|
|
||||||
|
def test_rar_400_fara_field_errors(env):
|
||||||
|
"""RAR 400 cu field_errors=[] -> array cu 1 element RAR_VALIDARE, cauza din str(exc)."""
|
||||||
|
from app.worker.__main__ import process_one
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn)
|
||||||
|
exc = RarError("Eroare generica RAR", status_code=400, field_errors=[])
|
||||||
|
rar = FakeRar(post_exc=exc)
|
||||||
|
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||||
|
|
||||||
|
assert out == "needs_data"
|
||||||
|
erori = json.loads(_row(conn, sid)["rar_error"])
|
||||||
|
assert isinstance(erori, list)
|
||||||
|
assert len(erori) == 1
|
||||||
|
|
||||||
|
elem = erori[0]
|
||||||
|
assert elem["cod"] == "RAR_VALIDARE"
|
||||||
|
assert elem["cauza"] == "Eroare generica RAR" # str(exc)
|
||||||
|
assert elem["problema"]
|
||||||
|
assert elem["fix"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rar_401_creds_3niveluri(env):
|
||||||
|
"""Login esueaza cu RarAuthError -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE."""
|
||||||
|
from app.worker.__main__ import mark, process_one
|
||||||
|
conn, settings = env
|
||||||
|
|
||||||
|
# Simulam fluxul din run(): login esueaza cu RarAuthError, workerul marcheaza direct.
|
||||||
|
# Testam handler-ul login din run() prin apel direct la mark() asa cum il face workerul.
|
||||||
|
sid = _insert(conn, status="sending")
|
||||||
|
|
||||||
|
# Apelam direct mark() cu JSON-ul pe care workerul il va produce (dupa implementare)
|
||||||
|
from app.errors import eroare
|
||||||
|
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
|
||||||
|
err_json = json.dumps(err_obj, ensure_ascii=False)
|
||||||
|
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||||
|
|
||||||
|
row = _row(conn, sid)
|
||||||
|
assert row["status"] == "error"
|
||||||
|
err = json.loads(row["rar_error"])
|
||||||
|
assert err["cod"] == "RAR_CREDS_INVALIDE"
|
||||||
|
assert err["problema"]
|
||||||
|
assert err["fix"]
|
||||||
|
assert err["cauza"] == "credentiale RAR invalide"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rar_401_login_in_run_loop(env):
|
||||||
|
"""run() loop: RarAuthError la login -> submission 'error' cu rar_error JSON 3-niveluri."""
|
||||||
|
from app.worker.__main__ import AccountSessions, _creds_for, _creds_from_account, mark, requeue_with_backoff
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn, status="queued")
|
||||||
|
|
||||||
|
# Simuleaza comportamentul din run() pentru blocul try: sessions.get_token cu RarAuthError
|
||||||
|
# Acesta e acelasi cod pe care il implementam; testam efectul final pe DB.
|
||||||
|
|
||||||
|
class FakeSessionsLoginFail:
|
||||||
|
def get_token(self, conn, account_id, creds):
|
||||||
|
raise RarAuthError("Credentiale RAR invalide", status_code=401)
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
from app.errors import eroare as _eroare
|
||||||
|
|
||||||
|
try:
|
||||||
|
FakeSessionsLoginFail().get_token(conn, 1, {"email": "x@x.com", "password": "secret123"})
|
||||||
|
except RarAuthError:
|
||||||
|
err_json = _json.dumps(_eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)
|
||||||
|
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||||
|
|
||||||
|
row = _row(conn, sid)
|
||||||
|
assert row["status"] == "error"
|
||||||
|
err = _json.loads(row["rar_error"])
|
||||||
|
assert err["cod"] == "RAR_CREDS_INVALIDE"
|
||||||
|
assert err["problema"]
|
||||||
|
assert err["fix"]
|
||||||
|
# Fara retry: statusul ramane error (nu queued)
|
||||||
|
assert row["retry_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_clasificare_transient_neschimbata(env):
|
||||||
|
"""5xx ramane transient: requeue cu retry, NU error terminal."""
|
||||||
|
from app.worker.__main__ import process_one
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn)
|
||||||
|
rar = FakeRar(finalizate=[], post_exc=RarError("server error", status_code=503))
|
||||||
|
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||||
|
|
||||||
|
assert out == "requeued"
|
||||||
|
row = _row(conn, sid)
|
||||||
|
assert row["status"] == "queued" # requeue, nu error
|
||||||
|
assert int(row["retry_count"]) == 1 # a incrementat retry
|
||||||
|
|
||||||
|
# Timeout httpx -> de asemenea transient
|
||||||
|
sid2 = _insert(conn)
|
||||||
|
rar2 = FakeRar(finalizate=[], post_exc=httpx.ConnectTimeout("timeout"))
|
||||||
|
out2 = process_one(conn, settings, rar2, "tok", {"id": sid2, "content": _CONTENT})
|
||||||
|
assert out2 == "requeued"
|
||||||
|
assert _row(conn, sid2)["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fara_echo_creds(env):
|
||||||
|
"""rar_error nu contine valori concrete de credentiale (email/parola reala).
|
||||||
|
|
||||||
|
Textul 'parola' poate aparea in mesajele de ajutor (fix) — e de asteptat.
|
||||||
|
Verificam ca parola/email-ul concret al utilizatorului NU apare in rar_error.
|
||||||
|
"""
|
||||||
|
from app.worker.__main__ import mark, process_one
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn, status="sending")
|
||||||
|
|
||||||
|
# O parola concreta fictiva care NU trebuie sa apara niciodata in rar_error
|
||||||
|
parola_concreta = "SuperSecret$789!"
|
||||||
|
email_concret = "user@exemplu.ro"
|
||||||
|
|
||||||
|
from app.errors import eroare
|
||||||
|
# Simuleaza eroarea 401 — cauza e generica, nu contine parola concreta
|
||||||
|
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
|
||||||
|
err_json = json.dumps(err_obj, ensure_ascii=False)
|
||||||
|
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||||
|
|
||||||
|
row = _row(conn, sid)
|
||||||
|
rar_error_str = row["rar_error"]
|
||||||
|
# Parola concreta si email-ul concret NU trebuie sa apara
|
||||||
|
assert parola_concreta not in rar_error_str
|
||||||
|
assert email_concret not in rar_error_str
|
||||||
|
# Campul "password" (cheia JSON) nu trebuie sa apara — ar insemna ca obiectul creds a fost serializat
|
||||||
|
assert '"password"' not in rar_error_str
|
||||||
|
|
||||||
|
# Verifica si pentru 400 — mesajul RAR nu contine parola utilizatorului
|
||||||
|
sid2 = _insert(conn)
|
||||||
|
exc = RarError("Validare", status_code=400, field_errors=[{"field": "vin", "message": "bad vin"}])
|
||||||
|
rar = FakeRar(post_exc=exc)
|
||||||
|
process_one(conn, settings, rar, "tok", {"id": sid2, "content": _CONTENT})
|
||||||
|
rar_error_400 = _row(conn, sid2)["rar_error"]
|
||||||
|
assert parola_concreta not in rar_error_400
|
||||||
|
assert '"password"' not in rar_error_400
|
||||||
Reference in New Issue
Block a user