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:
Claude Agent
2026-06-23 10:28:09 +00:00
parent b48501d8e4
commit 14e1c463f0
25 changed files with 2440 additions and 44 deletions

View File

@@ -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),
},
)

View File

@@ -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
View 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,
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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

View 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 %}

View File

@@ -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 }}

View File

@@ -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;">

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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