Files
rar-autopass/app/web/routes.py
Claude Agent 5a964a1a8d feat(5.10): UX trimiteri (pill filtre, paginare, editare) + Mapari in meniu + branding ROMFAST
14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates pe valuri cu fisiere disjuncte; routes.py + base.html serializate ca fisiere fierbinti).

- US-001 fix filtrare data (_iso_date_prefix pe garda+comparatie, prinde timestamp cu ora)
- US-002/007 operatie service distincta in payload_view + afisare in detaliu
- US-003 pill-uri categorii (button/aria-pressed; needs_mapping --warn, needs_data/error --err); fara lista ID-uri/dropdown
- US-004 paginare numerotata 25/pag (total ramificat SQL-COUNT vs fetch-all+slice, clamp page, poll pastreaza pagina)
- US-005 VIN block-level sub nr
- US-006/006b editare cod RAR + validare nomenclator + recalcul idempotency (needs_data/needs_mapping via /corecteaza, error via /repune)
- US-008 card eroare 3-niveluri doar pe read-only + rezumat top-of-form
- US-009 Mapari in meniu hamburger; scoatere tab-bar + role=tablist orfan
- US-010/011 pagina Mapari consolidata + butoane icon SVG + dirty-state (fara kebab/emoji)
- US-012/012b header centrat + logo ROMFAST (/static/romfast_logo.png) in header
- US-013 paleta azur ROMFAST (#2E74D6/#1F66C9) + IBM Plex Sans/Mono self-host (woff2 reale)
- US-014 selector tema ciclic Light/Dark/Petrol/Auto + anti-FOUC pe 4 stari

Backend trimitere (worker/masina stari/idempotenta/mapping) + schema NEATINSE (UI/UX pur + 1 fix de filtrare).
VERIFY context curat PASS; /code-review high: 1 finding material reparat (US-006b). Regresie 896 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:20:58 +00:00

2907 lines
118 KiB
Python

"""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
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 _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,
# 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)
ctx["status_filtru"] = status
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()
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 "",
})
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),
})
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(
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
)
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca + modalul se inchide.
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
return resp
finally:
conn.close()
@router.post("/trimiteri/sterge-bulk", response_class=HTMLResponse)
async def post_sterge_bulk(request: Request) -> HTMLResponse:
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a
opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
ids = form.getlist("submission_id")
conn = get_connection()
try:
for raw in ids:
try:
sid = int(str(raw))
except (ValueError, TypeError):
continue
try:
delete_submission(conn, account_id, sid)
except (SubmissionNotFound, SubmissionStateConflict):
continue # doar blocate ale contului; restul sarite
resp = _render_submissions(request, conn, account_id)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
"FROM operations_mapping o "
"LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie "
"WHERE o.account_id=? ORDER BY o.cod_op_service",
(acct,),
).fetchall()
return [
{
"id": r["id"],
"cod_op_service": r["cod_op_service"],
"cod_prestatie": r["cod_prestatie"],
"auto_send": bool(r["auto_send"]),
"nume_prestatie": r["nume_prestatie"],
}
for r in rows
]
def _load_column_formats(conn, account_id: int) -> list[dict]:
"""Formate de coloane salvate (column_mappings) ale contului (US-006).
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT id, signature_coloane, json_mapare, format_data, created_at "
"FROM column_mappings WHERE account_id=? ORDER BY id DESC",
(acct,),
).fetchall()
out: list[dict] = []
for r in rows:
try:
jm = json.loads(r["json_mapare"]) if r["json_mapare"] else {}
except (ValueError, TypeError):
jm = {}
out.append({
"id": r["id"],
"signature_coloane": r["signature_coloane"],
"mappings": jm,
"columns": list(jm.keys()),
"format_data": r["format_data"],
"created_at": r["created_at"],
})
return out
def _render_mapari(
request: Request, conn, account_id: int, *, message: str | None = None
) -> HTMLResponse:
return templates.TemplateResponse(
"_mapari.html",
{
"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": message,
"csrf_token": get_csrf_token(request),
},
)
@router.get("/_fragments/mapari", response_class=HTMLResponse)
def fragment_mapari(request: Request) -> HTMLResponse:
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
"""
account_id = require_login(request)
conn = get_connection()
try:
return _render_mapari(request, conn, account_id)
finally:
conn.close()
@router.post("/mapari", response_class=HTMLResponse)
def post_mapare(
request: Request,
cod_op_service: str = Form(...),
cod_prestatie: str = Form(...),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
cod = cod_prestatie.strip().upper()
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
if not exists:
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = (
f"Mapat {cod_op_service.strip()} -> {cod}. "
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, account_id, message=msg)
finally:
conn.close()
# =========================================================================== #
# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere #
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
# =========================================================================== #
@router.post("/mapari/salvate", response_class=HTMLResponse)
def post_editeaza_mapare_salvata(
request: Request,
cod_op_service: str = Form(...),
cod_prestatie: str = Form(...),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Editeaza o mapare op->cod salvata (cod RAR / auto-send) + re-rezolva blocatele.
Scoped pe contul sesiunii (save_mapping foloseste account_or_default(sesiune) —
cross-account imposibil). Respinge cod inexistent in nomenclator.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
cod = cod_prestatie.strip().upper()
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone()
if not exists:
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = (
f"Mapare actualizata: {cod_op_service.strip()} -> {cod}. "
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, account_id, message=msg)
finally:
conn.close()
@router.post("/mapari/salvate/sterge", response_class=HTMLResponse)
def post_sterge_mapare_salvata(
request: Request,
cod_op_service: str = Form(...),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Sterge o mapare op->cod salvata. Scoped pe contul sesiunii."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
conn.execute(
"DELETE FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(acct, cod_op_service.strip()),
)
return _render_mapari(
request, conn, account_id,
message=f"Mapare stearsa: {cod_op_service.strip()}.",
)
finally:
conn.close()
# =========================================================================== #
# US-004 (5.8) — Reguli automate (text): substring -> cod RAR #
# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.#
# =========================================================================== #
@router.post("/mapari/reguli-text", response_class=HTMLResponse)
def post_salveaza_regula_text(
request: Request,
pattern: str = Form(...),
cod_prestatie: str = Form(...),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Salveaza o regula text (substring -> cod RAR) + re-rezolva submission-urile blocate.
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N"
+ trigger trimiteriChanged (refresh lista), ca maparea inline (5.7).
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
pat = (pattern or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not pat or not cod:
return _render_mapari(
request, conn, account_id,
message="Completeaza textul cautat si codul RAR.",
)
valid_codes = load_nomenclator_codes(conn)
if valid_codes and cod not in valid_codes:
return _render_mapari(
request, conn, account_id,
message=f"Cod RAR necunoscut in nomenclator: {cod}.",
)
# US-011: avertisment neblocant daca regula noua se suprapune (substring,
# oricare directie) cu una existenta. Calculam INAINTE de save, fata de
# regulile curente, ca pattern-ul nou sa nu se compare cu sine.
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
save_text_rule(conn, account_id, pat, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = f"Regula salvata. Deblocate: {stats['requeued']}."
if overlap:
parti = "; ".join(
f"«{r.get('pattern')}» -> {r.get('cod_prestatie')}" for r in overlap
)
msg += (
f" Se suprapune cu regula {parti}; "
"ordinea (priority, id) decide care se aplica prima."
)
resp = _render_mapari(request, conn, account_id, message=msg)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/mapari/reguli-text/sterge", response_class=HTMLResponse)
def post_sterge_regula_text(
request: Request,
pattern: str = Form(...),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Sterge o regula text. Scoped pe contul sesiunii."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
pat = (pattern or "").strip()
delete_text_rule(conn, account_id, pat)
return _render_mapari(
request, conn, account_id,
message=f"Regula stearsa: «{pat}».",
)
finally:
conn.close()
@router.post("/mapari/reguli-text/preview", response_class=HTMLResponse)
def post_preview_regula_text(
request: Request,
pattern: str = Form(""),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Preview pre-salvare (US-009): cate operatii nemapate ar potrivi regula.
NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
(needs_mapping, reuse pending_unmapped) al caror text (denumire sau
cod_op_service, normalizat) CONTINE pattern-ul + intoarce pana la 3 exemple.
Pattern gol -> fragment gol (nu numara „tot"). Scoped pe contul sesiunii.
"""
from markupsafe import escape
account_id = require_login(request)
verify_csrf(request, csrf_token)
pat = normalize_for_match(pattern)
if not pat:
return HTMLResponse(content="")
conn = get_connection()
try:
pending = pending_unmapped(conn, account_id)
finally:
conn.close()
matches = []
for entry in pending:
text = normalize_for_match(entry.get("denumire") or entry.get("cod_op_service"))
if pat in text:
matches.append(entry)
if not matches:
return HTMLResponse(
content='<span class="muted" style="font-size:12px;">Nicio potrivire acum.</span>'
)
exemple = ", ".join(
f"«{escape((e.get('denumire') or e.get('cod_op_service') or '').strip())}»"
for e in matches[:3]
)
n = len(matches)
plural = "operatie nemapata" if n == 1 else "operatii nemapate"
return HTMLResponse(
content=(
f'<span class="muted" style="font-size:12px;">'
f'Potriveste {n} {plural}: {exemple}.</span>'
)
)
# =========================================================================== #
# US-006 — Formate de coloane salvate: editare format data + stergere #
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
# =========================================================================== #
@router.post("/formate-coloane/editeaza", response_class=HTMLResponse)
async def post_editeaza_format_coloane(
request: Request,
format_id: int = Form(...),
format_data: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Editeaza un format de coloane salvat (format data). Scoped pe cont prin id+account_id.
json_mapare optional (string JSON valid) — daca e dat, inlocuieste maparea coloanelor.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
form = await request.form()
json_mapare_raw = form.get("json_mapare")
conn = get_connection()
try:
owned = conn.execute(
"SELECT 1 FROM column_mappings WHERE id=? AND account_id=?",
(format_id, acct),
).fetchone()
if not owned:
return _render_mapari(
request, conn, account_id, message="Format inexistent sau inaccesibil."
)
fmt = (format_data or "").strip() or None
if isinstance(json_mapare_raw, str) and json_mapare_raw.strip():
try:
jm = json.loads(json_mapare_raw)
if not isinstance(jm, dict):
raise ValueError
except (ValueError, TypeError):
return _render_mapari(
request, conn, account_id, message="Mapare coloane invalida (JSON)."
)
conn.execute(
"UPDATE column_mappings SET json_mapare=?, format_data=? WHERE id=? AND account_id=?",
(json.dumps(jm, ensure_ascii=False), fmt, format_id, acct),
)
else:
conn.execute(
"UPDATE column_mappings SET format_data=? WHERE id=? AND account_id=?",
(fmt, format_id, acct),
)
return _render_mapari(request, conn, account_id, message="Format de coloane actualizat.")
finally:
conn.close()
@router.post("/formate-coloane/sterge", response_class=HTMLResponse)
def post_sterge_format_coloane(
request: Request,
format_id: int = Form(...),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Sterge un format de coloane salvat. Scoped pe cont prin id+account_id."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
conn.execute(
"DELETE FROM column_mappings WHERE id=? AND account_id=?",
(format_id, acct),
)
return _render_mapari(request, conn, account_id, message="Format de coloane sters.")
finally:
conn.close()
# =========================================================================== #
# Import UI (U5) — upload → mapare coloane → preview → confirmare #
# Consuma helper-e din import_router fara a edita fisierul backend. #
# Toate rutele /_import/* returneaza fragmente HTML (target #import-section). #
# =========================================================================== #
def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict]) -> list[dict]:
"""Operatii distincte nemapate dintr-un preview de import (staging), cu sugestii fuzzy.
Echivalentul lui pending_unmapped() dar pe randuri de PREVIEW (import in staging,
inca neexistente ca submissions). Aduna doar prestatiile fara cod_prestatie
(cele cu auto_send=0 au deja cod -> nu apar aici). Sortare: cele mai blocate intai.
"""
agg: dict[str, dict[str, Any]] = {}
for row in preview_rows:
if row.get("resolved_status") != "needs_mapping":
continue
for item in (row.get("resolved", {}).get("prestatii") or []):
if not isinstance(item, dict) or item.get("cod_prestatie"):
continue
op = (item.get("cod_op_service") or "").strip()
if not op:
continue
entry = agg.setdefault(op, {"cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0})
if not entry["denumire"] and item.get("denumire"):
entry["denumire"] = item.get("denumire")
entry["blocked"] += 1
out: list[dict] = []
for entry in agg.values():
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
out.append(entry)
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
return out
def _web_compute_preview(
conn,
import_id: int,
account_id: int,
) -> dict[str, Any] | str:
"""Calculeaza preview pentru un batch; intoarce date sau str cu mesaj de eroare.
Reutilizeaza _resolve_row_for_preview, _already_sent_lookup, _signature
din import_router. Nu repeta logica de rezolvare — only orchestrare.
"""
acct = account_or_default(account_id)
batch = conn.execute(
"SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?",
(import_id, acct),
).fetchone()
if not batch:
return "Batch de import inexistent sau inaccesibil."
raw_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index",
(import_id,),
).fetchall()
if not raw_rows_db:
return "Niciun rand in batch."
# Decripteaza randurile + override-urile editate (3.6)
rows: list[dict[str, Any]] = []
overrides: list[dict[str, Any]] = []
for r in raw_rows_db:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
except Exception:
row_data = {}
rows.append(row_data)
try:
ov = decrypt_creds(r["override_json"]) if r["override_json"] else None
except Exception:
ov = None
overrides.append(ov or {})
col_names = list(rows[0].keys()) if rows else []
sig = _signature(col_names)
mapping_row = conn.execute(
"SELECT json_mapare, format_data FROM column_mappings WHERE account_id=? AND signature_coloane=?",
(acct, sig),
).fetchone()
if not mapping_row:
return "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
format_data: str | None = mapping_row["format_data"]
# Mapare operatii (o singura incarcare — Eng#5)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# US-010/US-003 (paritate): preview-ul web trebuie sa aplice ACELEASI reguli text +
# validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin
# regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Detectie coercion flags din valorile stocate (VIN numeric)
coercion_flags_map: dict[int, list[str]] = {}
for i, row_dict in enumerate(rows):
flags: list[str] = []
for col_f, camp_c in json_mapare.items():
if camp_c == "vin":
vin_val = row_dict.get(col_f)
if vin_val is not None and str(vin_val).replace(".", "").isdigit():
flags.append(f"VIN numeric ({vin_val}) — verificati seria sasiului")
if flags:
coercion_flags_map[i] = flags
# Reconstructie date_col_format din format_data stocat in mapare
date_col_format: dict[str, str] = {}
if format_data:
for col_f, camp_c in json_mapare.items():
if camp_c == "data_prestatie":
date_col_format[col_f] = format_data
# Detectie coloane cu formule (rata None ridicata)
n_rows = len(rows)
formula_columns: list[str] = []
if n_rows > 0:
none_counts = {col_f: sum(1 for r in rows if r.get(col_f) is None) for col_f in col_names}
formula_columns = [col_f for col_f, cnt in none_counts.items() if cnt / n_rows >= 0.6]
# Rezolvare per rand
preview_rows: list[dict[str, Any]] = []
keys_for_lookup: list[str] = []
key_to_indices: dict[str, list[int]] = {}
for i, row_dict in enumerate(rows):
flags_i = coercion_flags_map.get(i, [])
info = _resolve_row_for_preview(
raw_row=row_dict,
json_mapare=json_mapare,
date_col_format=date_col_format,
coercion_flags=flags_i,
mapping=mapping,
mapping_meta=mapping_meta,
formula_columns=formula_columns,
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
)
key: str | None = None
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try:
key = _build_idempotency_key(account_id, info["resolved"])
keys_for_lookup.append(key)
key_to_indices.setdefault(key, []).append(i)
except Exception:
pass
preview_rows.append({
"row_index": i,
"resolved_status": info["resolved_status"],
"resolved": info["resolved"],
"errors": info["errors"],
"flags": info["flags"],
"idempotency_key": key,
})
# Already_sent: batch lookup (Eng#5 — fara N+1)
unique_keys = list(set(keys_for_lookup))
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
# Aplica already_sent si duplicate_in_file
for row in preview_rows:
k = row.get("idempotency_key")
if not k:
continue
if k in already_sent_map and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
row["resolved_status"] = "already_sent"
row["already_sent_info"] = already_sent_map[k]
continue
indices_same_key = key_to_indices.get(k, [])
if len(indices_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
row["resolved_status"] = "duplicate_in_file"
row["duplicate_with"] = [idx for idx in indices_same_key if idx != row["row_index"]]
# Rezumat stari
summary: dict[str, int] = {}
for row in preview_rows:
s = row["resolved_status"]
summary[s] = summary.get(s, 0) + 1
# Actualizeaza contoare in import_batches
conn.execute(
"UPDATE import_batches SET ok=?, needs_mapping=?, needs_data=?, needs_review=?, "
"already_sent=?, duplicate_in_file=? WHERE id=?",
(
summary.get("ok", 0),
summary.get("needs_mapping", 0),
summary.get("needs_data", 0),
summary.get("needs_review", 0),
summary.get("already_sent", 0),
summary.get("duplicate_in_file", 0),
import_id,
),
)
# Actualizeaza resolved_status in import_rows
conn.execute("BEGIN IMMEDIATE")
try:
conn.executemany(
"UPDATE import_rows SET resolved_status=? WHERE batch_id=? AND row_index=?",
[(row["resolved_status"], import_id, row["row_index"]) for row in preview_rows],
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
nomenclator = load_nomenclator(conn)
return {
"rows": preview_rows,
"summary": summary,
"total": len(preview_rows),
"filename": batch["filename"],
"unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator),
"nomenclator": nomenclator,
}
@router.post("/_import/upload", response_class=HTMLResponse)
async def web_upload_import(
request: Request,
file: UploadFile = File(...),
sheet_name: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
Daca maparea de coloane exista deja (signature match): computa preview imediat.
Daca nu: intoarce formularul de mapare coloane.
Nu editeaza import_router.py — apeleaza parse_file si DB direct.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
data = await file.read()
filename = file.filename or "fisier"
# Parsare fisier
try:
parsed = parse_file(data, filename, sheet_name=sheet_name)
except MultipleSheets as ms:
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
except FileTooLarge as e:
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
return templates.TemplateResponse("_upload.html", _ctx(
request, error=str(e), eroare_upload=eroare_upload
))
except HeaderError as e:
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
return templates.TemplateResponse("_upload.html", _ctx(
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload
))
except UnicodeDecodeError as e:
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
return templates.TemplateResponse("_upload.html", _ctx(
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload
))
except Exception as e:
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
return templates.TemplateResponse("_upload.html", _ctx(
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload
))
conn = get_connection()
try:
sig = _signature(parsed.columns)
# Stagingul in DB (tranzactie explicita — Issue 6)
conn.execute("BEGIN IMMEDIATE")
try:
cur = conn.execute(
"INSERT INTO import_batches (account_id, filename, status, total, purge_after) "
"VALUES (?, ?, 'staging', ?, datetime('now', '+90 days'))",
(acct, filename, len(parsed.rows)),
)
batch_id = cur.lastrowid
conn.executemany(
"INSERT INTO import_rows (batch_id, row_index, raw_json, resolved_status, error) "
"VALUES (?, ?, ?, 'pending', NULL)",
[
(batch_id, i, encrypt_creds(row_dict))
for i, row_dict in enumerate(parsed.rows)
],
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
# Verifica mapare existenta
existing = conn.execute(
"SELECT json_mapare, format_data FROM column_mappings "
"WHERE account_id=? AND signature_coloane=?",
(acct, sig),
).fetchone()
batch_id_int: int = cur.lastrowid or 0 # lastrowid este int dupa INSERT reusit
if existing:
# Mapare retinuta → computa preview imediat
result = _web_compute_preview(conn, batch_id_int, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
"csrf_token": get_csrf_token(request),
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": batch_id_int,
"message": "Mapare retinuta aplicata automat.",
"csrf_token": get_csrf_token(request),
**result,
})
# Mapare noua — sugestii fuzzy si formular de mapare
fuzzy_suggestions: dict[str, list[dict]] = {}
for col in parsed.columns:
sugg = _fuzzy_suggest_column(col, limit=3)
if sugg:
fuzzy_suggestions[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", {
"request": request,
"import_id": batch_id_int,
"filename": filename,
"columns": parsed.columns,
"sample_rows": parsed.rows[:3],
"fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": None,
"csrf_token": get_csrf_token(request),
})
finally:
conn.close()
@router.post("/_import/{import_id}/mapare-coloane", response_class=HTMLResponse)
async def web_save_mapare_coloane(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
account_id = require_login(request)
acct = account_or_default(account_id)
# Detecta body JSON trimis eronat (Content-Type: application/json) → COLOANE_FORMAT_JSON
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.body()
try:
json.loads(body)
# JSON valid dar trimis pe ruta de form — tot e format gresit pentru aceasta ruta
except (json.JSONDecodeError, ValueError):
pass
# Indiferent daca JSON-ul e valid sau nu, Content-Type application/json e gresit pentru ruta form
eroare_mapare = _errors.eroare(
"COLOANE_FORMAT_JSON",
cauza="Cererea a fost trimisa ca JSON (application/json) in loc de form data.",
)
conn = get_connection()
try:
first_row = conn.execute(
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
(import_id,),
).fetchone()
columns: list[str] = []
if first_row:
try:
rd = decrypt_creds(first_row["raw_json"]) or {}
columns = list(rd.keys())
except Exception:
pass
fuzzy: dict[str, list[dict]] = {}
for col in columns:
sugg = _fuzzy_suggest_column(col, limit=3)
if sugg:
fuzzy[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", _ctx(
request,
import_id=import_id,
columns=columns,
sample_rows=[],
fuzzy_suggestions=fuzzy,
canonical_fields=_CANONICAL_FIELDS,
format_data=None,
eroare_mapare=eroare_mapare,
error=True,
))
finally:
conn.close()
form = await request.form()
# Colectare perechi coloana fisier → camp canonic din form
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
verify_csrf(request, str(form.get("csrf_token") or ""))
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
format_data_val = str(form.get("format_data") or "").strip() or None
# Construieste json_mapare (ignora campurile marcate ca "ignorate")
json_mapare: dict[str, str] = {}
for colname, canon in zip(colnames, canons):
if canon:
json_mapare[colname] = canon
if not json_mapare:
# Nici un camp mapat → re-arata formularul cu eroare
conn = get_connection()
try:
first_row = conn.execute(
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
(import_id,),
).fetchone()
columns = []
if first_row:
try:
rd = decrypt_creds(first_row["raw_json"]) or {}
columns = list(rd.keys())
except Exception:
pass
fuzzy: dict[str, list[dict]] = {}
for col in columns:
sugg = _fuzzy_suggest_column(col, limit=3)
if sugg:
fuzzy[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", _ctx(
request,
import_id=import_id,
columns=columns,
sample_rows=[],
fuzzy_suggestions=fuzzy,
canonical_fields=_CANONICAL_FIELDS,
format_data=format_data_val,
message="Mapeaza cel putin un camp canonic inainte de a continua.",
error=True,
))
finally:
conn.close()
conn = get_connection()
try:
# Verifica ca batch-ul apartine contului
batch = conn.execute(
"SELECT id FROM import_batches WHERE id=? AND account_id=?",
(import_id, acct),
).fetchone()
if not batch:
return templates.TemplateResponse("_upload.html", _ctx(
request, error="Batch de import inexistent sau expirat."
))
# Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele
# ignorate), nu doar campurile mapate. Altfel ignorarea unei coloane schimba
# semnatura si maparea nu mai e gasita la preview/re-upload (col_names = antet
# complet peste tot). `colnames` vine din form = toate coloanele randate.
sig = _signature(colnames or list(json_mapare.keys()))
# Salveaza maparea (upsert)
conn.execute(
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(account_id, signature_coloane) DO UPDATE SET "
"json_mapare=excluded.json_mapare, format_data=excluded.format_data",
(acct, sig, json.dumps(json_mapare, ensure_ascii=False), format_data_val),
)
# Computa preview
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, **result
))
finally:
conn.close()
@router.get("/_import/{import_id}/preview", response_class=HTMLResponse)
def web_preview_import(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
account_id = require_login(request)
conn = get_connection()
try:
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
"csrf_token": get_csrf_token(request),
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"csrf_token": get_csrf_token(request),
**result,
})
finally:
conn.close()
# =========================================================================== #
# US-002 (3.6) — Editare celule in preview: mod editare pe rand. #
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). #
# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). #
# =========================================================================== #
def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
"""Recalculeaza preview-ul si extrage un singur rand.
Statusul e rederivat prin `_resolve_row_for_preview` (H2, fara clasificator duplicat),
iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel
confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None)."""
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return result, None
row = next((r for r in result["rows"] if r["row_index"] == row_index), None)
return result, row
def _render_preview_rand(
request: Request, *, import_id: int, row: dict, editing: bool,
include_oob: bool, summary: dict, message: str | None = None,
) -> HTMLResponse:
return templates.TemplateResponse("_preview_rand.html", {
"request": request,
"import_id": import_id,
"row": row,
"editing": editing,
"include_oob": include_oob,
"summary": summary,
"message": message,
"csrf_token": get_csrf_token(request),
})
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu, D-3.3)."""
account_id = require_login(request)
conn = get_connection()
try:
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
return _render_preview_rand(
request, import_id=import_id, row=row, editing=True,
include_oob=False, summary=result["summary"],
)
finally:
conn.close()
@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse)
def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare."""
account_id = require_login(request)
conn = get_connection()
try:
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
return _render_preview_rand(
request, import_id=import_id, row=row, editing=False,
include_oob=True, summary=result["summary"],
)
finally:
conn.close()
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Alias web (US-001/US-002): persista override (mutatie pura) + re-randeaza DOAR randul.
Statusul e rederivat prin `_resolve_row_for_preview` (H2). Swap pe rand + OOB
contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare
cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2)."""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
fields: dict[str, str | None] = {
camp: (str(form.get(camp)) if form.get(camp) is not None else None)
for camp in EDIT_FIELDS
}
conn = get_connection()
try:
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
# pastreaza randul + valorile la 4xx/5xx).
apply_row_override(
conn, import_id=import_id, account_id=account_id,
row_index=row_index, fields=fields,
)
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
field_errors = [
e for e in (row.get("errors") or [])
if isinstance(e, dict) and e.get("field")
]
if field_errors:
return _render_preview_rand(
request, import_id=import_id, row=row, editing=True,
include_oob=True, summary=result["summary"],
message="Mai sunt valori invalide — corecteaza campurile marcate.",
)
return _render_preview_rand(
request, import_id=import_id, row=row, editing=False,
include_oob=True, summary=result["summary"],
)
finally:
conn.close()
@router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse)
async def web_mapare_operatie(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Mapeaza o operatie nemapata din preview-ul de import la un cod RAR, in flux.
Salveaza maparea (persistenta, operations_mapping) si re-randeaza preview-ul:
_web_compute_preview recalculeaza cu noua mapare si re-scrie resolved_status in
import_rows, deci randurile afectate trec din needs_mapping in ok fara re-upload.
"""
account_id = require_login(request)
conn = get_connection()
try:
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 = bool(form.get("auto_send"))
def _render(message: str | None = None, error: bool = False) -> HTMLResponse:
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=message, error=error, **result
))
if not cod_op_service or not cod_prestatie:
return _render("Alege un cod RAR pentru operatie.", error=True)
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
).fetchone()
if not exists:
return _render(f"Cod RAR necunoscut: {cod_prestatie}", error=True)
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send)
return _render(f"Mapat {cod_op_service} -> {cod_prestatie}.")
finally:
conn.close()
@router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
return templates.TemplateResponse("_upload.html", {
"request": request,
"csrf_token": get_csrf_token(request),
})
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
async def web_confirma_import(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Gate HARD confirmare + enqueue randuri ok + log atestare. Intoarce fragment HTML.
Replica logica din import_router.commit_import dar cu input din form HTML
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
"""
account_id = require_login(request)
acct = account_or_default(account_id)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
try:
n_confirmat = int(str(form.get("n_confirmat") or "0"))
except (ValueError, TypeError):
n_confirmat = 0
# Randuri needs_review bifate explicit
reviewed_rows: set[int] = set()
for v in form.getlist("reviewed_rows"):
if isinstance(v, str):
try:
reviewed_rows.add(int(v))
except (ValueError, TypeError):
pass
confirmed_by = str(form.get("confirmed_by") or "").strip() or None
conn = get_connection()
try:
batch = conn.execute(
"SELECT id, filename, status FROM import_batches WHERE id=? AND account_id=?",
(import_id, acct),
).fetchone()
if not batch:
return templates.TemplateResponse("_upload.html", _ctx(
request, error="Batch de import inexistent sau expirat."
))
if batch["status"] == "committed":
return templates.TemplateResponse("_upload.html", _ctx(
request, message="Acest batch a fost deja comis."
))
# Incarca randurile cu stare ok si needs_review
ok_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
(import_id,),
).fetchall()
def _override_of(r) -> dict:
return (decrypt_creds(r["override_json"]) if r["override_json"] else None) or {}
if not ok_rows_db:
# Re-arata preview cu eroare
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request,
import_id=import_id,
message="Niciun rand ok de confirmat in acest batch.",
error=True,
**result,
))
# Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = []
review_indices: set[int] = set()
for r in ok_rows_db:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
except Exception:
continue
if r["resolved_status"] == "ok":
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "ok"})
elif r["resolved_status"] == "needs_review":
review_indices.add(r["row_index"])
# Adauga randurile needs_review bifate explicit
for r in ok_rows_db:
if r["resolved_status"] == "needs_review" and r["row_index"] in reviewed_rows:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "needs_review"})
except Exception:
pass
n_total_ok = len(to_enqueue)
# Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis
if n_confirmat != n_total_ok:
result = _web_compute_preview(conn, import_id, account_id)
msg = (
f"Numarul confirmat ({n_confirmat}) difera de randurile gata de trimis ({n_total_ok}). "
f"Verifica preview-ul si retasteaza numarul corect."
)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=msg, error=True, **result
))
if n_total_ok == 0:
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request,
import_id=import_id,
message="Niciun rand ok de confirmat.",
error=True,
**result,
))
# Incarca maparea de coloane pentru payload
first_row_db = conn.execute(
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
(import_id,),
).fetchone()
col_names: list[str] = []
if first_row_db:
try:
fd = decrypt_creds(first_row_db["raw_json"]) or {}
col_names = list(fd.keys())
except Exception:
pass
sig = _signature(col_names) if col_names else ""
mapping_row = conn.execute(
"SELECT json_mapare, format_data FROM column_mappings WHERE account_id=? AND signature_coloane=?",
(acct, sig),
).fetchone()
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) if mapping_row else {}
fmt = mapping_row["format_data"] if mapping_row else None
# Mapare operatii
mapping_meta = load_mapping_meta(conn, acct)
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU)
enqueued: list[dict] = []
toctou: list[int] = []
rows_for_hash: list[str] = []
conn.execute("BEGIN IMMEDIATE")
try:
for item in to_enqueue:
row_dict = item["data"]
row_index = item["row_index"]
# Aplica maparea de coloane
mapped: dict[str, Any] = {}
for col_f, camp_c in json_mapare.items():
if col_f in row_dict and camp_c:
mapped[camp_c] = row_dict[col_f]
# Rezolva data
for col_f, camp_c in json_mapare.items():
if camp_c == "data_prestatie":
col_fmt = fmt or "ambiguous"
raw_date = mapped.get("data_prestatie")
if raw_date is not None:
iso_date, _ = parse_date_value(raw_date, col_fmt)
if iso_date:
mapped["data_prestatie"] = iso_date
break
# Operatia → prestatii (denumire_op alimenteaza denumirea reala)
operatie_val = mapped.pop("operatie", None)
denumire_val = mapped.pop("denumire_op", None)
if operatie_val and "prestatii" not in mapped:
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Rezolva prestatii
prestatii = mapped.get("prestatii") or []
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules)
mapped["prestatii"] = resolved_p
# Canonicalizare
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
override = item.get("override") or {}
if override:
mapped.update(override)
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
key = build_key(account_id, canon)
rows_for_hash.append(json.dumps({
"row_index": row_index,
"vin": mapped.get("vin"),
"data_prestatie": mapped.get("data_prestatie"),
"odometru_final": mapped.get("odometru_final"),
"prestatii": [
str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
for p in resolved_p
],
}, sort_keys=True, ensure_ascii=False))
cur = conn.execute(
"INSERT OR IGNORE INTO submissions "
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'))",
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index),
)
if cur.rowcount == 0:
toctou.append(row_index)
else:
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p)
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
n_enqueued = len(enqueued)
# Log atestare (Voce#9)
rows_hash = hashlib.sha256(
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
).hexdigest() if rows_for_hash else ""
conn.execute(
"INSERT INTO import_attestations (batch_id, account_id, confirmed_by, rows_hash, n_confirmed) "
"VALUES (?, ?, ?, ?, ?)",
(import_id, acct, confirmed_by, rows_hash, n_enqueued),
)
conn.execute(
"UPDATE import_batches SET status='committed', ok=? WHERE id=?",
(n_enqueued, import_id),
)
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True:
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie
# sectiunea "Trimiterile tale" de pe Acasa (US-003/US-004).
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", _ctx(
request,
are_trimiteri=True,
message=(
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
),
))
finally:
conn.close()
# =========================================================================== #
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
# care cere cheie API; sesiunea web e suficienta ca identitate). #
# =========================================================================== #
def _render_cont(
request: Request,
*,
api_key: str | None = None,
are_creds: bool = False,
creds_mesaj: str | None = None,
creds_eroare: str | None = None,
rot_eroare: str | None = None,
) -> HTMLResponse:
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
return templates.TemplateResponse(
"_cont.html",
_ctx(
request,
api_key=api_key,
are_creds=are_creds,
creds_mesaj=creds_mesaj,
creds_eroare=creds_eroare,
rot_eroare=rot_eroare,
),
)
@router.get("/_fragments/cont", response_class=HTMLResponse)
def fragment_cont(request: Request) -> HTMLResponse:
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
account_id = require_login(request)
acct = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, are_creds=are_creds)
finally:
conn.close()
@router.post("/cont/roteste-cheie", response_class=HTMLResponse)
def cont_roteste_cheie(
request: Request,
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Revoca toate cheile active si emite una noua. Afisata O SINGURA DATA."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
new_key = rotate_api_key(conn, acct)
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, api_key=new_key, are_creds=are_creds)
finally:
conn.close()
@router.post("/integrare/test-cheie", response_class=HTMLResponse)
def integrare_test_cheie(
request: Request,
api_key: str = Form(""),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata
din generatorul de exemple corespunde contului sau, fara efecte secundare
(fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de
eroare neutru (fara eco al cheii in raspuns).
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
# Validare cheie goala / doar spatii -> eroare inainte de DB
cheie = (api_key or "").strip()
if not cheie:
return templates.TemplateResponse(
"_integrare_test_rezultat.html",
{"request": request, "succes": False, "mesaj": "Cheia este goala sau lipseste."},
)
conn = get_connection()
try:
from ..auth import account_for_key
cont_cheie = account_for_key(conn, cheie)
finally:
conn.close()
if cont_cheie is None:
# Cheie invalida, inexistenta sau revocata
return templates.TemplateResponse(
"_integrare_test_rezultat.html",
{"request": request, "succes": False,
"mesaj": "Cheie invalida sau revocata — nu a fost gasita in sistem."},
)
if cont_cheie != acct:
# Cheie valida dar apartine altui cont — mesaj neutru, fara dezvaluire cont terta
return templates.TemplateResponse(
"_integrare_test_rezultat.html",
{"request": request, "succes": False,
"mesaj": "Cheia nu apartine contului tau."},
)
# Succes — cheia e activa si corespunde contului sesiunii
return templates.TemplateResponse(
"_integrare_test_rezultat.html",
{"request": request, "succes": True,
"mesaj": f"Cheie valida — cont {acct}."},
)
@router.post("/cont/rar-creds", response_class=HTMLResponse)
def cont_rar_creds(
request: Request,
rar_email: str = Form(""),
rar_parola: str = Form(""),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13).
Camp parola NICIODATA re-pus in value= la re-randare.
Validare minima: email si parola negoale.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
email = rar_email.strip()
parola = rar_parola.strip()
if not email or not parola:
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
creds_eroare="Email si parola sunt obligatorii.",
)
enc = encrypt_creds({"email": email, "password": parola})
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(enc, acct),
)
return _render_cont(
request,
are_creds=True,
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
)
finally:
conn.close()