feat(web): dashboard compact — import pe Acasa, status cu bife, Trimiteri lizibile, Mapari complete (3.5)

Acasa = ecran de import (tab Import scos, ?tab=import->Acasa). Bara status
compacta pe 2 randuri cu bife accesibile (glife + text) + data formatata.
'Coada'->'Trimiteri': coloane RO, stare umana, detaliu la click in panou
dedicat. Mapari pe 3 sectiuni (de rezolvat / op salvate / formate coloane),
Cont doar cheie+creds. Filtrare Trimiteri, corectie inline needs_data cu
re-enqueue + detectie coliziune idempotency, badge contoare pe tab-uri.
Helper pur partajat payload_view.py (web + GET /v1/prezentari).
Backend trimitere (worker/idempotenta/mapping/schema) neatins. 483 teste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-19 08:56:45 +00:00
parent d10e9db998
commit d7ba1195d4
29 changed files with 3241 additions and 233 deletions

View File

@@ -34,6 +34,7 @@ from ...mapping import (
save_mapping,
)
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
from ...payload_view import prezentare_din_payload
from ...validation import validate_prezentare
router = APIRouter(prefix="/v1", tags=["v1"])
@@ -139,19 +140,31 @@ def list_prezentari(
conn = get_connection()
try:
scope_sql, scope_params = account_scope_clause(account_id)
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
# sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem.
cols = (
"id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json"
)
if status:
rows = conn.execute(
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?",
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
f"ORDER BY id DESC LIMIT ?",
scope_params + [status, limit],
).fetchall()
else:
rows = conn.execute(
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
scope_params + [limit],
).fetchall()
return {"submissions": [dict(r) for r in rows]}
out = []
for r in rows:
d = dict(r)
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
# payload_json brut nu se intoarce in raspuns.
d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None))
out.append(d)
return {"submissions": out}
finally:
conn.close()

112
app/payload_view.py Normal file
View File

@@ -0,0 +1,112 @@
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5).
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON
plaintext, vezi `submissions.payload_json`) sau un dict deja parsat.
Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza
la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
`nr_inmatriculare` vs `numar`/`numarInmatriculare`; coercion Excel pe odometru/VIN).
"""
from __future__ import annotations
import json
from typing import Any
EMPTY = ""
def _clean_str(value: Any) -> str:
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
if value is None:
return ""
return str(value).strip()
def _clean_odometru(value: Any) -> str:
"""Odometru afisat curat: strip '.0' din coercion Excel (123456.0 -> 123456)."""
s = _clean_str(value)
if "." in s:
before, after = s.split(".", 1)
if after == "0" and before.lstrip("-").isdigit():
return before
return s
def _vin_scurt(vin: str) -> str:
"""Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu).
VIN are 17 caractere; in tabel aratam ultimele 6 cu prefix elipsa ca sa
incapa fara sa ascundem complet identitatea vehiculului.
"""
if not vin:
return ""
if len(vin) <= 8:
return vin
return "" + vin[-6:]
def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
if isinstance(prestatii, list):
for item in prestatii:
if isinstance(item, dict):
return item
return {}
def _payload_dict(payload: str | dict | None) -> dict[str, Any]:
"""Normalizeaza intrarea la dict. JSON invalid / tip neasteptat -> {}."""
if payload is None:
return {}
if isinstance(payload, dict):
return payload
if isinstance(payload, str):
try:
data = json.loads(payload)
except (ValueError, TypeError):
return {}
return data if isinstance(data, dict) else {}
return {}
def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"""Campuri afisabile dintr-un payload de submission.
Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc):
vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR
(`cod_prestatie`) sau, in lipsa, codul intern (`cod_op_service`).
"""
data = _payload_dict(payload)
vin = _clean_str(data.get("vin"))
nr = _clean_str(
data.get("nr_inmatriculare")
or data.get("numar")
or data.get("numarInmatriculare")
)
odo = _clean_odometru(
data.get("odometru_final")
if data.get("odometru_final") is not None
else data.get("odometru")
)
data_prest = _clean_str(data.get("data_prestatie") or data.get("dataPrestatie"))
item = _prima_prestatie(data.get("prestatii"))
cod = _clean_str(item.get("cod_prestatie") or item.get("cod_op_service"))
denumire = _clean_str(item.get("denumire"))
operatie = denumire or cod
return {
"vehicul_nr": nr or EMPTY,
"vin": vin or EMPTY,
"vin_scurt": _vin_scurt(vin) or EMPTY,
"operatie": operatie or EMPTY,
"data_prestatie": data_prest or EMPTY,
"odometru": odo or EMPTY,
"cod": cod or EMPTY,
}

View File

@@ -6,6 +6,8 @@ Functii pure: fara DB, fara request. Usor de testat unitar si de importat in tem
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
"""
import json
from datetime import datetime
from typing import Tuple
# ---------------------------------------------------------------------------
@@ -119,6 +121,80 @@ def eticheta_rar(stare: str) -> Eticheta:
)
# ---------------------------------------------------------------------------
# Format data RAR (US-001, PRD 3.5)
# ---------------------------------------------------------------------------
def format_data_rar(raw: object) -> str:
"""Formateaza un timestamp ISO ca `dd.mm.yyyy hh24:mi:ss` (ora romaneasca).
- Valoare lipsa (None / "") -> "".
- ISO valid (cu sau fara timezone / 'Z' / microsecunde) -> data formatata,
fara fractiuni de secunda.
- Format invalid -> fallback grijuliu: intoarce stringul brut (nu arunca),
ca operatorul sa vada totusi ceva, nu o pagina rupta.
"""
if raw is None:
return ""
s = str(raw).strip()
if not s:
return ""
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
try:
dt = datetime.fromisoformat(iso)
except (ValueError, TypeError):
return s
return dt.strftime("%d.%m.%Y %H:%M:%S")
# ---------------------------------------------------------------------------
# Motiv uman din rar_error (US-004, PRD 3.5)
# ---------------------------------------------------------------------------
def motiv_uman(status: str, rar_error: object) -> str:
"""Transforma `rar_error` (JSON tehnic) intr-un motiv lizibil pentru coloana Motiv.
Formele intalnite (vezi router.py / mapping.py):
- validare continut: list[{field, message}] -> mesajele concatenate.
- operatie nemapata: {"unmapped": [{cod_op_service, denumire}]}.
- auto-send oprit: {"auto_send": "..."}.
- eroare RAR: text simplu sau dict generic.
Fara rar_error -> "". Nu arunca niciodata (degradeaza la text brut trunchiat).
"""
if not rar_error:
return ""
raw = rar_error if isinstance(rar_error, str) else str(rar_error)
try:
data = json.loads(raw)
except (ValueError, TypeError):
return raw[:160]
if isinstance(data, dict):
if "unmapped" in data:
ops = data.get("unmapped") or []
nume = ", ".join(
(o.get("cod_op_service") or "") for o in ops if isinstance(o, dict)
).strip(", ")
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)"
parti = [f"{k}: {v}" for k, v in data.items()]
return "; ".join(parti)[:200]
if isinstance(data, list):
msgs: list[str] = []
for e in data:
if isinstance(e, dict):
msgs.append(
str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values()))
)
else:
msgs.append(str(e))
return "; ".join(m for m in msgs if m)[:200]
return str(data)[:160]
# ---------------------------------------------------------------------------
# Constante auxiliare (microcopy fix, fara logica)
# ---------------------------------------------------------------------------

View File

@@ -13,22 +13,26 @@ from __future__ import annotations
import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..auth import rotate_api_key
from ..payload_view import prezentare_din_payload
from ..web.csrf import get_csrf_token, verify_csrf
from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
eticheta_rar,
eticheta_stare,
eticheta_worker,
format_data_rar,
motiv_uman,
)
from ..web.session import require_login
from ..api.v1.import_router import (
@@ -43,11 +47,14 @@ from ..config import get_settings
from ..crypto import decrypt_creds, encrypt_creds
from ..db import get_connection, read_heartbeat
from ..idempotency import build_key, canonicalize_row
from ..validation import validate_prezentare
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
from ..users import is_account_admin
from ..mapping import (
DEFAULT_ACCOUNT_ID,
account_or_default,
account_scope_clause,
has_no_auto_send,
load_mapping_meta,
load_nomenclator,
pending_unmapped,
@@ -121,7 +128,9 @@ def _rar_state(hb, worker_alive: bool) -> str:
return "indisponibil?" if age > 108000 else "ok"
_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"}
# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
_TABS_VALIDE = {"acasa", "coada", "mapari", "cont", "nomenclator"}
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
@@ -158,13 +167,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
"are_creds": are_creds,
"are_trimiteri": are_trimiteri,
"are_cheie_folosita": are_cheie_folosita,
# US-002: Acasa include caseta de upload -> are nevoie de csrf_token
"csrf_token": get_csrf_token(request),
}
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
"""Randeaza panoul Acasa ca string HTML."""
if conn is None:
return templates.get_template("_acasa.html").render({"request": request})
return templates.get_template("_acasa.html").render(
{"request": request, "csrf_token": get_csrf_token(request)}
)
ctx = _get_acasa_context(request, conn, account_id)
return templates.get_template("_acasa.html").render(ctx)
@@ -183,10 +196,12 @@ def _render_panel_coada(request: Request) -> str:
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Mapari ca string HTML."""
"""Randeaza panoul Mapari ca string HTML (3 sectiuni: de rezolvat / op salvate / formate)."""
return templates.get_template("_mapari.html").render({
"request": request,
"pending": pending_unmapped(conn, account_id),
"saved_mappings": _load_saved_op_mappings(conn, account_id),
"column_formats": _load_column_formats(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": None,
"csrf_token": get_csrf_token(request),
@@ -251,12 +266,19 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
conn = get_connection()
try:
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari, blocate -> Trimiteri.
counts = _status_counts(conn, account_id)
badges = {
"mapari": counts.get("needs_mapping", 0),
"coada": sum(counts.get(s, 0) for s in _BLOCKED),
}
ctx = {
"request": request,
"rar_env": get_settings().rar_env,
"version": __version__,
"active_tab": active_tab,
"panel_html": panel_html,
"badges": badges,
"is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request),
}
@@ -355,16 +377,22 @@ def fragment_status(request: Request) -> HTMLResponse:
# Etichete umane pre-calculate (nu logica in template)
worker_lbl = eticheta_worker(worker_alive)
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state)
rar_ok = rar_state == "ok"
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_status.html", {
"request": request,
"worker_lbl": worker_lbl,
"rar_lbl": rar_lbl,
# Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare
"worker_ok": worker_alive,
"rar_ok": rar_ok,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
"last_login": hb["last_rar_login_ok"] if hb else None,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
"counts_queued": counts.get("queued", 0),
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"account_active": _account_active(conn, account_id),
})
@@ -372,23 +400,376 @@ def fragment_status(request: Request) -> HTMLResponse:
conn.close()
def _is_iso_date(value: object) -> bool:
"""True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect)."""
s = str(value or "").strip()
if len(s) != 10:
return False
try:
datetime.strptime(s, "%Y-%m-%d")
return True
except (ValueError, TypeError):
return False
def _submission_row_view(r) -> dict:
"""Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004)."""
eticheta = eticheta_stare(r["status"])
return {
"id": r["id"],
"status": r["status"],
"stare_text": eticheta[0],
"stare_css": eticheta[2],
"prez": prezentare_din_payload(r["payload_json"]),
"id_prezentare": r["id_prezentare"],
"updated_at": format_data_rar(r["updated_at"]),
"motiv": motiv_uman(r["status"], r["rar_error"]),
}
@router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse:
def fragment_submissions(
request: Request,
status: str | None = None,
vehicul: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
) -> HTMLResponse:
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009).
Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status);
filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie
se face dupa parsarea payload_json in Python (plafon perf notat — eng review).
"""
account_id = require_login(request)
status = (status or "").strip() or None
vehicul_q = (vehicul or "").strip().upper() or None
data_de = (data_de or "").strip() or None
data_pana = (data_pana or "").strip() or None
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
conn = get_connection()
try:
scope_sql, scope_params = account_scope_clause(account_id)
where = [scope_sql]
params: list = list(scope_params)
if status:
where.append("status=?")
params.append(status)
# Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am
# taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care
# potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam
# afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review).
limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200"
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
"updated_at, payload_json FROM submissions "
f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}",
params,
).fetchall()
view = []
for r in rows:
v = _submission_row_view(r)
prez = v["prez"]
if vehicul_q:
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
if vehicul_q not in hay:
continue
if data_de or data_pana:
d = prez["data_prestatie"]
# Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string
# ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru.
if not _is_iso_date(d):
continue
if data_de and d < data_de:
continue
if data_pana and d > data_pana:
continue
view.append(v)
if len(view) >= 200:
break
return templates.TemplateResponse("_submissions.html", {
"request": request,
"rows": view,
"filtru_activ": filtru_activ,
})
finally:
conn.close()
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
_CORECTABILE = ("needs_data", "needs_mapping")
def _payload_form_values(payload_json) -> dict:
"""Valori brute pentru prefill-ul formularului de corectie (US-010)."""
try:
data = json.loads(payload_json) if payload_json else {}
if not isinstance(data, dict):
data = {}
except (ValueError, TypeError):
data = {}
return {
"form_vin": data.get("vin") or "",
"form_nr": data.get("nr_inmatriculare") or "",
"form_data": data.get("data_prestatie") or "",
"form_odo_final": data.get("odometru_final") or "",
"form_odo_initial": data.get("odometru_initial") or "",
}
def _detaliu_ctx(request: Request, row, *, message: str | None = None,
error: bool = False, corectie_errors: list | None = None) -> dict:
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission."""
eticheta = eticheta_stare(row["status"])
ctx = {
"request": request,
"csrf_token": get_csrf_token(request),
"id": row["id"],
"status": row["status"],
"stare_text": eticheta[0],
"stare_css": eticheta[2],
"stare_subtext": eticheta[1],
"prez": prezentare_din_payload(row["payload_json"]),
"id_prezentare": row["id_prezentare"],
"rar_status_code": row["rar_status_code"],
"rar_error": row["rar_error"],
"motiv": motiv_uman(row["status"], row["rar_error"]),
"retry_count": row["retry_count"],
"created_at": format_data_rar(row["created_at"]),
"updated_at": format_data_rar(row["updated_at"]),
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
"editabil": row["status"] in _CORECTABILE,
"corectie_msg": message,
"corectie_error": error,
"corectie_errors": corectie_errors or [],
}
ctx.update(_payload_form_values(row["payload_json"]))
return ctx
def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3)."""
scope_sql, scope_params = account_scope_clause(account_id)
return conn.execute(
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
[submission_id] + scope_params,
).fetchone()
# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e
# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload).
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
(acelasi mesaj, nu confirmam existenta — vezi B3/router.py).
"""
account_id = require_login(request)
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
"FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"ORDER BY id DESC LIMIT 100",
(account_id, account_id),
).fetchall()
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
row = _fetch_submission_scoped(conn, account_id, submission_id)
if not row:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
return templates.TemplateResponse("_trimitere_detaliu.html", _detaliu_ctx(request, row))
finally:
conn.close()
@router.post("/trimitere/{submission_id}/corecteaza", response_class=HTMLResponse)
async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Corectie inline pentru randuri ne-trimise blocate (needs_data/needs_mapping).
Re-valideaza (validation.py, fara reguli noi), reconstruieste payload_json,
recalculeaza idempotency_key (canonicalize -> build_key, ca la enqueue) si
re-pune randul in 'queued' (re-enqueue). NU atinge worker-ul / masina de stari.
Randurile sent/sending/queued/error raman read-only (gard explicit -> 403).
Coliziune de idempotency detectata INAINTE de UPDATE (fara 500/duplicat).
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
row = _fetch_submission_scoped(conn, account_id, submission_id)
if not row:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
# Gard read-only: doar randurile blocate ne-trimise sunt corectabile.
if row["status"] not in _CORECTABILE:
raise HTTPException(status_code=403, detail="trimitere read-only (deja procesata)")
try:
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
if not isinstance(content, dict):
content = {}
except (ValueError, TypeError):
content = {}
# Aplica DOAR campurile prezente in form (negoale).
for camp in ("vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "odometru_initial"):
val = form.get(camp)
if isinstance(val, str) and val.strip() != "":
content[camp] = val.strip()
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
content["prestatii"] = resolved
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
canon = canonicalize_row(content)
content.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
payload_json = json.dumps(content, ensure_ascii=False)
if unmapped:
conn.execute(
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), row["id"]),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, error=True,
message="Lipseste inca un cod RAR — rezolva operatia in tab-ul Mapari."),
)
if has_no_auto_send(resolved, mapping_meta):
conn.execute(
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json,
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
ensure_ascii=False),
row["id"]),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, error=True,
message="Cod cu auto-send oprit — confirma manual din tab-ul Mapari."),
)
errors = validate_prezentare(content)
if errors:
# Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp.
conn.execute(
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json, json.dumps(errors, ensure_ascii=False), row["id"]),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, message="Mai sunt campuri invalide — vezi mai jos.",
error=True, corectie_errors=errors),
)
# Valid: recalculeaza cheia. Coliziune cu alt rand -> opreste, fara 500/duplicat.
new_key = build_key(account_id, canon)
if new_key != row["idempotency_key"]:
dup = conn.execute(
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
(new_key, row["id"]),
).fetchone()
if dup:
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row2,
message=f"Exista deja o trimitere identica (rand #{dup['id']}). Corectia a fost oprita.",
error=True,
),
)
try:
conn.execute(
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
"updated_at=datetime('now') WHERE id=?",
(new_key, payload_json, row["id"]),
)
except sqlite3.IntegrityError:
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
# pre-check-ul a trecut dar alt rand a primit cheia intre timp. Fara 500.
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, error=True,
message="Exista deja o trimitere identica. Corectia a fost oprita."),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
)
finally:
conn.close()
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
"FROM operations_mapping o "
"LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie "
"WHERE o.account_id=? ORDER BY o.cod_op_service",
(acct,),
).fetchall()
return [
{
"id": r["id"],
"cod_op_service": r["cod_op_service"],
"cod_prestatie": r["cod_prestatie"],
"auto_send": bool(r["auto_send"]),
"nume_prestatie": r["nume_prestatie"],
}
for r in rows
]
def _load_column_formats(conn, account_id: int) -> list[dict]:
"""Formate de coloane salvate (column_mappings) ale contului (US-006).
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT id, signature_coloane, json_mapare, format_data, created_at "
"FROM column_mappings WHERE account_id=? ORDER BY id DESC",
(acct,),
).fetchall()
out: list[dict] = []
for r in rows:
try:
jm = json.loads(r["json_mapare"]) if r["json_mapare"] else {}
except (ValueError, TypeError):
jm = {}
out.append({
"id": r["id"],
"signature_coloane": r["signature_coloane"],
"mappings": jm,
"columns": list(jm.keys()),
"format_data": r["format_data"],
"created_at": r["created_at"],
})
return out
def _render_mapari(
request: Request, conn, account_id: int, *, message: str | None = None
) -> HTMLResponse:
@@ -397,6 +778,8 @@ def _render_mapari(
{
"request": request,
"pending": pending_unmapped(conn, account_id),
"saved_mappings": _load_saved_op_mappings(conn, account_id),
"column_formats": _load_column_formats(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": message,
"csrf_token": get_csrf_token(request),
@@ -447,6 +830,146 @@ def post_mapare(
conn.close()
# =========================================================================== #
# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere #
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
# =========================================================================== #
@router.post("/mapari/salvate", response_class=HTMLResponse)
def post_editeaza_mapare_salvata(
request: Request,
cod_op_service: str = Form(...),
cod_prestatie: str = Form(...),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Editeaza o mapare op->cod salvata (cod RAR / auto-send) + re-rezolva blocatele.
Scoped pe contul sesiunii (save_mapping foloseste account_or_default(sesiune) —
cross-account imposibil). Respinge cod inexistent in nomenclator.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
cod = cod_prestatie.strip().upper()
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone()
if not exists:
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = (
f"Mapare actualizata: {cod_op_service.strip()} -> {cod}. "
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, account_id, message=msg)
finally:
conn.close()
@router.post("/mapari/salvate/sterge", response_class=HTMLResponse)
def post_sterge_mapare_salvata(
request: Request,
cod_op_service: str = Form(...),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Sterge o mapare op->cod salvata. Scoped pe contul sesiunii."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
conn.execute(
"DELETE FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(acct, cod_op_service.strip()),
)
return _render_mapari(
request, conn, account_id,
message=f"Mapare stearsa: {cod_op_service.strip()}.",
)
finally:
conn.close()
# =========================================================================== #
# US-006 — Formate de coloane salvate: editare format data + stergere #
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
# =========================================================================== #
@router.post("/formate-coloane/editeaza", response_class=HTMLResponse)
async def post_editeaza_format_coloane(
request: Request,
format_id: int = Form(...),
format_data: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Editeaza un format de coloane salvat (format data). Scoped pe cont prin id+account_id.
json_mapare optional (string JSON valid) — daca e dat, inlocuieste maparea coloanelor.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
form = await request.form()
json_mapare_raw = form.get("json_mapare")
conn = get_connection()
try:
owned = conn.execute(
"SELECT 1 FROM column_mappings WHERE id=? AND account_id=?",
(format_id, acct),
).fetchone()
if not owned:
return _render_mapari(
request, conn, account_id, message="Format inexistent sau inaccesibil."
)
fmt = (format_data or "").strip() or None
if isinstance(json_mapare_raw, str) and json_mapare_raw.strip():
try:
jm = json.loads(json_mapare_raw)
if not isinstance(jm, dict):
raise ValueError
except (ValueError, TypeError):
return _render_mapari(
request, conn, account_id, message="Mapare coloane invalida (JSON)."
)
conn.execute(
"UPDATE column_mappings SET json_mapare=?, format_data=? WHERE id=? AND account_id=?",
(json.dumps(jm, ensure_ascii=False), fmt, format_id, acct),
)
else:
conn.execute(
"UPDATE column_mappings SET format_data=? WHERE id=? AND account_id=?",
(fmt, format_id, acct),
)
return _render_mapari(request, conn, account_id, message="Format de coloane actualizat.")
finally:
conn.close()
@router.post("/formate-coloane/sterge", response_class=HTMLResponse)
def post_sterge_format_coloane(
request: Request,
format_id: int = Form(...),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Sterge un format de coloane salvat. Scoped pe cont prin id+account_id."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
conn.execute(
"DELETE FROM column_mappings WHERE id=? AND account_id=?",
(format_id, acct),
)
return _render_mapari(request, conn, account_id, message="Format de coloane sters.")
finally:
conn.close()
# =========================================================================== #
# Import UI (U5) — upload → mapare coloane → preview → confirmare #
# Consuma helper-e din import_router fara a edita fisierul backend. #

View File

@@ -1,81 +1,56 @@
<div id="acasa-section">
{% set toti_esentiali = are_creds and are_trimiteri %}
{# === Centru de greutate: caseta de upload (importul e operatia principala) === #}
{% include '_upload.html' %}
{% if toti_esentiali %}
{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #}
<div class="ghid-complet" style="margin-bottom:12px; font-size:13px; color:var(--muted);">
Totul e configurat —
<a href="/?tab=coada">vezi coada</a>
</div>
{% else %}
{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #}
<div class="card" style="margin-bottom:16px;">
<h2 style="font-size:15px; margin:0 0 12px;">Primii pasi</h2>
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px;">
{# === Subordonat: primii pasi pe un singur rand compact === #}
{% set toti_esentiali = are_creds and are_trimiteri %}
{% if not toti_esentiali %}
<div class="card" style="margin-top:14px; padding:12px 16px;">
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:center; font-size:13px;">
<span class="muted" style="font-weight:600;">Primii pasi:</span>
{# Pas 1: Conecteaza contul RAR (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_creds %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Conecteaza-ti contul RAR</a>
<span class="muted" style="font-size:12px; display:block;">
Email + parola portal AUTOPASS RAR
</span>
{# Pas 1: Cont RAR (esential) #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_creds %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="muted" aria-hidden="true">&#9675;</span>
{% endif %}
<a href="/?tab=cont">Cont RAR</a>
</span>
</li>
{# Pas 2: Cheie API (optional) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_cheie_folosita %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Ia-ti cheia API</a>
<span class="muted" style="font-size:12px; display:block;">
<em>Optional</em> — doar daca trimiti din soft propriu prin API
</span>
{# Pas 2: Cheie API (optional) #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_cheie_folosita %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="muted" aria-hidden="true">&#9675;</span>
{% endif %}
<a href="/?tab=cont">Cheie API</a>
<span class="muted">(optional)</span>
</span>
</li>
{# Pas 3: Import primul fisier (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_trimiteri %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=import">Importa primul fisier</a>
<span class="muted" style="font-size:12px; display:block;">
Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR
</span>
{# Pas 3: Import (esential) — marcat ca pas curent #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_trimiteri %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">&#9679;</span>
{% endif %}
<strong>Import</strong> <span class="muted">(incarca fisierul sus)</span>
</span>
</li>
</ul>
</div>
{% endif %}
{# Rezumat si scurtaturi rapide (mereu vizibile) #}
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Bun venit la Gateway RAR AUTOPASS</h2>
<p class="muted" style="margin:0 0 10px; font-size:13px;">
Importa fisiere din tab-ul <strong><a href="/?tab=import">Import</a></strong>,
urmareste coada in tab-ul <strong><a href="/?tab=coada">Coada</a></strong>
si rezolva mapari lipsa in tab-ul <strong><a href="/?tab=mapari">Mapari</a></strong>.
</p>
<div style="display:flex; gap:12px; flex-wrap:wrap; font-size:13px;">
<a href="/?tab=coada" class="cardlink">Coada submissions</a>
<a href="/?tab=import" class="cardlink">Import fisier nou</a>
<a href="/?tab=mapari" class="cardlink">Mapari operatii</a>
</div>
</div>
{% endif %}
{# === Subordonat: ajutor rapid pe un rand discret === #}
<div style="margin-top:10px; font-size:13px; color:var(--muted);
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
<span>Ajutor:</span>
<a href="/?tab=coada">Trimiteri</a>
<a href="/?tab=mapari">Mapari</a>
<a href="/?tab=nomenclator">Coduri RAR</a>
</div>
</div>
</div>

View File

@@ -1,14 +1,55 @@
<div id="coada-section">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
<h2 style="font-size:15px; margin:0;">Trimiteri catre RAR</h2>
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
</span>
</div>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include -->
<form id="filtre-trimiteri"
hx-get="/_fragments/submissions"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
<select id="f-status" name="status">
<option value="">toate</option>
<option value="queued">in asteptare</option>
<option value="sent">declarate la RAR</option>
<option value="needs_mapping">lipsa cod</option>
<option value="needs_data">date incomplete</option>
<option value="error">eroare</option>
<option value="sending">se trimite</option>
</select>
</div>
<div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label>
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;">
</div>
<div>
<label for="f-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
<input id="f-data-de" type="date" name="data_de">
</div>
<div>
<label for="f-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="f-data-pana" type="date" name="data_pana">
</div>
<button type="submit">Filtreaza</button>
</form>
<div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-trigger="load, every 10s"
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
<!-- Panou dedicat pentru detaliul trimiterii (NU inline in tabel: poll-ul de 10s
din tabel ar sterge un expand inline). Gol pana la click pe un rand. -->
<div id="trimitere-detaliu"></div>
</div>

View File

@@ -1,61 +1,182 @@
<div id="mapari-section" class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Mapari de rezolvat</h2>
<div id="mapari-section">
{% if message %}
<div class="flash">{{ message }}</div>
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
{% endif %}
{% if not pending %}
<div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=import">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
</p>
<!-- ============================================================ -->
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2>
{% for e in pending %}
{% set top = e.suggestions[0] if e.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
{% if not pending %}
<div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
</p>
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
{% for e in pending %}
{% set top = e.suggestions[0] if e.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" required>
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<select name="cod_prestatie" required>
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true" checked> auto-send</label>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true" checked> auto-send</label>
</div>
<div class="mapcol">
<button type="submit">Salveaza</button>
<div class="mapcol">
<button type="submit">Salveaza</button>
</div>
</form>
{% endfor %}
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 2: Mapari operatii salvate (operations_mapping) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Mapari operatii salvate</h2>
{% if not saved_mappings %}
<div class="empty">
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div>
</form>
{% endfor %}
{% endif %}
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau auto-send si salveaza;
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</p>
{% for m in saved_mappings %}
<form class="maprow" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ m.cod_op_service }}</strong></div>
<div class="muted" style="font-size:12px;">
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
</div>
</div>
<div class="mapcol">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ m.cod_op_service }}">
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true"
{% if m.auto_send %}checked{% endif %}> auto-send</label>
</div>
<div class="mapcol" style="display:flex; gap:6px;">
<button type="submit">Salveaza</button>
</div>
<div class="mapcol">
<button type="submit"
hx-post="/mapari/salvate/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi maparea pentru {{ m.cod_op_service }}?"
style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</div>
</form>
{% endfor %}
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
{% if not column_formats %}
<div class="empty">
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
se retine aici si se reaplica automat la fisierele cu acelasi antet.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie).
</p>
{% for f in column_formats %}
<div class="maprow" style="align-items:flex-start;">
<div class="mapcol grow">
<div style="font-size:13px; margin-bottom:4px;">
<strong>{{ f.columns | length }} coloane recunoscute</strong>
{% if f.format_data %}
<span class="pill" title="format data">data: {{ f.format_data }}</span>
{% endif %}
</div>
<div class="muted" style="font-size:12px;">
{% for col, camp in f.mappings.items() %}
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
{% endfor %}
</div>
</div>
<form class="mapcol" style="display:flex; gap:6px; align-items:center;"
hx-post="/formate-coloane/editeaza" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
<button type="submit">Salveaza data</button>
</form>
<form class="mapcol"
hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</div>
{% endfor %}
{% endif %}
</div>
</div>

View File

@@ -156,12 +156,15 @@
{% elif row.flags %}
{{ row.flags[0] }}
{% elif row.errors %}
{%- set e = row.errors[0] -%}
{%- if e is mapping -%}
{{ e.values() | list | first }}
{%- else -%}
{{ e }}
{%- endif -%}
{# US-008: arata MOTIVUL (mesajul de validare), nu numele campului #}
{%- for e in row.errors -%}
{%- if e is mapping -%}
{{ e.get('message') or e.get('msg') or (e.values() | list | first) }}
{%- else -%}
{{ e }}
{%- endif -%}
{%- if not loop.last %}; {% endif -%}
{%- endfor -%}
{% endif %}
</td>
<td style="text-align:center;">
@@ -242,7 +245,8 @@
</form>
<div style="padding:8px 0 4px;">
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a>
<a href="#" class="muted" style="font-size:13px;"
hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
</div>
</div>

View File

@@ -13,62 +13,52 @@
</div>
{% endif %}
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
<!-- Rand 1: doua bife binare + ultima autentificare -->
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;">
<!-- Starea worker (Trimitere automata) -->
<div>
<div class="muted" style="font-size:12px;">{{ worker_lbl[0] }}</div>
<div class="{{ worker_lbl[2] }}" title="{{ worker_lbl[1] }}">
{{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }}
</div>
{% if worker_lbl[1] %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ worker_lbl[1] }}</div>
{% endif %}
</div>
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #}
{% macro bifa(ok, text, tip) %}
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;">
{% if ok %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
<span class="s-sent">{{ text }}</span>
{% else %}
<span class="s-error" aria-hidden="true" style="font-weight:bold;">&#10007;</span>
<span class="s-error">{{ text }}</span>
{% endif %}
</span>
{% endmacro %}
<!-- Legatura RAR -->
<div>
<div class="muted" style="font-size:12px;">Legatura RAR</div>
<div class="{{ rar_lbl[2] }}" title="{{ rar_lbl[1] }}">
{{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }}
</div>
{% if rar_lbl[1] and rar_lbl[2] != 's-sent' %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ rar_lbl[1] }}</div>
{% endif %}
</div>
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }}
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }}
<!-- Ultima autentificare RAR -->
<div>
<div class="muted" style="font-size:12px;">{{ eticheta_ultima_auth }}</div>
<div>{{ last_login or '—' }}</div>
</div>
<!-- In asteptare (queued) -->
<div>
<div class="muted" style="font-size:12px;">In asteptare</div>
<div class="s-queued">{{ counts_queued }}</div>
</div>
<!-- Declarate la RAR (sent) -->
<div>
<div class="muted" style="font-size:12px;">Declarate la RAR</div>
<div class="s-sent">{{ counts_sent }}</div>
</div>
<span style="display:inline-flex; align-items:center; gap:6px;">
<span class="muted">{{ eticheta_ultima_auth }}:</span>
<span>{{ last_login }}</span>
</span>
</div>
<!-- Rand 2: contoare coada -->
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;">
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span>
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span>
<span><span class="muted">Blocate:</span>
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span>
</span>
</div>
<!-- Defalcare blocate pe motiv (doar daca exista) -->
{% if blocate_defalcat %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:12px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="display:flex; gap:16px; flex-wrap:wrap;">
{% for eticheta, n in blocate_defalcat %}
{% if n > 0 %}
<div>
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
<span class="muted" style="font-size:12px; margin-left:4px;">({{ n }})</span>
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span>
{% if eticheta[1] %}
<div class="muted" style="font-size:11px; max-width:200px;">{{ eticheta[1] }}</div>
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div>
{% endif %}
</div>
{% endif %}

View File

@@ -1,26 +1,55 @@
{% if rows %}
<div class="tablewrap">
<table>
<thead><tr><th>#</th><th>Stare</th><th>idPrezentare</th><th>HTTP RAR</th><th>Retry</th><th>Actualizat</th><th>Motiv</th></tr></thead>
<thead><tr>
<th>#</th>
<th>Stare</th>
<th>Vehicul</th>
<th>Operatie</th>
<th>Data prestatie</th>
<th>Nr. prezentare RAR</th>
<th>Actualizat</th>
<th>Motiv</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
<td><span class="pill s-{{ r.status }}">{{ r.status }}</span></td>
<tr id="trimitere-row-{{ r.id }}"
hx-get="/_fragments/trimitere/{{ r.id }}"
hx-target="#trimitere-detaliu"
hx-swap="innerHTML"
style="cursor:pointer;"
title="Click pentru detaliul complet">
<td class="muted">{{ r.id }}</td>
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
<td>
{{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
{% endif %}
</td>
<td>{{ r.prez.operatie }}</td>
<td>{{ r.prez.data_prestatie }}</td>
<td>{{ r.id_prezentare or '—' }}</td>
<td>{{ r.rar_status_code or '—' }}</td>
<td>{{ r.retry_count }}</td>
<td class="muted">{{ r.updated_at }}</td>
<td class="muted">{{ (r.rar_error or '')[:80] }}</td>
<td class="muted" style="white-space:normal; max-width:280px;">{{ r.motiv }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif filtru_activ %}
<div class="empty">
Nimic pe filtrul curent.
<a href="#"
onclick="var f=document.getElementById('filtre-trimiteri'); if(f) f.reset(); return true;"
hx-get="/_fragments/submissions" hx-target="#submissions-wrap" hx-swap="innerHTML">
sterge filtrele
</a>
</div>
{% else %}
<div class="empty">
Nicio trimitere inca —
<a href="/?tab=import">incepe cu Import</a>
<a href="/?tab=acasa">incepe cu un import</a>
sau trimite o prezentare prin API.
</div>
{% endif %}

View File

@@ -0,0 +1,98 @@
<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>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
<button type="button" style="margin-left:auto; background:var(--card); color:var(--muted); border-color:var(--line);"
onclick="document.getElementById('trimitere-detaliu').innerHTML='';">
Inchide
</button>
</div>
{% if stare_subtext %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% endif %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px 24px;">
<div><div class="muted" style="font-size:12px;">Numar inmatriculare</div><div>{{ prez.vehicul_nr }}</div></div>
<div><div class="muted" style="font-size:12px;">VIN (serie sasiu)</div><div style="word-break:break-all;">{{ prez.vin }}</div></div>
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }}</div></div>
<div><div class="muted" style="font-size:12px;">Cod RAR</div><div>{{ prez.cod }}</div></div>
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
<div><div class="muted" style="font-size:12px;">Nr. prezentare RAR</div><div>{{ id_prezentare or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Cod HTTP RAR</div><div>{{ rar_status_code or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Reincercari</div><div>{{ retry_count }}</div></div>
<div><div class="muted" style="font-size:12px;">Creat</div><div>{{ created_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Actualizat</div><div>{{ updated_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
</div>
{% if 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>
</div>
{% endif %}
{% if rar_error %}
<details style="margin-top:10px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">Mesaj tehnic RAR (integral)</summary>
<pre style="white-space:pre-wrap; word-break:break-all; font-size:12px; margin:6px 0 0; color:var(--muted);">{{ rar_error }}</pre>
</details>
{% endif %}
{# === Corectie inline (US-010): doar randuri ne-trimise blocate === #}
{% if editabil %}
{% set err_map = {} %}
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
{% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;"
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#trimitere-detaliu" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div>
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
<input id="c-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare }}"
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}"
{% if err_map.get(nume) %}aria-invalid="true"{% endif %}>
{% if err_map.get(nume) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
{% endif %}
</div>
{% endmacro %}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
{{ camp('vin', 'VIN (serie sasiu)', form_vin) }}
{{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', form_data) }}
{{ camp('odometru_final', 'Odometru final', form_odo_final) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
</div>
<div style="margin-top:10px;">
<button type="submit">Salveaza si re-pune in coada</button>
</div>
</form>
</div>
{% endif %}
</div>
<script>
(function() {
/* Vizibilitate (design review): scroll la panou + evidentiaza randul selectat. */
var panou = document.getElementById('trimitere-detaliu');
if (panou) panou.scrollIntoView({behavior: 'smooth', block: 'nearest'});
document.querySelectorAll('tr[id^="trimitere-row-"]').forEach(function(tr) {
tr.style.outline = '';
});
var rand = document.getElementById('trimitere-row-{{ id }}');
if (rand) rand.style.outline = '2px solid var(--accent)';
})();
</script>

View File

@@ -22,24 +22,25 @@
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{% set tabs = [
("acasa", "Acasa", "tab-acasa"),
("import", "Import", "tab-import"),
("coada", "Coada", "tab-coada"),
("coada", "Trimiteri", "tab-coada"),
("mapari", "Mapari", "tab-mapari"),
("cont", "Cont", "tab-cont"),
("nomenclator", "Nomenclator", "tab-nomenclator")
] %}
{% for tab_id, tab_label, tab_elem_id in tabs %}
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
<a id="{{ tab_elem_id }}"
role="tab"
href="/?tab={{ tab_id }}"
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
hx-get="/_fragments/{{ tab_id }}"
hx-target="#tab-panel"
hx-swap="innerHTML"
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}</a>
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}{% if badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ badge }}</span>{% endif %}</a>
{% endfor %}
</div>