"""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. Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite). Consuma helper-e interne din import_router 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 re as _re 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 ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS from ..payload_view import prezentare_din_payload from ..web.csrf import get_csrf_token, verify_csrf from .labels import ( ETICHETA_ULTIMA_AUTENTIFICARE_RAR, STARI_PREVIEW, eticheta_rar, eticheta_scurta, eticheta_stare, eticheta_worker, format_data_rar, motiv_uman, nota_umana_preview, parse_erori, ) from ..web.session import LoginRequired, 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 ..observ import log_event 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, enrich_suggestions, ensure_embeddings_corpus, 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, ) from ..shared_store import record_human_validation # Campuri canonice cu eticheta umana pentru dropdown mapare coloane _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 templates.env.globals["parse_erori"] = parse_erori _BLOCKED = ("error", "needs_data", "needs_mapping") def _record_gold_validation(conn, denumire: str | None, cod_op_service: str, cod_prestatie: str, provenance: str) -> None: """Scrie GOLD partajat (shared_mappings) DOAR cand denumirea umana e reala. shared_mappings e cheiat pe `denumire_normalizata` (text uman din prezentari). `cod_op_service` e codul INTERN al operatiei, NU denumirea — a-l scrie ca si cheie polueaza GOLD cu intrari pe care `lookup_shared_gold` (cauta pe denumirea umana) nu le potriveste niciodata. Sarim scrierea cand denumirea lipseste sau == cod_op_service. Best-effort: confirmarea GOLD nu blocheaza fluxul principal. """ den = (denumire or "").strip() if not den or den == (cod_op_service or "").strip(): return try: record_human_validation(conn, den, cod_prestatie, provenance=provenance) except Exception: pass # --------------------------------------------------------------------------- # # Analytics device-mix (US-012, PRD 5.15) # # --------------------------------------------------------------------------- # _UA_MOBIL = _re.compile( r"Mobile|Android|iPhone|iPad|iPod|BlackBerry|Windows Phone|webOS", _re.IGNORECASE, ) def _clasificare_device(user_agent: str) -> str: """Clasifica grosier un User-Agent in 'mobil' sau 'desktop'. Regex pe markeri standard (Mobile/Android/iPhone/iPad/iPod/BlackBerry/ Windows Phone/webOS) — suficient pentru a valida premisa de utilizare mobil. Nicio librarie externa noua. """ if _UA_MOBIL.search(user_agent or ""): return "mobil" return "desktop" def _log_device_mix(request: Request, account_id: int | None) -> None: """Inregistreaza semnalul agregat de device-mix in app_events. Stocheaza DOAR eticheta grosiera ('desktop'/'mobil') in campul `cod`. NU stocheaza UA brut, IP sau alte PII suplimentare. Citire raport agregat (SQL): SELECT cod, COUNT(*) AS n FROM app_events WHERE tip='device_mix' GROUP BY cod; Sau cu evolutie zilnica: SELECT date(ts) AS zi, cod, COUNT(*) AS n FROM app_events WHERE tip='device_mix' GROUP BY zi, cod ORDER BY zi DESC; """ ua = request.headers.get("user-agent", "") clasificare = _clasificare_device(ua) log_event( "device_mix", nivel="INFO", account_id=account_id, cod=clasificare, mesaj=clasificare, # doar eticheta — nu UA brut ) 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. """ 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() counts = {r["status"]: int(r["n"]) for r in rows} # sent_today si sent_month — bucketare in TIMP LOCAL RO (E7 CRITIC). # updated_at e stocat ca datetime('now') UTC; date(updated_at) pur ar bucketiza # trimiterile dintre miezul noptii local (21:xx-24:xx UTC) pe ziua gresita. # Folosim modificatorul SQLite 'localtime' (DST-aware) in loc de offset fix '+3 hours': # RO e UTC+2 (EET) iarna si UTC+3 (EEST) vara; un '+3 hours' fix gresea cu 1h iarna # (ex. 21:30 UTC iarna = 23:30 RO azi, dar +3h = 00:30 maine -> ziua gresita). # Presupune TZ=Europe/Bucharest in mediul procesului (docker-compose/Dockerfile). row = conn.execute( "SELECT " " COUNT(CASE WHEN date(updated_at, 'localtime') = date('now', 'localtime') THEN 1 END) AS sent_today, " " COUNT(CASE WHEN strftime('%Y-%m', updated_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime') THEN 1 END) AS sent_month " "FROM submissions " "WHERE status = 'sent' AND (account_id = ? OR (? = 1 AND account_id IS NULL))", (account_id, account_id), ).fetchone() if row: counts["sent_today"] = int(row["sent_today"] or 0) counts["sent_month"] = int(row["sent_month"] or 0) else: counts["sent_today"] = 0 counts["sent_month"] = 0 return counts 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" # "import" si "coada" nu mai sunt tab-uri separate — importul si Trimiterile sunt # sectiuni pe Acasa. ?tab=import / ?tab=coada cad pe Acasa (fallback in dashboard()), # fara 404 si 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. Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1). """ from ..mapping import account_or_default from ..accounts import account_is_complete as _acct_is_complete acct = account_or_default(account_id) # Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet) row = conn.execute( "SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) # Banner cont incomplet (US-002): contul nu are companie + email + CUI complete cont_incomplet = not _acct_is_complete(row) if row else False # 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 # Contorul de atentie (blocate) se reflecta in heading-ul sectiunii # "Trimiterile tale" de pe Acasa. 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), # Acasa include caseta de upload -> are nevoie de csrf_token "csrf_token": get_csrf_token(request), # Banner ne-blocant (US-002): contul nu are identitate completa (companie+email+CUI) "cont_incomplet": cont_incomplet, } def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str: """Randeaza panoul Acasa ca string HTML. `status`: 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: """"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 id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) account_meta = _fetch_account_meta(conn, acct) cont_ctx = { "request": request, "csrf_token": get_csrf_token(request), "api_key": None, "are_creds": are_creds, "creds_mesaj": None, "creds_eroare": None, "rot_eroare": None, "account_meta": account_meta, "date_firma_mesaj": None, "date_firma_eroare": None, } # US-006 (5.17): context plan pentru sectiunea Plan din _cont.html. cont_ctx.update(_plan_ctx(conn, account_id)) return templates.get_template("_cont.html").render(cont_ctx) 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: 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). """ 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.""" 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) # Etichete tier pentru badge in antet (US-010 PRD 5.16). _TIER_LABELS: dict[str, str] = { "free": "Gratuit", "standard": "Standard", "pro": "Pro", "premium": "Premium", } def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict: """Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger. Returneaza: plan_linie — linie completa cu copy RO (cele 6 stari) plan_warn — True la >=80% consum sau limita atinsa (culoare + text) plan_limita_atinsa — True la 100% consum (--err in loc de --warn) trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time) usage_lunar — numar prestatii acceptate in coada luna curenta monthly_limit_val — limita lunara (60 pt free, None pt nelimitat) effective_tier_name — tier-ul efectiv ('free','standard','pro','premium') """ if now is None: now = datetime.now(timezone.utc) acct = account_or_default(account_id) row = conn.execute( "SELECT tier, trial_until FROM accounts WHERE id=?", (acct,) ).fetchone() tier_base = (row["tier"] if row else None) or "free" trial_until_str = (row["trial_until"] if row else None) eff = _eff_tier(row, now) if row else "free" monthly_limit = _PLANS.get(eff, _PLANS["free"]).get("monthly_limit") usage = _monthly_usage(conn, acct, now) # Calcul zile ramase din trial activ trial_ultima_zi = False trial_days: int | None = None if trial_until_str and eff == "pro" and tier_base == "free": try: tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T")) if tu.tzinfo is None: tu = tu.replace(tzinfo=timezone.utc) now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc) delta = tu - now_cmp trial_days = delta.days # 0 = < 1 zi ramasa (azi), 1 = < 2 zile, etc. trial_ultima_zi = (trial_days <= 0) except (ValueError, AttributeError, TypeError): pass # Construieste plan_linie si stari aferente (cele 6 stari din PRD) warn_aproape = False plan_limita_atinsa = False trial_expirat_recent = False if eff == "pro" and tier_base == "free" and trial_until_str: # Trial Pro activ if trial_ultima_zi: plan_linie = "Plan: Pro · trial expira azi" else: n = trial_days or 0 z = "zi" if n == 1 else "zile" plan_linie = f"Plan: Pro · trial {n} {z} ramase" elif eff == "free": # Free — cu sau fara trial expirat recent if trial_until_str: trial_expirat_recent = True if monthly_limit is not None: if usage >= monthly_limit: plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — limita atinsa" warn_aproape = True plan_limita_atinsa = True elif monthly_limit > 0 and usage >= int(monthly_limit * 0.8): plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — aproape de limita" warn_aproape = True else: plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} luna asta" else: plan_linie = "Plan: Gratuit" else: # Platit (tier de baza != free, ex. standard/pro/premium alocat de admin) label = _PLANS.get(eff, {}).get("label", eff.capitalize()) plan_linie = f"Plan: {label}" return { "plan_linie": plan_linie, "plan_warn": warn_aproape, "plan_limita_atinsa": plan_limita_atinsa, "trial_expirat_recent": trial_expirat_recent, "usage_lunar": usage, "monthly_limit_val": monthly_limit, "effective_tier_name": eff, } def _layout_header_ctx(conn, account_id: int) -> dict: """Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16). Citeste account_name, tier si starea de sanatate RAR pentru a popula: - account_name: numele service-ului, afisat sub titlu cand logat - tier_label: eticheta planului (Gratuit/Standard/Pro/Premium) - sanatate_ok: True daca worker viu si RAR ok (dot verde in antet) - last_login: data/ora ultimei autentificari RAR (format romanesc) - plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger Apelat aditiv din dashboard() fara a atinge alti handlere. """ row = conn.execute( "SELECT name, tier FROM accounts WHERE id=?", (account_id,) ).fetchone() account_name = (row["name"] if row else None) or "" tier = (row["tier"] if row else "free") or "free" tier_label = _TIER_LABELS.get(tier, "Gratuit") hb = read_heartbeat(conn) worker_alive = _worker_alive(hb) rar_state = _rar_state(hb, worker_alive) rar_ok = rar_state == "ok" sanatate_ok = worker_alive and rar_ok ctx = { "account_name": account_name, "tier_label": tier_label, "sanatate_ok": sanatate_ok, "last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None), } # US-006 (5.17): context plan pentru linia detaliata din meniul burger. ctx.update(_plan_ctx(conn, account_id)) return ctx @router.get("/", response_class=HTMLResponse) def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: """Dashboard principal cu tab-uri. 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=` pre-filtreaza lista Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta"). Vizitator neautentificat -> landing-ul comercial (in loc de redirect la /login), cu formularele de inregistrare/autentificare care posteaza la /signup si /login. """ try: account_id = require_login(request) except LoginRequired: return templates.TemplateResponse( "landing.html", { "request": request, "rar_env": get_settings().rar_env, "version": __version__, "csrf_token": get_csrf_token(request), }, ) # US-012: semnal agregat de device-mix (fara PII) _log_device_mix(request, account_id) 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: needs_mapping -> Mapari. Blocatele se reflecta in # heading-ul sectiunii Trimiteri. 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), } # US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR). # Adaugat aditiv, fara a atinge handlerele altora. ctx.update(_layout_header_ctx(conn, account_id)) 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.""" 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.""" require_login(request) return templates.TemplateResponse("_upload.html", _ctx(request)) @router.get("/_fragments/coada", response_class=HTMLResponse) def fragment_coada(request: Request) -> HTMLResponse: """"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). Necesita autentificare: este un fragment al dashboard-ului intern, nu un endpoint public. Fara sesiune -> redirect /login (via require_login). """ require_login(request) 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: 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. 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. Reutilizeaza contoarele deja calculate din _status_counts (fara PII/VIN per rand). Returneaza lista goala daca nu exista nicio stare blocata. """ # 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 ] def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = False, tab_activ: str = "acasa") -> dict: """Construieste dictionarul de context pentru _status.html. Accepta o conexiune deja deschisa (nu deschide alta). Folosit de fragment_status si de web_confirma_import (OOB swap dupa commit). """ counts = _status_counts(conn, account_id) hb = read_heartbeat(conn) worker_alive = _worker_alive(hb) rar_state = _rar_state(hb, worker_alive) worker_lbl = eticheta_worker(worker_alive) 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) # D6 (strip sanatate mereu-vizibil): text compus + stare verde/rosu sanatate_ok = worker_alive and rar_ok if not worker_alive: sanatate_text = "Blocat: worker oprit — declaratiile NU pleaca" elif not rar_ok: sanatate_text = "Blocat: RAR inaccesibil — declaratiile NU pleaca" else: sanatate_text = "Declaratiile curg normal" status_ctx = { "request": request, "worker_lbl": worker_lbl, "rar_lbl": rar_lbl, "worker_ok": worker_alive, "rar_ok": rar_ok, "sanatate_ok": sanatate_ok, "sanatate_text": sanatate_text, "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), "sent_today": counts.get("sent_today", 0), "sent_month": counts.get("sent_month", 0), "blocate_total": blocate_total, "blocate_defalcat": _blocate_defalcat(counts), "pills_categorii": _pills_categorii(counts), "account_active": _account_active(conn, account_id), "tab_activ": tab_activ, "mapari_badge": counts.get("needs_mapping", 0), "oob": oob, } # US-006 (5.17): context plan pentru linia de consum/trial in _status.html. status_ctx.update(_plan_ctx(conn, account_id)) return status_ctx @router.get("/_fragments/status", response_class=HTMLResponse) def fragment_status(request: Request) -> HTMLResponse: """Bara de status persistenta cu etichete umane. 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: tab_activ = request.query_params.get("tab", "acasa") ctx = _build_status_ctx(request, conn, account_id, tab_activ=tab_activ) return templates.TemplateResponse("_status.html", ctx) 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. Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si sunt excluse din filtru. """ 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 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. Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe `eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (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.""" eticheta = eticheta_stare(r["status"]) motiv = motiv_uman(r["status"], r["rar_error"]) return { "id": r["id"], "status": r["status"], # 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, # eticheta umana scurta a problemei sub pill (text, nu cod brut). "eticheta_problema": _eticheta_problema(r["status"], motiv), # 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 @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. 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. # FARA LIMIT — altfel paginile >8 ar disparea silentios. 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. 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 la nr. de pagini 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 la nr. de pagini 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 "total": total, "page": page, "pages": pages, "page_start": page_start, "page_end": page_end, # Filtre curente pentru linkurile de paginare (pastreaza filtrele) "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. _CORECTABILE = ("needs_data", "needs_mapping") # 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: sterge / re-pune in coada. _GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping") def _render_submissions(request: Request, conn, account_id: int, *, message: str | None = None) -> HTMLResponse: """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk. `message`: sumar optional (ex. "2 reusite, 0 esuate") afisat ca banner la inceputul fragmentului — folosit de bulk-fix (US-010, PRD 5.15). """ 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), "bulk_message": message, # US-010 (PRD 5.15): sumar bulk-fix }) def _payload_form_values(payload_json) -> dict: """Valori brute pentru prefill-ul formularului de corectie.""" 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 _prestatii_chips_from_payload(payload_json) -> list[dict]: """Extrage lista de chips prestatii din payload_json pentru _form_editare.html (US-007). Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}. Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''. """ try: data = json.loads(payload_json) if payload_json else {} if not isinstance(data, dict): data = {} except (ValueError, TypeError): data = {} chips = [] for item in (data.get("prestatii") or []): if not isinstance(item, dict): continue chips.append({ "cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(), "cod_op_service": (item.get("cod_op_service") or "").strip(), "denumire": (item.get("denumire") or "").strip(), }) return chips def _has_r_odo_chips(prestatii_chips: list[dict]) -> bool: """True daca orice chip are cod R-ODO sau I-ODO (trigger pentru reveal odo initial).""" return any(c.get("cod_prestatie") in ("R-ODO", "I-ODO") for c in prestatii_chips) def _nemapate_pentru_submission(row, nomenclator: list[dict], conn=None) -> list[dict]: """Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy + enriched. 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). L14-S6: cand `conn` e dat, adauga `sugestie_principala` (GOLD partajat > SILVER > embeddings) si `surse_sugestie` din `enrich_suggestions`. SUGGESTION-ONLY (#13). """ 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 = {} # Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off). if conn is not None: ensure_embeddings_corpus(conn, nomenclator) 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) entry: dict = { "cod_op_service": op, "denumire": item.get("denumire"), "suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5), "sugestie_principala": None, "surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}, } # L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13) if conn is not None: enriched = enrich_suggestions(conn, item.get("denumire")) entry["sugestie_principala"] = enriched["sugestie_principala"] entry["surse_sugestie"] = enriched["surse"] out.append(entry) 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. """ eticheta = eticheta_stare(row["status"]) nemapate_inline: list[dict] = [] nomenclator: list[dict] = [] # Nomenclatorul complet, incarcat pentru needs_mapping si refolosit mai jos. _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) # L14-S6: pasam conn pt enrich_suggestions (GOLD/SILVER/embeddings, suggestion-only) nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet, conn=conn) nomenclator = _nomenclator_complet if nemapate_inline else [] # 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) # 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; sent/sending nu "editabil": row["status"] in _CORECTABILE, # error/needs_data/needs_mapping pot fi sterse / re-puse in coada "gestionabil": row["status"] in _GESTIONABILE_WEB, # mapare inline (operatii nemapate ale acestui rand + nomenclator) "nemapate_inline": nemapate_inline, "nomenclator": nomenclator, # 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 [], # US-007 (PRD 5.15): chips prestatii + obs pentru formularul slim. # prestatii_chips: lista {cod_prestatie, cod_op_service, denumire} pentru _chips_prestatii.html. # has_r_odo: True daca chips contin R-ODO/I-ODO (trigger reveal odo initial, D10c). # obs_val: text liber observatii (campul obs din payload_json). # form_chips_url: endpoint HTMX pentru add/remove chip (E6 server-driven). } # Chips context (US-007): derivat din payload_json _pjson = row["payload_json"] prestatii_chips = _prestatii_chips_from_payload(_pjson) ctx["prestatii_chips"] = prestatii_chips ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips) ctx["form_chips_url"] = "/form-chips" # US-009: submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html. # Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste # → butonul nu apare (corect: /form-chips nu are scop de submission). ctx["chips_submission_id"] = row["id"] try: _pdata = json.loads(_pjson or "{}") ctx["obs_val"] = (str(_pdata.get("obs") or "") if isinstance(_pdata, dict) else "").strip() except (ValueError, TypeError): ctx["obs_val"] = "" 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).""" 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. 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). """ 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: 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) # L14-S6: inregistreaza confirmare umana in GOLD partajat (shared_mappings). # Gaseste denumirea operatiei din payload (cheia partajata e denumirea, nu cod_op_service). try: _pj = json.loads(row["payload_json"]) if row["payload_json"] else {} _den_gold = None for _pit in (_pj.get("prestatii") or []): if isinstance(_pit, dict) and _pit.get("cod_op_service") == cod_op_service: _den_gold = _pit.get("denumire") break _record_gold_validation( conn, _den_gold, cod_op_service, cod_prestatie, provenance=f"account_{account_or_default(account_id)}/mapeaza_inline", ) except Exception: pass # best-effort: confirmare GOLD nu blocheaza fluxul principal # 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() # obs: text liber optional (US-005 PRD 5.15). Permite si string gol (sterge obs). # None = absent din form (neschimbat); "" = curatare explicita. obs_val = form.get("obs") if isinstance(obs_val, str): content["obs"] = obs_val.strip() # Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii. # US-006 (PRD 5.15): form.getlist permite N coduri; fiecare se ataseaza itemului # corespondent din prestatii (by index), pastrand cod_op_service/denumire (D7/E1). # US-007 (PRD 5.15): form-ul slim trimite TOATE itemele (inclusiv "" pentru nemapate), # permitand 1-1 aliniere by-index chiar cand un item de mijloc ramane nemapat. # Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899). codes_raw = form.getlist("cod_prestatie") # Acceptam lista cu "" pentru pozitii nemapate (US-007); filtrare doar pt detectia # "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie). codes_positional = [ c.strip().upper() if isinstance(c, str) else "" for c in codes_raw ] # US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. # Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit. # Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional. # Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899). _flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper() if _flat_picker and _flat_picker not in codes_positional: codes_positional.append(_flat_picker) for _i in range(len(codes_positional)): if not codes_positional[_i]: _op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper() if _op_val: codes_positional[_i] = _op_val # Verifica daca cel putin un cod non-gol a fost trimis codes_nonempty = [c for c in codes_positional if c] if codes_nonempty: # Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide) for cod in codes_nonempty: exists_nom = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) ).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}. " "Alege un cod valid din lista.", ), ) # Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index. # Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE). # Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping. existing = content.get("prestatii") or [] new_prestatii = [] for i, cod in enumerate(codes_positional): if i >= len(existing) and not cod: continue # extra pozitii goale fara item corespondent — sarite item = dict(existing[i]) if i < len(existing) else {} if cod: item["cod_prestatie"] = cod # E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta new_prestatii.append(item) # Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul. # Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza. seen_pairs: set = set() deduped: list = [] for item in new_prestatii: pair = (item.get("cod_op_service"), item.get("cod_prestatie")) if pair not in seen_pairs: seen_pairs.add(pair) deduped.append(item) content["prestatii"] = deduped # else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii # detecteaza operatii nemapate si randul ramane needs_mapping. # 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 # 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."), ) # US-001 (PRD 5.11): ramura auto_send eliminata din corectie. 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, conn=conn, account_id=account_id, 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, conn=conn, account_id=account_id, 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, conn=conn, account_id=account_id, 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, conn=conn, account_id=account_id, message="Corectat — randul a fost re-pus in coada."), ) # 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() # =========================================================================== # # Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada. # # Peste helper-ul 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. Daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pune 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: # Prelucrare coduri_prestatie (lista multi-select) pentru starea error, inaintea # requeue_submission standard care nu actualizeaza cheia de idempotency. # US-006 (PRD 5.15): form.getlist; cod_op_service/denumire RAMAN pe item (E1 IRON RULE). codes_raw = form.getlist("cod_prestatie") codes = [c.strip().upper() for c in codes_raw if isinstance(c, str) and c.strip()] if codes: 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 FIECARE cod fata de nomenclator (ORA-12899) for cod in codes: exists_nom = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) ).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}. Alege un cod valid.", ), ) # Parseaza payload try: content = json.loads(row["payload_json"]) if row["payload_json"] else {} if not isinstance(content, dict): content = {} except (ValueError, TypeError): content = {} # Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index. # E1 IRON RULE: cod_op_service/denumire RAMAN pe item (pop-ul vechi ELIMINAT). # ITERAM peste `existing`, NU peste `codes`: formularul /repune trimite un # SINGUR select cod_prestatie, deci pentru un rand cu 2+ prestatii o iterare # pe codes ar fi pastrat doar len(codes) itemi -> prestatii[1:] PIERDUTE -> # declaratie incompleta la RAR (FINALIZATA ireversibil). Aplicam codes pozitional # si pastram intacte toate prestatiile existente fara cod nou. existing = content.get("prestatii") or [] new_prestatii = [] for i in range(max(len(existing), len(codes))): item = dict(existing[i]) if i < len(existing) else {} if i < len(codes): item["cod_prestatie"] = codes[i] # E1: cod_op_service/denumire NU se sterg — perechea op<->cod ramane intacta new_prestatii.append(item) # Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul seen_pairs: set = set() deduped: list = [] for item in new_prestatii: pair = (item.get("cod_op_service"), item.get("cod_prestatie")) if pair not in seen_pairs: seen_pairs.add(pair) deduped.append(item) content["prestatii"] = deduped # 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( '