"""Dashboard Jinja2 + HTMX (server-rendered, zero build). Schelet cu stari explicite: empty (coada goala), banner alerta blocate, worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator + export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review). U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite). Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica. Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX. """ from __future__ import annotations import hashlib import json import math import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse 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 from .labels import ( ETICHETA_ULTIMA_AUTENTIFICARE_RAR, eticheta_rar, eticheta_scurta, eticheta_stare, eticheta_worker, format_data_rar, motiv_uman, parse_erori, ) from ..web.session import require_login from ..api.v1.import_router import ( _already_sent_lookup, _build_idempotency_key, _CANONICAL_SYNONYMS, _fuzzy_suggest_column, _resolve_row_for_preview, _signature, apply_row_override, EDIT_FIELDS, ) from ..config import get_settings from ..crypto import decrypt_creds, encrypt_creds from ..db import get_connection, read_app_events, 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 ..submissions_admin import ( SubmissionNotFound, SubmissionStateConflict, delete_submission, requeue_submission, ) from ..mapping import ( DEFAULT_ACCOUNT_ID, _emite_text_rule_hits, account_or_default, account_scope_clause, delete_text_rule, has_no_auto_send, load_mapping_meta, load_nomenclator, load_nomenclator_codes, load_text_rules, normalize_for_match, pending_unmapped, reresolve_account, resolve_prestatii, save_mapping, save_text_rule, suggest_codes, text_rules_overlap, ) # Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5) _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") def _ctx(request: Request, **extra) -> dict: """Context de baza pentru template-uri cu formulare: include mereu csrf_token. Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8). """ return {"request": request, "csrf_token": get_csrf_token(request), **extra} def _status_counts(conn, account_id: int) -> dict[str, int]: rows = conn.execute( "SELECT status, COUNT(*) AS n FROM submissions " "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) " "GROUP BY status", (account_id, account_id), ).fetchall() return {r["status"]: int(r["n"]) for r in rows} def _trimiteri_versiune(conn, account_id: int) -> str: """Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent updated_at. Se schimba la orice insert/update/delete -> nudge-ul "Date noi" o compara fara a re-randa tabelul.""" row = conn.execute( "SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), '') AS m FROM submissions " "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL))", (account_id, account_id), ).fetchone() return f"{row['n']}:{row['m']}" def _account_active(conn, account_id: int) -> bool: """True daca contul e activ (sau legacy cu NULL/absent active).""" row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone() return bool(row["active"]) if row else True def _worker_alive(hb) -> bool: if hb is None or not hb["last_beat"]: return False try: last = datetime.fromisoformat(hb["last_beat"]) except ValueError: return False age = (datetime.now(timezone.utc) - last).total_seconds() return age <= get_settings().worker_heartbeat_stale_s def _rar_state(hb, worker_alive: bool) -> str: """Eticheta de disponibilitate RAR, derivata din ultimul login reusit. Nu interogam RAR live aici (dashboard-ul degradeaza la ultima stare cunoscuta a cozii). JWT TTL = 30h: un login mai vechi de atat inseamna ca nu mai stim sigur ca RAR raspunde -> "indisponibil?". Fara niciun login -> necunoscut. """ if not worker_alive: return "necunoscut (worker oprit)" last = hb["last_rar_login_ok"] if hb else None if not last: return "fara login reusit inca" try: age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds() except (ValueError, TypeError): return "necunoscut" return "indisponibil?" if age > 108000 else "ok" # 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. # US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa. # ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan. _TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"} def _get_acasa_context(request: Request, conn, account_id: int) -> dict: """Calculeaza contextul pentru panoul Acasa (US-005). Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1). """ from ..mapping import account_or_default acct = account_or_default(account_id) # Pas 1: are credentiale RAR configurate? row = conn.execute( "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) # Pas 3: are cel putin un submission (trimis sau in coada)? row_sub = conn.execute( "SELECT 1 FROM submissions " "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1", (acct, acct), ).fetchone() are_trimiteri = row_sub is not None # Pas 2 (optional): are cheie API activa? row_key = conn.execute( "SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,), ).fetchone() are_cheie_folosita = row_key is not None # US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul # sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut. counts = _status_counts(conn, account_id) blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) return { "request": request, "are_creds": are_creds, "are_trimiteri": are_trimiteri, "are_cheie_folosita": are_cheie_folosita, "blocate_total": blocate_total, # Pill-uri de filtrare a starii, randate in bara de filtre (nu in bara de status). "pills_categorii": _pills_categorii(counts), # Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor. "versiune_trimiteri": _trimiteri_versiune(conn, account_id), # 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, status: str | None = None) -> str: """Randeaza panoul Acasa ca string HTML. `status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end). """ if conn is None: return templates.get_template("_acasa.html").render( {"request": request, "csrf_token": get_csrf_token(request)} ) ctx = _get_acasa_context(request, conn, account_id) # `status or ""`: campul hidden de filtru ar randa literal "None" cu un None Python # (Jinja `default('')` inlocuieste doar undefined), trimitand status=None la poll. ctx["status_filtru"] = status or "" return templates.get_template("_acasa.html").render(ctx) def _render_panel_import(request: Request) -> str: """Randeaza panoul Import ca string HTML (include _upload.html).""" return templates.get_template("_upload.html").render({ "request": request, "csrf_token": get_csrf_token(request), }) def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str: """US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi.""" return _render_panel_acasa(request, conn, account_id) def _render_panel_mapari(request: Request, conn, account_id: int) -> str: """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), "text_rules": load_text_rules(conn, account_id), "nomenclator": load_nomenclator(conn), "message": None, "csrf_token": get_csrf_token(request), }) def _render_panel_cont(request: Request, conn, account_id: int) -> str: """Randeaza panoul Cont ca string HTML.""" from ..mapping import account_or_default acct = account_or_default(account_id) row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() are_creds = bool(row and row["rar_creds_enc"]) return templates.get_template("_cont.html").render({ "request": request, "csrf_token": get_csrf_token(request), "api_key": None, "are_creds": are_creds, "creds_mesaj": None, "creds_eroare": None, "rot_eroare": None, }) def _render_panel_nomenclator(request: Request, conn) -> str: """Randeaza panoul Nomenclator ca string HTML.""" rows = conn.execute( "SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie" ).fetchall() return templates.get_template("_nomenclator.html").render({ "request": request, "rows": rows, }) def _render_integrare(request: Request, conn, account_id: int) -> str: """Randeaza panoul Integrare ca string HTML (hub documentatie + exemple cod). Calculeaza are_cheie (chei API active pe cont) si are_creds (credentiale RAR configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj. """ from ..mapping import account_or_default from .integrare_examples import exemple as _exemple acct = account_or_default(account_id) row_creds = conn.execute( "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row_creds and row_creds["rar_creds_enc"]) row_key = conn.execute( "SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,) ).fetchone() are_cheie = row_key is not None base_url = str(request.base_url).rstrip("/") ex = _exemple(base_url, acct) csrf_token = get_csrf_token(request) return templates.get_template("_integrare.html").render({ "request": request, "account_id": acct, "base_url": base_url, "exemple": ex, "are_cheie": are_cheie, "are_creds": are_creds, "csrf_token": csrf_token, }) _JURNAL_PAGE_SIZE = 50 def _jurnal_context( request: Request, conn, account_id: int, *, tip: str | None = None, nivel: str | None = None, data_de: str | None = None, data_pana: str | None = None, cont: str | None = None, page: int = 0, ) -> dict: """Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope. Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5. """ admin = is_account_admin(conn, account_id) tip = (tip or "").strip() or None nivel = (nivel or "").strip() or None data_de = (data_de or "").strip() or None data_pana = (data_pana or "").strip() or None page = max(0, page) if admin: cont_filtru = None if cont and str(cont).strip(): try: cont_filtru = int(cont) except (ValueError, TypeError): cont_filtru = None scope_account = cont_filtru # None = toate conturile else: scope_account = account_or_default(account_id) offset = page * _JURNAL_PAGE_SIZE rows = read_app_events( conn, account_id=scope_account, tip=tip, nivel=nivel, date_from=data_de, date_to=data_pana, limit=_JURNAL_PAGE_SIZE + 1, offset=offset, ) has_more = len(rows) > _JURNAL_PAGE_SIZE rows = rows[:_JURNAL_PAGE_SIZE] evenimente = [] for r in rows: evenimente.append({ "ts": format_data_rar(r["ts"]), "sursa": r["sursa"], "tip": r["tip"], "nivel": r["nivel"], "account_id": r["account_id"], "cod": r["cod"], "mesaj": r["mesaj"], }) tipuri = [r["tip"] for r in conn.execute("SELECT DISTINCT tip FROM app_events ORDER BY tip").fetchall()] return { "request": request, "evenimente": evenimente, "tipuri": tipuri, "is_admin": admin, "f_tip": tip or "", "f_nivel": nivel or "", "f_data_de": data_de or "", "f_data_pana": data_pana or "", "f_cont": (cont or "") if admin else "", "page": page, "has_more": has_more, "prev_page": page - 1 if page > 0 else None, "next_page": page + 1 if has_more else None, } def _render_panel_jurnal(request: Request, conn, account_id: int) -> str: """Randeaza panoul Jurnal ca string HTML (US-006).""" return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id)) def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, status: str | None = None) -> str: """Randeaza panoul corespunzator unui tab ca string HTML.""" if tab == "acasa": return _render_panel_acasa(request, conn, account_id, status=status) if tab == "jurnal": return _render_panel_jurnal(request, conn, account_id) if tab == "import": return _render_panel_import(request) if tab == "coada": return _render_panel_coada(request, conn, account_id) if tab == "mapari": return _render_panel_mapari(request, conn, account_id) if tab == "cont": return _render_panel_cont(request, conn, account_id) if tab == "nomenclator": return _render_panel_nomenclator(request, conn) if tab == "integrare": return _render_integrare(request, conn, account_id) return _render_panel_acasa(request) @router.get("/", response_class=HTMLResponse) def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: """Dashboard principal cu tab-uri (US-003). Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS). Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta"). """ account_id = require_login(request) active_tab = tab if tab in _TABS_VALIDE else "acasa" conn = get_connection() try: panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status) # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele # (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003). counts = _status_counts(conn, account_id) badges = { "mapari": counts.get("needs_mapping", 0), } ctx = { "request": request, "rar_env": get_settings().rar_env, "version": __version__, "active_tab": active_tab, "panel_html": panel_html, "badges": badges, "is_authenticated": True, "is_admin": is_account_admin(conn, account_id), "csrf_token": get_csrf_token(request), } return templates.TemplateResponse("dashboard.html", ctx) finally: conn.close() @router.get("/_fragments/acasa", response_class=HTMLResponse) def fragment_acasa(request: Request) -> HTMLResponse: """Fragment HTMX pentru tab-ul Acasa (US-003, US-005).""" account_id = require_login(request) conn = get_connection() try: ctx = _get_acasa_context(request, conn, account_id) return templates.TemplateResponse("_acasa.html", ctx) finally: conn.close() @router.get("/_fragments/import", response_class=HTMLResponse) def fragment_import(request: Request) -> HTMLResponse: """Fragment HTMX pentru tab-ul Import — include zona de upload (US-003).""" require_login(request) return templates.TemplateResponse("_upload.html", _ctx(request)) @router.get("/_fragments/coada", response_class=HTMLResponse) def fragment_coada(request: Request) -> HTMLResponse: """US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din bookmark-uri/HTMX vechi. Nu da 404.""" account_id = require_login(request) conn = get_connection() try: ctx = _get_acasa_context(request, conn, account_id) return templates.TemplateResponse("_acasa.html", ctx) finally: conn.close() @router.get("/_fragments/nomenclator", response_class=HTMLResponse) def fragment_nomenclator(request: Request) -> HTMLResponse: """Browser nomenclator RAR (cache local upsert-at de worker la fiecare login).""" conn = get_connection() try: rows = conn.execute( "SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie" ).fetchall() return templates.TemplateResponse( "_nomenclator.html", {"request": request, "rows": rows} ) finally: conn.close() @router.get("/_fragments/integrare", response_class=HTMLResponse) def fragment_integrare(request: Request) -> HTMLResponse: """Fragment HTMX pentru tab-ul Integrare (hub documentatie + exemple cod).""" account_id = require_login(request) conn = get_connection() try: html = _render_integrare(request, conn, account_id) return HTMLResponse(content=html) finally: conn.close() @router.get("/_fragments/jurnal", response_class=HTMLResponse) def fragment_jurnal( request: Request, tip: str | None = None, nivel: str | None = None, data_de: str | None = None, data_pana: str | None = None, cont: str | None = None, page: int = 0, ) -> HTMLResponse: """Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont. Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii. """ account_id = require_login(request) conn = get_connection() try: ctx = _jurnal_context( request, conn, account_id, tip=tip, nivel=nivel, data_de=data_de, data_pana=data_pana, cont=cont, page=page, ) return templates.TemplateResponse("_jurnal.html", ctx) finally: conn.close() @router.get("/_fragments/banner", response_class=HTMLResponse) def fragment_banner(request: Request) -> HTMLResponse: account_id = require_login(request) conn = get_connection() try: counts = _status_counts(conn, account_id) blocked = sum(counts.get(s, 0) for s in _BLOCKED) return templates.TemplateResponse("_banner.html", { "request": request, "blocked": blocked, "account_active": _account_active(conn, account_id), }) finally: conn.close() def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: """Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0. Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD. Returneaza lista goala daca nu exista nicio stare blocata. """ rezultat = [] for status in ("needs_mapping", "needs_data", "error"): n = counts.get(status, 0) if n > 0: rezultat.append((eticheta_stare(status), n)) return rezultat def _pills_categorii(counts: dict[str, int]) -> list[dict]: """Pill-uri pentru starile cu problema (US-003 PRD 5.10). Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand). Reutilizeaza contoarele deja calculate din _status_counts. Returneza lista goala daca nu exista nicio stare blocata. """ # DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu). # Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului, # pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill). PILL_DEFS = [ ("needs_mapping", "Lipsa cod", "--warn"), ("needs_data", "Date incomplete", "--err"), ("error", "Eroare", "--err"), ] return [ {"status": status, "label": label, "color_var": color_var, "n": counts.get(status, 0)} for status, label, color_var in PILL_DEFS if counts.get(status, 0) > 0 ] @router.get("/_fragments/status", response_class=HTMLResponse) def fragment_status(request: Request) -> HTMLResponse: """Bara de status persistenta cu etichete umane (US-002, PRD 3.4). Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima autentificare, contorii de coada si defalcarea blocatelor pe motiv. Logica in routes.py (nu in template) pentru testabilitate. """ account_id = require_login(request) conn = get_connection() try: counts = _status_counts(conn, account_id) hb = read_heartbeat(conn) worker_alive = _worker_alive(hb) rar_state = _rar_state(hb, worker_alive) # 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_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": 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), "pills_categorii": _pills_categorii(counts), "account_active": _account_active(conn, account_id), }) finally: conn.close() @router.get("/_fragments/trimiteri-versiune", response_class=JSONResponse) def fragment_trimiteri_versiune(request: Request) -> JSONResponse: """Semnatura curenta a trimiterilor contului (JSON usor). Pollerul "Date noi" o compara cu versiunea cu care s-a randat tabelul; daca difera, arata nudge-ul de reincarcare — tabelul nu se mai schimba singur.""" account_id = require_login(request) conn = get_connection() try: return JSONResponse({"v": _trimiteri_versiune(conn, account_id)}) finally: conn.close() def _iso_date_prefix(value: object) -> str | None: """Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None. Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda (ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10). Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si sunt excluse din filtru — comportament actual pastrat. """ s = str(value or "").strip() if len(s) < 10: return None prefix = s[:10] try: datetime.strptime(prefix, "%Y-%m-%d") return prefix except (ValueError, TypeError): return None # Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana # scurta de pe rand (US-001, R1) e ne-goala DOAR pe acestea; pe queued/sending/sent e "". _STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping") def _eticheta_problema(status: str, motiv: str) -> str: """Eticheta umana scurta a problemei pentru randul de tabel (US-001, R1). Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe `eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (R1: DRY, fara al 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand. Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*. Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta scurta -> fallback-ul garanteaza un text ne-gol chiar la `rar_error` lipsa/corupt. """ if status not in _STARI_CU_PROBLEMA: return "" return motiv or eticheta_scurta(status) def _submission_row_view(r) -> dict: """Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004).""" eticheta = eticheta_stare(r["status"]) motiv = motiv_uman(r["status"], r["rar_error"]) return { "id": r["id"], "status": r["status"], # PRD 5.8 US-007/US-006: pill = eticheta scurta; textul lung ramane ca tooltip (title=). "stare_scurt": eticheta_scurta(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, # US-001/R1: eticheta umana scurta a problemei sub pill (text, nu cod brut). "eticheta_problema": _eticheta_problema(r["status"], motiv), # US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile # pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox). "gestionabil": r["status"] in _GESTIONABILE_WEB, } _PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10) @router.get("/_fragments/submissions", response_class=HTMLResponse) def fragment_submissions( request: Request, status: str | None = None, vehicul: str | None = None, data_de: str | None = None, data_pana: str | None = None, page: int = 1, ) -> HTMLResponse: """Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004). US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru: - FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET - CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru). """ 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) filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python page = max(1, page) # pre-clamp >= 1 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) where_sql = " AND ".join(where) if filtru_python: # Calea B: fetch-all, filtreaza in Python, slice (US-004 H1) # FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1) rows_db = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC", params, ).fetchall() view_all: list[dict] = [] for r in rows_db: 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: # Extragem portiunea YYYY-MM-DD (US-001 fix). d_prefix = _iso_date_prefix(prez["data_prestatie"]) if d_prefix is None: continue if data_de and d_prefix < data_de: continue if data_pana and d_prefix > data_pana: continue view_all.append(v) total = len(view_all) pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 page = max(1, min(page, pages)) # clamp H2 offset = (page - 1) * _PAGE_SIZE view = view_all[offset:offset + _PAGE_SIZE] else: # Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ) total = conn.execute( f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params ).fetchone()[0] pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 page = max(1, min(page, pages)) # clamp H2 offset = (page - 1) * _PAGE_SIZE rows_db = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC " "LIMIT ? OFFSET ?", params + [_PAGE_SIZE, offset], ).fetchall() view = [_submission_row_view(r) for r in rows_db] page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0 page_end = min(page * _PAGE_SIZE, total) return templates.TemplateResponse("_submissions.html", { "request": request, "rows": view, "filtru_activ": filtru_activ, "csrf_token": get_csrf_token(request), # Paginare (US-004) "total": total, "page": page, "pages": pages, "page_start": page_start, "page_end": page_end, # Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2) "f_status": status or "", "f_vehicul": vehicul_q or "", "f_data_de": data_de or "", "f_data_pana": data_pana or "", # Pill-uri (OOB) + stare activa + versiune pentru nudge-ul "Date noi". "pills_categorii": _pills_categorii(_status_counts(conn, account_id)), "status_filtru": status or "", "versiune_trimiteri": _trimiteri_versiune(conn, account_id), }) finally: conn.close() # Stari ne-trimise blocate pe care le putem corecta inline (US-010). _CORECTABILE = ("needs_data", "needs_mapping") # US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error # primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields). _EDITABILE_OP = ("needs_data", "needs_mapping", "error") # Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada. _GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping") def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse: """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011).""" scope_sql, scope_params = account_scope_clause(account_id) rows = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200", scope_params, ).fetchall() view = [_submission_row_view(r) for r in rows] return templates.TemplateResponse("_submissions.html", { "request": request, "rows": view, "filtru_activ": False, "csrf_token": get_csrf_token(request), "pills_categorii": _pills_categorii(_status_counts(conn, account_id)), "status_filtru": "", "versiune_trimiteri": _trimiteri_versiune(conn, account_id), }) 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 _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]: """Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy (PRD 5.7). Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json, aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din `nomenclator` (pasat de apelant — evita un SELECT redundant in _detaliu_ctx). Goala daca randul nu e needs_mapping sau nu are operatii nemapate reale (ex. needs_mapping din auto_send=0 — codul exista deja, doar trimiterea e oprita). """ if row["status"] != "needs_mapping": return [] try: content = json.loads(row["payload_json"]) if row["payload_json"] else {} if not isinstance(content, dict): content = {} except (ValueError, TypeError): content = {} seen: set[str] = set() out: list[dict] = [] for item in content.get("prestatii") or []: if not isinstance(item, dict) or (item.get("cod_prestatie") or ""): continue op = (item.get("cod_op_service") or "").strip() if not op or op in seen: continue seen.add(op) out.append({ "cod_op_service": op, "denumire": item.get("denumire"), "suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5), }) return out def _detaliu_ctx(request: Request, row, *, message: str | None = None, error: bool = False, corectie_errors: list | None = None, conn=None, account_id: int | None = None) -> dict: """Context pentru _trimitere_detaliu.html dintr-un rand de submission. `conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune `nemapate_inline` + `nomenclator` pentru maparea inline din panou (PRD 5.7). """ eticheta = eticheta_stare(row["status"]) nemapate_inline: list[dict] = [] nomenclator: list[dict] = [] # Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006) _nomenclator_complet: list[dict] = [] if conn is not None and row["status"] == "needs_mapping": # Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown. _nomenclator_complet = load_nomenclator(conn) nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet) nomenclator = _nomenclator_complet if nemapate_inline else [] # US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in # formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet # daca e deja incarcat (needs_mapping), altfel incarca fresh. nomenclator_rar: list[dict] = [] if conn is not None and row["status"] in _EDITABILE_OP: nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn) # US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select) cod_prestatie_curent = "" try: _pd = json.loads(row["payload_json"] or "{}") _prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else [] if _prestatii and isinstance(_prestatii[0], dict): cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper() except (ValueError, TypeError): pass 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"]), "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"]), "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, # US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada "gestionabil": row["status"] in _GESTIONABILE_WEB, # PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator) "nemapate_inline": nemapate_inline, "nomenclator": nomenclator, # US-006: select cod_prestatie pentru stari editabile "nomenclator_rar": nomenclator_rar, "cod_prestatie_curent": cod_prestatie_curent, "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: 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, conn=conn, account_id=account_id), ) finally: conn.close() @router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse) async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse: """Mapare inline din panoul de detaliu (PRD 5.7): alege cod RAR pentru o operatie nemapata. Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL SAU import batch), deblocand si randurile-frate cu aceeasi operatie. Scoped pe sesiune (404 cross-account/inexistent), CSRF obligatoriu, gard pe status needs_mapping. """ account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) cod_op_service = str(form.get("cod_op_service") or "").strip() cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper() auto_send = str(form.get("auto_send") or "") not in ("", "false", "0", "off") conn = get_connection() try: row = _fetch_submission_scoped(conn, account_id, submission_id) if not row: raise HTTPException(status_code=404, detail="trimitere inexistenta") if row["status"] != "needs_mapping": raise HTTPException(status_code=403, detail="trimitere fara operatii de mapat") if not cod_op_service or not cod_prestatie: return templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx(request, row, conn=conn, account_id=account_id, error=True, message="Alege un cod RAR pentru operatie."), ) exists = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,) ).fetchone() if not exists: return templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx(request, row, conn=conn, account_id=account_id, error=True, message=f"Cod necunoscut in nomenclator: {cod_prestatie}."), ) save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send) # Re-rezolva scoped pe canalul randului: batch_id None (API) sau batch import. reresolve_account(conn, account_id, batch_id=row["batch_id"]) row2 = _fetch_submission_scoped(conn, account_id, submission_id) eticheta = eticheta_stare(row2["status"]) resp = templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx(request, row2, conn=conn, account_id=account_id, message=f"Mapat {cod_op_service} -> {cod_prestatie}. " f"Stare noua: {eticheta[0]}."), ) resp.headers["HX-Trigger"] = "trimiteriChanged" return resp 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() # US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii. # Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou # e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md # invariant "build_key hashuieste cod_prestatie, idempotency.py:34"). _cod_raw = form.get("cod_prestatie") cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") if cod_prestatie_form: exists_nom = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,) ).fetchone() if not exists_nom: return templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx( request, row, conn=conn, account_id=account_id, error=True, message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie_form}. " "Alege un cod valid din lista.", ), ) prestatii_form = content.get("prestatii") if isinstance(prestatii_form, list) and prestatii_form: p0 = dict(prestatii_form[0]) p0["cod_prestatie"] = cod_prestatie_form content["prestatii"] = [p0] + list(prestatii_form[1:]) # 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()} valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, account_id) resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) content["prestatii"] = resolved # US-010: telemetrie pentru itemii rezolvati prin regula text (calea corectie web). _emite_text_rule_hits(conn, account_id, row["id"], 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, conn=conn, account_id=account_id, error=True, message="Lipseste inca un cod RAR — alege-l mai jos sau 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) resp = templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."), ) # PRD 5.9 US-003 (R5): pe succes, lista se reincarca (trimiteriChanged) si modalul # se inchide (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului. resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal" return resp finally: conn.close() # =========================================================================== # # US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada # # Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. # # =========================================================================== # @router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse) async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse: """Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard. US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista. """ account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) conn = get_connection() try: # US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission # standard, care nu actualizeaza cheia de idempotency). _cod_raw = form.get("cod_prestatie") cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") if cod_prestatie_form: row = _fetch_submission_scoped(conn, account_id, submission_id) if not row: raise HTTPException(status_code=404, detail="trimitere inexistenta") if row["status"] != "error": # cod_prestatie acceptat DOAR pentru starea error prin /repune raise HTTPException( status_code=409, detail="modificarea cod_prestatie prin repune e valida doar pentru starea error", ) # Valideaza cod-ul fata de nomenclator exists_nom = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,) ).fetchone() if not exists_nom: return templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx( request, row, conn=conn, account_id=account_id, error=True, message=f"Cod RAR necunoscut: {cod_prestatie_form}. Alege un cod valid.", ), ) # Parseaza payload si injecteaza cod_prestatie try: content = json.loads(row["payload_json"]) if row["payload_json"] else {} if not isinstance(content, dict): content = {} except (ValueError, TypeError): content = {} prestatii = content.get("prestatii") or [] if isinstance(prestatii, list) and prestatii: p0 = dict(prestatii[0]) p0["cod_prestatie"] = cod_prestatie_form # sterge cod_op_service/denumire daca exista (codul direct preia prioritate) p0.pop("cod_op_service", None) content["prestatii"] = [p0] + list(prestatii[1:]) # Re-rezolva prestatii cu noul cod mapping_meta = load_mapping_meta(conn, account_id) mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, account_id) resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) content["prestatii"] = resolved # Canonicalize + rebuild idempotency key canon = canonicalize_row(content) payload_json = json.dumps(content, ensure_ascii=False) new_key = build_key(account_id, canon) # Verifica coliziune (numai daca cheia s-a schimbat) 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: return templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx( request, row, conn=conn, account_id=account_id, error=True, message=f"Exista deja o trimitere identica (rand #{dup['id']}).", ), ) 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=? AND account_id=?", (new_key, payload_json, row["id"], account_id), ) conn.commit() except sqlite3.IntegrityError: return templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx( request, row, conn=conn, account_id=account_id, error=True, message="Exista deja o trimitere identica. Operatia a fost oprita.", ), ) row2 = _fetch_submission_scoped(conn, account_id, submission_id) resp = templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx( request, row2, conn=conn, account_id=account_id, message="Cod actualizat — randul a fost re-pus in coada.", ), ) resp.headers["HX-Trigger"] = "trimiteriChanged" return resp # Cale normala: fara cod_prestatie → delega la requeue_submission try: requeue_submission(conn, account_id, submission_id) except SubmissionNotFound: raise HTTPException(status_code=404, detail="trimitere inexistenta") except SubmissionStateConflict: raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)") row = _fetch_submission_scoped(conn, account_id, submission_id) resp = templates.TemplateResponse( "_trimitere_detaliu.html", _detaliu_ctx(request, row, conn=conn, account_id=account_id, message="Re-pus in coada — pleaca la urmatoarea trimitere."), ) resp.headers["HX-Trigger"] = "trimiteriChanged" return resp finally: conn.close() @router.post("/trimitere/{submission_id}/sterge", response_class=HTMLResponse) async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLResponse: """Sterge un rand blocat din dashboard. Scoped pe sesiune; sent/sending interzis (409).""" account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) conn = get_connection() try: try: delete_submission(conn, account_id, submission_id) except SubmissionNotFound: raise HTTPException(status_code=404, detail="trimitere inexistenta") except SubmissionStateConflict: raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)") resp = HTMLResponse( '