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 pydantic import BaseModel, Field
|
||||
|
||||
from ... import errors
|
||||
from ...auth import resolve_account_id
|
||||
from ...crypto import decrypt_creds, encrypt_creds
|
||||
from ...db import get_connection
|
||||
@@ -326,7 +327,15 @@ def apply_row_override(
|
||||
dec = decrypt_creds(row["oj"])
|
||||
if dec is None:
|
||||
# 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
|
||||
|
||||
new_override = _merge_override(current, fields)
|
||||
@@ -406,6 +415,7 @@ async def upload_import(
|
||||
"error": "multiple_sheets",
|
||||
"message": str(ms),
|
||||
"sheets": ms.sheet_names,
|
||||
**errors.eroare("IMPORT_MULTIPLE_SHEETS", cauza=str(ms)),
|
||||
},
|
||||
)
|
||||
except FileTooLarge as e:
|
||||
@@ -414,6 +424,7 @@ async def upload_import(
|
||||
detail={
|
||||
"error": "file_too_large",
|
||||
"message": str(e),
|
||||
**errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)),
|
||||
},
|
||||
)
|
||||
except HeaderError as e:
|
||||
@@ -423,23 +434,28 @@ async def upload_import(
|
||||
"error": "header_error",
|
||||
"message": str(e),
|
||||
"found": e.found,
|
||||
**errors.eroare("IMPORT_ANTET_NECLAR", cauza=str(e)),
|
||||
},
|
||||
)
|
||||
except UnicodeDecodeError as e:
|
||||
_enc_msg = f"Encoding nesuportat: {e.reason}"
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "encoding_error",
|
||||
"message": f"Encoding nesuportat: {e.reason}",
|
||||
"message": _enc_msg,
|
||||
**errors.eroare("IMPORT_ENCODING", cauza=_enc_msg),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
# Fisier corupt (BadZipFile, InvalidFileException, etc.)
|
||||
_inv_msg = f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"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()
|
||||
|
||||
if not mapping_row:
|
||||
_ncm_msg = "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"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
|
||||
n_total_ok = len(ok_rows)
|
||||
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(
|
||||
status_code=422,
|
||||
detail={
|
||||
"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,
|
||||
**errors.eroare("IMPORT_CONFIRMARE_GRESITA", cauza=_cg_msg),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from pydantic import BaseModel, Field
|
||||
from ...auth import resolve_account_id
|
||||
from ...crypto import encrypt_creds
|
||||
from ...db import get_connection
|
||||
from ...errors import eroare as err_eroare
|
||||
from ...idempotency import build_key, canonicalize_row
|
||||
from ...mapping import (
|
||||
account_or_default,
|
||||
@@ -133,12 +134,17 @@ def valideaza_prezentari(
|
||||
for i, prez in enumerate(req.prezentari):
|
||||
content = prez.model_dump()
|
||||
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(
|
||||
index=i,
|
||||
valid=(res["status"] == "queued"),
|
||||
status_estimat=res["status"],
|
||||
erori=res["errors"],
|
||||
nemapate=res["unmapped"],
|
||||
nemapate=nemapate,
|
||||
prestatii_rezolvate=res["resolved"],
|
||||
))
|
||||
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 . import errors as err_mod
|
||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||
from .validation import validate_prezentare
|
||||
|
||||
@@ -245,7 +246,11 @@ def classify_prezentare(
|
||||
|
||||
if unmapped:
|
||||
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] = []
|
||||
else:
|
||||
errors = validate_prezentare(c)
|
||||
@@ -254,8 +259,9 @@ def classify_prezentare(
|
||||
rar_error = json.dumps(errors, ensure_ascii=False)
|
||||
elif has_no_auto_send(resolved, mapping_meta):
|
||||
status = "needs_mapping"
|
||||
mesaj = "cod mapat cu auto_send=0; review manual inainte de trimitere"
|
||||
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,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -17,6 +17,8 @@ import re
|
||||
from datetime import date
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.errors import eroare as _eroare
|
||||
|
||||
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
|
||||
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
||||
# Numar inmatriculare: max 10, litere + cifre majuscule.
|
||||
@@ -64,36 +66,54 @@ def validate_prezentare(content: dict) -> list[dict]:
|
||||
# --- VIN ---
|
||||
vin = _norm(content.get("vin"))
|
||||
if not VIN_RE.match(vin):
|
||||
errors.append({
|
||||
"field": "vin",
|
||||
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||
})
|
||||
errors.append(_eroare(
|
||||
"VIN_FORMAT",
|
||||
field="vin",
|
||||
cauza="VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||
))
|
||||
|
||||
# --- nrInmatriculare ---
|
||||
nrinm = _norm(content.get("nr_inmatriculare"))
|
||||
if not NRINM_RE.match(nrinm):
|
||||
errors.append({
|
||||
"field": "nr_inmatriculare",
|
||||
"message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.",
|
||||
})
|
||||
errors.append(_eroare(
|
||||
"NR_INMATRICULARE_FORMAT",
|
||||
field="nr_inmatriculare",
|
||||
cauza="Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.",
|
||||
))
|
||||
|
||||
# --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti ---
|
||||
raw_data = str(content.get("data_prestatie") or "").strip()
|
||||
try:
|
||||
d = date.fromisoformat(raw_data)
|
||||
except ValueError:
|
||||
errors.append({"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
|
||||
if d is not None:
|
||||
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():
|
||||
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) ---
|
||||
odo_final = _parse_int(content.get("odometru_final"))
|
||||
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 ---
|
||||
codes = _codes(content.get("prestatii"))
|
||||
@@ -101,26 +121,43 @@ def validate_prezentare(content: dict) -> list[dict]:
|
||||
raw_initial = content.get("odometru_initial")
|
||||
has_initial = str(raw_initial or "").strip() != ""
|
||||
if needs_initial and not has_initial:
|
||||
errors.append({
|
||||
"field": "odometru_initial",
|
||||
"message": "odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
|
||||
})
|
||||
errors.append(_eroare(
|
||||
"ODOMETRU_INITIAL_LIPSA",
|
||||
field="odometru_initial",
|
||||
cauza="odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
|
||||
))
|
||||
if has_initial:
|
||||
odo_initial = _parse_int(raw_initial)
|
||||
if odo_initial is None:
|
||||
errors.append({"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:
|
||||
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 ---
|
||||
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 ---
|
||||
b64 = content.get("b64_image")
|
||||
if 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
|
||||
|
||||
|
||||
@@ -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"
|
||||
if "auto_send" in data:
|
||||
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()]
|
||||
return "; ".join(parti)[:200]
|
||||
|
||||
@@ -195,6 +197,102 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,6 +23,7 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from .. import errors as _errors
|
||||
from ..auth import rotate_api_key
|
||||
from ..payload_view import prezentare_din_payload
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
@@ -33,6 +34,7 @@ from .labels import (
|
||||
eticheta_worker,
|
||||
format_data_rar,
|
||||
motiv_uman,
|
||||
parse_erori,
|
||||
)
|
||||
from ..web.session import require_login
|
||||
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"])
|
||||
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")
|
||||
|
||||
@@ -604,6 +608,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
||||
"rar_status_code": row["rar_status_code"],
|
||||
"rar_error": row["rar_error"],
|
||||
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
||||
"erori_3n": parse_erori(row["rar_error"]),
|
||||
"retry_count": row["retry_count"],
|
||||
"created_at": format_data_rar(row["created_at"]),
|
||||
"updated_at": format_data_rar(row["updated_at"]),
|
||||
@@ -1279,13 +1284,25 @@ async def web_upload_import(
|
||||
except MultipleSheets as ms:
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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()
|
||||
try:
|
||||
@@ -1370,6 +1387,52 @@ async def web_save_mapare_coloane(
|
||||
account_id = require_login(request)
|
||||
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()
|
||||
|
||||
# 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">
|
||||
{% set pas = 2 %}{% include '_stepper.html' %}
|
||||
{% from '_eroare.html' import card_erori %}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||
Mapare coloane —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</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;"
|
||||
{% if error %}role="alert"{% endif %}>
|
||||
{{ message }}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
|
||||
{% if editing %}
|
||||
{%- 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">
|
||||
<td colspan="10" style="background:rgba(91,141,239,.06);">
|
||||
<form class="rand-editare"
|
||||
@@ -50,6 +51,9 @@
|
||||
{% if err_map.get(nume) %}
|
||||
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
|
||||
{% endif %}
|
||||
{% if fix_map.get(nume) %}
|
||||
<span class="camp-fix">{{ fix_map.get(nume) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -83,13 +87,23 @@
|
||||
})();
|
||||
</script>
|
||||
{% 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 }}"
|
||||
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>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}</td>
|
||||
<td>{{ res.get('nr_inmatriculare') or '' }}</td>
|
||||
<td>{{ res.get('data_prestatie') or '' }}</td>
|
||||
<td>{{ res.get('odometru_final') or '' }}</td>
|
||||
<td>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}
|
||||
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
|
||||
</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><span class="pill s-{{ status }}">{{ status }}</span></td>
|
||||
<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 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>
|
||||
@@ -27,7 +28,11 @@
|
||||
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</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 class="muted" style="font-size:12px;">Motiv</div>
|
||||
<div>{{ motiv }}</div>
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||
{# 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). #}
|
||||
{% from '_eroare.html' import card_erori %}
|
||||
<div class="card" style="border-color:var(--accent);">
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||
{% 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;"
|
||||
role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -103,6 +103,19 @@
|
||||
border-color:var(--line); border-bottom-color:var(--card); }
|
||||
.tab-panel { min-height:120px; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -34,6 +34,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import errors
|
||||
from ..config import Settings, get_settings, load_test_credentials
|
||||
from ..crypto import decrypt_creds
|
||||
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"
|
||||
except RarError as exc:
|
||||
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)
|
||||
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True)
|
||||
return "needs_data"
|
||||
@@ -406,7 +414,8 @@ def run() -> int:
|
||||
token = sessions.get_token(conn, account_id, creds)
|
||||
except RarAuthError as exc:
|
||||
# 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)
|
||||
continue
|
||||
|
||||
|
||||
Reference in New Issue
Block a user