diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index ff1a02e..1244450 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -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), }, ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 9a51b5e..46a581d 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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: diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..587e9e6 --- /dev/null +++ b/app/errors.py @@ -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, + } diff --git a/app/mapping.py b/app/mapping.py index 2e8eb30..6de1038 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -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: diff --git a/app/validation.py b/app/validation.py index 83da623..ff66aba 100644 --- a/app/validation.py +++ b/app/validation.py @@ -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 diff --git a/app/web/labels.py b/app/web/labels.py index 1172e7a..47ba290 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -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) # --------------------------------------------------------------------------- diff --git a/app/web/routes.py b/app/web/routes.py index 04303a7..0750061 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -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 diff --git a/app/web/templates/_eroare.html b/app/web/templates/_eroare.html new file mode 100644 index 0000000..9902147 --- /dev/null +++ b/app/web/templates/_eroare.html @@ -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 %} +