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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user