VERIFY PASS pe corpus k-NN exemple etichetate (seed real 17181 Haiku, comis
in 756f777): suita 1392 passed, 1 deselected (live); smoke init_db seeder
(17181/NUL=2200/idempotent); toate codurile in nomenclator.
US-007 (cerere user la CLOSE) — badge sursa pe sugestia fuzzy din editor:
- _mapari.html: chip confirmat (GOLD) / similar (SILVER+k-NN) / non-operatie (NUL)
- base.html: .sugg-sursa--{confirmat,similar,nul} pe tokeni de tema (color-mix)
- routes.py: cheia `nul` adaugata in surse_sugestie default (finding cross-file)
- tests/test_web_badge_sursa.py: gold/silver/nul/fara-sursa (4 teste)
- E2E render live verificat in serverul real (/_fragments/mapari)
CLOSE /code-review high (main..HEAD, 3 finder x 8 unghiuri) — runtime curat,
invariant #13 intact; 3 findings low/cosmetic REPARATE + lock-uite:
- shared_store.seed_suggestions: cod whitespace -> NULL (era ''), + test lock
- genereaza_seed.py: with open(...) in loc de open().read() (FD leak tool offline)
- embeddings.py: docstring-uri aliniate la [{cod, is_nul, similaritate}]
ROADMAP: 5.18 LIVRAT. PRD: raport VERIFY/CLOSE scris.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4277 lines
180 KiB
Python
4277 lines
180 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.
|
|
|
|
Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
|
|
Consuma helper-e interne din import_router fara a le modifica. Toate rutele /_import/*
|
|
returneaza fragmente HTML targetate pe #import-section prin HTMX.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re as _re
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from .. import __version__
|
|
from .. import errors as _errors
|
|
from ..auth import rotate_api_key
|
|
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
|
|
from ..payload_view import prezentare_din_payload
|
|
from ..web.csrf import get_csrf_token, verify_csrf
|
|
from .labels import (
|
|
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
|
STARI_PREVIEW,
|
|
eticheta_rar,
|
|
eticheta_scurta,
|
|
eticheta_stare,
|
|
eticheta_worker,
|
|
format_data_rar,
|
|
motiv_uman,
|
|
nota_umana_preview,
|
|
parse_erori,
|
|
)
|
|
from ..web.session import LoginRequired, require_login
|
|
from ..api.v1.import_router import (
|
|
_already_sent_lookup,
|
|
_build_idempotency_key,
|
|
_CANONICAL_SYNONYMS,
|
|
_fuzzy_suggest_column,
|
|
_resolve_row_for_preview,
|
|
_signature,
|
|
apply_row_override,
|
|
EDIT_FIELDS,
|
|
)
|
|
from ..config import get_settings
|
|
from ..crypto import decrypt_creds, encrypt_creds
|
|
from ..db import get_connection, read_app_events, read_heartbeat
|
|
from ..observ import log_event
|
|
from ..idempotency import build_key, canonicalize_row
|
|
from ..validation import validate_prezentare
|
|
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
|
|
from ..users import is_account_admin
|
|
from ..submissions_admin import (
|
|
SubmissionNotFound,
|
|
SubmissionStateConflict,
|
|
delete_submission,
|
|
requeue_submission,
|
|
)
|
|
from ..mapping import (
|
|
DEFAULT_ACCOUNT_ID,
|
|
_emite_text_rule_hits,
|
|
account_or_default,
|
|
account_scope_clause,
|
|
delete_text_rule,
|
|
enrich_suggestions,
|
|
ensure_embeddings_corpus,
|
|
has_no_auto_send,
|
|
load_mapping_meta,
|
|
load_nomenclator,
|
|
load_nomenclator_codes,
|
|
load_text_rules,
|
|
normalize_for_match,
|
|
pending_unmapped,
|
|
reresolve_account,
|
|
resolve_prestatii,
|
|
save_mapping,
|
|
save_text_rule,
|
|
suggest_codes,
|
|
text_rules_overlap,
|
|
)
|
|
from ..shared_store import record_human_validation
|
|
|
|
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
|
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
|
|
|
router = APIRouter(tags=["web"])
|
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
|
# Expune parse_erori in toate template-urile
|
|
templates.env.globals["parse_erori"] = parse_erori
|
|
|
|
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _record_gold_validation(conn, denumire: str | None, cod_op_service: str,
|
|
cod_prestatie: str, provenance: str) -> None:
|
|
"""Scrie GOLD partajat (shared_mappings) DOAR cand denumirea umana e reala.
|
|
|
|
shared_mappings e cheiat pe `denumire_normalizata` (text uman din prezentari).
|
|
`cod_op_service` e codul INTERN al operatiei, NU denumirea — a-l scrie ca si cheie
|
|
polueaza GOLD cu intrari pe care `lookup_shared_gold` (cauta pe denumirea umana) nu
|
|
le potriveste niciodata. Sarim scrierea cand denumirea lipseste sau == cod_op_service.
|
|
Best-effort: confirmarea GOLD nu blocheaza fluxul principal.
|
|
"""
|
|
den = (denumire or "").strip()
|
|
if not den or den == (cod_op_service or "").strip():
|
|
return
|
|
try:
|
|
record_human_validation(conn, den, cod_prestatie, provenance=provenance)
|
|
except Exception:
|
|
pass
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Analytics device-mix (US-012, PRD 5.15) #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
_UA_MOBIL = _re.compile(
|
|
r"Mobile|Android|iPhone|iPad|iPod|BlackBerry|Windows Phone|webOS",
|
|
_re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _clasificare_device(user_agent: str) -> str:
|
|
"""Clasifica grosier un User-Agent in 'mobil' sau 'desktop'.
|
|
|
|
Regex pe markeri standard (Mobile/Android/iPhone/iPad/iPod/BlackBerry/
|
|
Windows Phone/webOS) — suficient pentru a valida premisa de utilizare mobil.
|
|
Nicio librarie externa noua.
|
|
"""
|
|
if _UA_MOBIL.search(user_agent or ""):
|
|
return "mobil"
|
|
return "desktop"
|
|
|
|
|
|
def _log_device_mix(request: Request, account_id: int | None) -> None:
|
|
"""Inregistreaza semnalul agregat de device-mix in app_events.
|
|
|
|
Stocheaza DOAR eticheta grosiera ('desktop'/'mobil') in campul `cod`.
|
|
NU stocheaza UA brut, IP sau alte PII suplimentare.
|
|
|
|
Citire raport agregat (SQL):
|
|
SELECT cod, COUNT(*) AS n
|
|
FROM app_events
|
|
WHERE tip='device_mix'
|
|
GROUP BY cod;
|
|
|
|
Sau cu evolutie zilnica:
|
|
SELECT date(ts) AS zi, cod, COUNT(*) AS n
|
|
FROM app_events
|
|
WHERE tip='device_mix'
|
|
GROUP BY zi, cod
|
|
ORDER BY zi DESC;
|
|
"""
|
|
ua = request.headers.get("user-agent", "")
|
|
clasificare = _clasificare_device(ua)
|
|
log_event(
|
|
"device_mix",
|
|
nivel="INFO",
|
|
account_id=account_id,
|
|
cod=clasificare,
|
|
mesaj=clasificare, # doar eticheta — nu UA brut
|
|
)
|
|
|
|
|
|
def _ctx(request: Request, **extra) -> dict:
|
|
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
|
|
|
|
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
|
|
trebuie sa includa csrf_token negol altfel urmatorul submit da 403.
|
|
"""
|
|
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
|
|
|
|
|
|
def _status_counts(conn, account_id: int) -> dict[str, int]:
|
|
rows = conn.execute(
|
|
"SELECT status, COUNT(*) AS n FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
|
"GROUP BY status",
|
|
(account_id, account_id),
|
|
).fetchall()
|
|
counts = {r["status"]: int(r["n"]) for r in rows}
|
|
|
|
# sent_today si sent_month — bucketare in TIMP LOCAL RO (E7 CRITIC).
|
|
# updated_at e stocat ca datetime('now') UTC; date(updated_at) pur ar bucketiza
|
|
# trimiterile dintre miezul noptii local (21:xx-24:xx UTC) pe ziua gresita.
|
|
# Folosim modificatorul SQLite 'localtime' (DST-aware) in loc de offset fix '+3 hours':
|
|
# RO e UTC+2 (EET) iarna si UTC+3 (EEST) vara; un '+3 hours' fix gresea cu 1h iarna
|
|
# (ex. 21:30 UTC iarna = 23:30 RO azi, dar +3h = 00:30 maine -> ziua gresita).
|
|
# Presupune TZ=Europe/Bucharest in mediul procesului (docker-compose/Dockerfile).
|
|
row = conn.execute(
|
|
"SELECT "
|
|
" COUNT(CASE WHEN date(updated_at, 'localtime') = date('now', 'localtime') THEN 1 END) AS sent_today, "
|
|
" COUNT(CASE WHEN strftime('%Y-%m', updated_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime') THEN 1 END) AS sent_month "
|
|
"FROM submissions "
|
|
"WHERE status = 'sent' AND (account_id = ? OR (? = 1 AND account_id IS NULL))",
|
|
(account_id, account_id),
|
|
).fetchone()
|
|
if row:
|
|
counts["sent_today"] = int(row["sent_today"] or 0)
|
|
counts["sent_month"] = int(row["sent_month"] or 0)
|
|
else:
|
|
counts["sent_today"] = 0
|
|
counts["sent_month"] = 0
|
|
return counts
|
|
|
|
|
|
def _trimiteri_versiune(conn, account_id: int) -> str:
|
|
"""Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent
|
|
updated_at. Se schimba la orice insert/update/delete -> nudge-ul "Date noi" o compara
|
|
fara a re-randa tabelul."""
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), '') AS m FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL))",
|
|
(account_id, account_id),
|
|
).fetchone()
|
|
return f"{row['n']}:{row['m']}"
|
|
|
|
|
|
def _account_active(conn, account_id: int) -> bool:
|
|
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
|
|
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
return bool(row["active"]) if row else True
|
|
|
|
|
|
def _worker_alive(hb) -> bool:
|
|
if hb is None or not hb["last_beat"]:
|
|
return False
|
|
try:
|
|
last = datetime.fromisoformat(hb["last_beat"])
|
|
except ValueError:
|
|
return False
|
|
age = (datetime.now(timezone.utc) - last).total_seconds()
|
|
return age <= get_settings().worker_heartbeat_stale_s
|
|
|
|
|
|
def _rar_state(hb, worker_alive: bool) -> str:
|
|
"""Eticheta de disponibilitate RAR, derivata din ultimul login reusit.
|
|
|
|
Nu interogam RAR live aici (dashboard-ul degradeaza la ultima stare cunoscuta
|
|
a cozii). JWT TTL = 30h: un login mai vechi de atat inseamna ca nu mai stim
|
|
sigur ca RAR raspunde -> "indisponibil?". Fara niciun login -> necunoscut.
|
|
"""
|
|
if not worker_alive:
|
|
return "necunoscut (worker oprit)"
|
|
last = hb["last_rar_login_ok"] if hb else None
|
|
if not last:
|
|
return "fara login reusit inca"
|
|
try:
|
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
|
except (ValueError, TypeError):
|
|
return "necunoscut"
|
|
return "indisponibil?" if age > 108000 else "ok"
|
|
|
|
|
|
# "import" si "coada" nu mai sunt tab-uri separate — importul si Trimiterile sunt
|
|
# sectiuni pe Acasa. ?tab=import / ?tab=coada cad pe Acasa (fallback in dashboard()),
|
|
# fara 404 si fara fragment orfan.
|
|
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
|
|
|
|
|
|
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
|
"""Calculeaza contextul pentru panoul Acasa.
|
|
|
|
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
|
"""
|
|
from ..mapping import account_or_default
|
|
from ..accounts import account_is_complete as _acct_is_complete
|
|
acct = account_or_default(account_id)
|
|
|
|
# Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet)
|
|
row = conn.execute(
|
|
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
# Banner cont incomplet (US-002): contul nu are companie + email + CUI complete
|
|
cont_incomplet = not _acct_is_complete(row) if row else False
|
|
|
|
# Pas 3: are cel putin un submission (trimis sau in coada)?
|
|
row_sub = conn.execute(
|
|
"SELECT 1 FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1",
|
|
(acct, acct),
|
|
).fetchone()
|
|
are_trimiteri = row_sub is not None
|
|
|
|
# Pas 2 (optional): are cheie API activa?
|
|
row_key = conn.execute(
|
|
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1",
|
|
(acct,),
|
|
).fetchone()
|
|
are_cheie_folosita = row_key is not None
|
|
|
|
# Contorul de atentie (blocate) se reflecta in heading-ul sectiunii
|
|
# "Trimiterile tale" de pe Acasa.
|
|
counts = _status_counts(conn, account_id)
|
|
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
|
|
|
return {
|
|
"request": request,
|
|
"are_creds": are_creds,
|
|
"are_trimiteri": are_trimiteri,
|
|
"are_cheie_folosita": are_cheie_folosita,
|
|
"blocate_total": blocate_total,
|
|
# Pill-uri de filtrare a starii, randate in bara de filtre (nu in bara de status).
|
|
"pills_categorii": _pills_categorii(counts),
|
|
# Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor.
|
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
|
# Acasa include caseta de upload -> are nevoie de csrf_token
|
|
"csrf_token": get_csrf_token(request),
|
|
# Banner ne-blocant (US-002): contul nu are identitate completa (companie+email+CUI)
|
|
"cont_incomplet": cont_incomplet,
|
|
}
|
|
|
|
|
|
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
|
|
"""Randeaza panoul Acasa ca string HTML.
|
|
|
|
`status`: deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in
|
|
sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
|
|
"""
|
|
if conn is None:
|
|
return templates.get_template("_acasa.html").render(
|
|
{"request": request, "csrf_token": get_csrf_token(request)}
|
|
)
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
# `status or ""`: campul hidden de filtru ar randa literal "None" cu un None Python
|
|
# (Jinja `default('')` inlocuieste doar undefined), trimitand status=None la poll.
|
|
ctx["status_filtru"] = status or ""
|
|
return templates.get_template("_acasa.html").render(ctx)
|
|
|
|
|
|
def _render_panel_import(request: Request) -> str:
|
|
"""Randeaza panoul Import ca string HTML (include _upload.html)."""
|
|
return templates.get_template("_upload.html").render({
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str:
|
|
""""coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt
|
|
sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
|
|
return _render_panel_acasa(request, conn, account_id)
|
|
|
|
|
|
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Mapari ca string HTML (3 sectiuni: de rezolvat / op salvate / formate)."""
|
|
return templates.get_template("_mapari.html").render({
|
|
"request": request,
|
|
"pending": pending_unmapped(conn, account_id),
|
|
"saved_mappings": _load_saved_op_mappings(conn, account_id),
|
|
"column_formats": _load_column_formats(conn, account_id),
|
|
"text_rules": load_text_rules(conn, account_id),
|
|
"nomenclator": load_nomenclator(conn),
|
|
"message": None,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Cont ca string HTML."""
|
|
from ..mapping import account_or_default
|
|
acct = account_or_default(account_id)
|
|
row = conn.execute(
|
|
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
cont_ctx = {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"api_key": None,
|
|
"are_creds": are_creds,
|
|
"creds_mesaj": None,
|
|
"creds_eroare": None,
|
|
"rot_eroare": None,
|
|
"account_meta": account_meta,
|
|
"date_firma_mesaj": None,
|
|
"date_firma_eroare": None,
|
|
}
|
|
# US-006 (5.17): context plan pentru sectiunea Plan din _cont.html.
|
|
cont_ctx.update(_plan_ctx(conn, account_id))
|
|
return templates.get_template("_cont.html").render(cont_ctx)
|
|
|
|
|
|
def _render_panel_nomenclator(request: Request, conn) -> str:
|
|
"""Randeaza panoul Nomenclator ca string HTML."""
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return templates.get_template("_nomenclator.html").render({
|
|
"request": request,
|
|
"rows": rows,
|
|
})
|
|
|
|
|
|
def _render_integrare(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Integrare ca string HTML (hub documentatie + exemple cod).
|
|
|
|
Calculeaza are_cheie (chei API active pe cont) si are_creds (credentiale RAR
|
|
configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj.
|
|
"""
|
|
from ..mapping import account_or_default
|
|
from .integrare_examples import exemple as _exemple
|
|
|
|
acct = account_or_default(account_id)
|
|
row_creds = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row_creds and row_creds["rar_creds_enc"])
|
|
|
|
row_key = conn.execute(
|
|
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,)
|
|
).fetchone()
|
|
are_cheie = row_key is not None
|
|
|
|
base_url = str(request.base_url).rstrip("/")
|
|
ex = _exemple(base_url, acct)
|
|
csrf_token = get_csrf_token(request)
|
|
|
|
return templates.get_template("_integrare.html").render({
|
|
"request": request,
|
|
"account_id": acct,
|
|
"base_url": base_url,
|
|
"exemple": ex,
|
|
"are_cheie": are_cheie,
|
|
"are_creds": are_creds,
|
|
"csrf_token": csrf_token,
|
|
})
|
|
|
|
|
|
_JURNAL_PAGE_SIZE = 50
|
|
|
|
|
|
def _jurnal_context(
|
|
request: Request, conn, account_id: int, *,
|
|
tip: str | None = None, nivel: str | None = None,
|
|
data_de: str | None = None, data_pana: str | None = None,
|
|
cont: str | None = None, page: int = 0,
|
|
) -> dict:
|
|
"""Context pentru tab-ul Jurnal: evenimente paginate + filtre + scope.
|
|
|
|
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
|
|
contului sau (regula NULL->cont 1, ca restul UI-ului).
|
|
"""
|
|
admin = is_account_admin(conn, account_id)
|
|
tip = (tip or "").strip() or None
|
|
nivel = (nivel or "").strip() or None
|
|
data_de = (data_de or "").strip() or None
|
|
data_pana = (data_pana or "").strip() or None
|
|
page = max(0, page)
|
|
|
|
if admin:
|
|
cont_filtru = None
|
|
if cont and str(cont).strip():
|
|
try:
|
|
cont_filtru = int(cont)
|
|
except (ValueError, TypeError):
|
|
cont_filtru = None
|
|
scope_account = cont_filtru # None = toate conturile
|
|
else:
|
|
scope_account = account_or_default(account_id)
|
|
|
|
offset = page * _JURNAL_PAGE_SIZE
|
|
rows = read_app_events(
|
|
conn, account_id=scope_account, tip=tip, nivel=nivel,
|
|
date_from=data_de, date_to=data_pana,
|
|
limit=_JURNAL_PAGE_SIZE + 1, offset=offset,
|
|
)
|
|
has_more = len(rows) > _JURNAL_PAGE_SIZE
|
|
rows = rows[:_JURNAL_PAGE_SIZE]
|
|
|
|
evenimente = []
|
|
for r in rows:
|
|
evenimente.append({
|
|
"ts": format_data_rar(r["ts"]),
|
|
"sursa": r["sursa"],
|
|
"tip": r["tip"],
|
|
"nivel": r["nivel"],
|
|
"account_id": r["account_id"],
|
|
"cod": r["cod"],
|
|
"mesaj": r["mesaj"],
|
|
})
|
|
tipuri = [r["tip"] for r in conn.execute("SELECT DISTINCT tip FROM app_events ORDER BY tip").fetchall()]
|
|
|
|
return {
|
|
"request": request,
|
|
"evenimente": evenimente,
|
|
"tipuri": tipuri,
|
|
"is_admin": admin,
|
|
"f_tip": tip or "",
|
|
"f_nivel": nivel or "",
|
|
"f_data_de": data_de or "",
|
|
"f_data_pana": data_pana or "",
|
|
"f_cont": (cont or "") if admin else "",
|
|
"page": page,
|
|
"has_more": has_more,
|
|
"prev_page": page - 1 if page > 0 else None,
|
|
"next_page": page + 1 if has_more else None,
|
|
}
|
|
|
|
|
|
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Jurnal ca string HTML."""
|
|
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
|
|
|
|
|
|
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, status: str | None = None) -> str:
|
|
"""Randeaza panoul corespunzator unui tab ca string HTML."""
|
|
if tab == "acasa":
|
|
return _render_panel_acasa(request, conn, account_id, status=status)
|
|
if tab == "jurnal":
|
|
return _render_panel_jurnal(request, conn, account_id)
|
|
if tab == "import":
|
|
return _render_panel_import(request)
|
|
if tab == "coada":
|
|
return _render_panel_coada(request, conn, account_id)
|
|
if tab == "mapari":
|
|
return _render_panel_mapari(request, conn, account_id)
|
|
if tab == "cont":
|
|
return _render_panel_cont(request, conn, account_id)
|
|
if tab == "nomenclator":
|
|
return _render_panel_nomenclator(request, conn)
|
|
if tab == "integrare":
|
|
return _render_integrare(request, conn, account_id)
|
|
return _render_panel_acasa(request)
|
|
|
|
|
|
# Etichete tier pentru badge in antet (US-010 PRD 5.16).
|
|
_TIER_LABELS: dict[str, str] = {
|
|
"free": "Gratuit",
|
|
"standard": "Standard",
|
|
"pro": "Pro",
|
|
"premium": "Premium",
|
|
}
|
|
|
|
|
|
def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict:
|
|
"""Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger.
|
|
|
|
Returneaza:
|
|
plan_linie — linie completa cu copy RO (cele 6 stari)
|
|
plan_warn — True la >=80% consum sau limita atinsa (culoare + text)
|
|
plan_limita_atinsa — True la 100% consum (--err in loc de --warn)
|
|
trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time)
|
|
usage_lunar — numar prestatii acceptate in coada luna curenta
|
|
monthly_limit_val — limita lunara (60 pt free, None pt nelimitat)
|
|
effective_tier_name — tier-ul efectiv ('free','standard','pro','premium')
|
|
"""
|
|
if now is None:
|
|
now = datetime.now(timezone.utc)
|
|
|
|
acct = account_or_default(account_id)
|
|
row = conn.execute(
|
|
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
|
|
tier_base = (row["tier"] if row else None) or "free"
|
|
trial_until_str = (row["trial_until"] if row else None)
|
|
|
|
eff = _eff_tier(row, now) if row else "free"
|
|
monthly_limit = _PLANS.get(eff, _PLANS["free"]).get("monthly_limit")
|
|
|
|
usage = _monthly_usage(conn, acct, now)
|
|
|
|
# Calcul zile ramase din trial activ
|
|
trial_ultima_zi = False
|
|
trial_days: int | None = None
|
|
if trial_until_str and eff == "pro" and tier_base == "free":
|
|
try:
|
|
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
|
|
if tu.tzinfo is None:
|
|
tu = tu.replace(tzinfo=timezone.utc)
|
|
now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc)
|
|
delta = tu - now_cmp
|
|
trial_days = delta.days # 0 = < 1 zi ramasa (azi), 1 = < 2 zile, etc.
|
|
trial_ultima_zi = (trial_days <= 0)
|
|
except (ValueError, AttributeError, TypeError):
|
|
pass
|
|
|
|
# Construieste plan_linie si stari aferente (cele 6 stari din PRD)
|
|
warn_aproape = False
|
|
plan_limita_atinsa = False
|
|
trial_expirat_recent = False
|
|
|
|
if eff == "pro" and tier_base == "free" and trial_until_str:
|
|
# Trial Pro activ
|
|
if trial_ultima_zi:
|
|
plan_linie = "Plan: Pro · trial expira azi"
|
|
else:
|
|
n = trial_days or 0
|
|
z = "zi" if n == 1 else "zile"
|
|
plan_linie = f"Plan: Pro · trial {n} {z} ramase"
|
|
elif eff == "free":
|
|
# Free — cu sau fara trial expirat recent
|
|
if trial_until_str:
|
|
trial_expirat_recent = True
|
|
if monthly_limit is not None:
|
|
if usage >= monthly_limit:
|
|
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — limita atinsa"
|
|
warn_aproape = True
|
|
plan_limita_atinsa = True
|
|
elif monthly_limit > 0 and usage >= int(monthly_limit * 0.8):
|
|
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — aproape de limita"
|
|
warn_aproape = True
|
|
else:
|
|
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} luna asta"
|
|
else:
|
|
plan_linie = "Plan: Gratuit"
|
|
else:
|
|
# Platit (tier de baza != free, ex. standard/pro/premium alocat de admin)
|
|
label = _PLANS.get(eff, {}).get("label", eff.capitalize())
|
|
plan_linie = f"Plan: {label}"
|
|
|
|
return {
|
|
"plan_linie": plan_linie,
|
|
"plan_warn": warn_aproape,
|
|
"plan_limita_atinsa": plan_limita_atinsa,
|
|
"trial_expirat_recent": trial_expirat_recent,
|
|
"usage_lunar": usage,
|
|
"monthly_limit_val": monthly_limit,
|
|
"effective_tier_name": eff,
|
|
}
|
|
|
|
|
|
def _layout_header_ctx(conn, account_id: int) -> dict:
|
|
"""Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16).
|
|
|
|
Citeste account_name, tier si starea de sanatate RAR pentru a popula:
|
|
- account_name: numele service-ului, afisat sub titlu cand logat
|
|
- tier_label: eticheta planului (Gratuit/Standard/Pro/Premium)
|
|
- sanatate_ok: True daca worker viu si RAR ok (dot verde in antet)
|
|
- last_login: data/ora ultimei autentificari RAR (format romanesc)
|
|
- plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger
|
|
|
|
Apelat aditiv din dashboard() fara a atinge alti handlere.
|
|
"""
|
|
row = conn.execute(
|
|
"SELECT name, tier FROM accounts WHERE id=?", (account_id,)
|
|
).fetchone()
|
|
account_name = (row["name"] if row else None) or ""
|
|
tier = (row["tier"] if row else "free") or "free"
|
|
tier_label = _TIER_LABELS.get(tier, "Gratuit")
|
|
|
|
hb = read_heartbeat(conn)
|
|
worker_alive = _worker_alive(hb)
|
|
rar_state = _rar_state(hb, worker_alive)
|
|
rar_ok = rar_state == "ok"
|
|
sanatate_ok = worker_alive and rar_ok
|
|
|
|
ctx = {
|
|
"account_name": account_name,
|
|
"tier_label": tier_label,
|
|
"sanatate_ok": sanatate_ok,
|
|
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
|
|
}
|
|
# US-006 (5.17): context plan pentru linia detaliata din meniul burger.
|
|
ctx.update(_plan_ctx(conn, account_id))
|
|
return ctx
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
|
|
"""Dashboard principal cu tab-uri.
|
|
|
|
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
|
|
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
|
|
Tab invalid -> fallback la 'acasa'. `?status=` pre-filtreaza lista Trimiteri de
|
|
pe Acasa (deep-link din banner-ul "Necesita atentia ta").
|
|
|
|
Vizitator neautentificat -> landing-ul comercial (in loc de redirect la /login),
|
|
cu formularele de inregistrare/autentificare care posteaza la /signup si /login.
|
|
"""
|
|
try:
|
|
account_id = require_login(request)
|
|
except LoginRequired:
|
|
return templates.TemplateResponse(
|
|
"landing.html",
|
|
{
|
|
"request": request,
|
|
"rar_env": get_settings().rar_env,
|
|
"version": __version__,
|
|
"csrf_token": get_csrf_token(request),
|
|
},
|
|
)
|
|
# US-012: semnal agregat de device-mix (fara PII)
|
|
_log_device_mix(request, account_id)
|
|
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
|
conn = get_connection()
|
|
try:
|
|
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
|
|
# Badge contoare pe tab-uri: needs_mapping -> Mapari. Blocatele se reflecta in
|
|
# heading-ul sectiunii Trimiteri.
|
|
counts = _status_counts(conn, account_id)
|
|
badges = {
|
|
"mapari": counts.get("needs_mapping", 0),
|
|
}
|
|
ctx = {
|
|
"request": request,
|
|
"rar_env": get_settings().rar_env,
|
|
"version": __version__,
|
|
"active_tab": active_tab,
|
|
"panel_html": panel_html,
|
|
"badges": badges,
|
|
"is_authenticated": True,
|
|
"is_admin": is_account_admin(conn, account_id),
|
|
"csrf_token": get_csrf_token(request),
|
|
}
|
|
# US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR).
|
|
# Adaugat aditiv, fara a atinge handlerele altora.
|
|
ctx.update(_layout_header_ctx(conn, account_id))
|
|
return templates.TemplateResponse("dashboard.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
|
def fragment_acasa(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Acasa."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.TemplateResponse("_acasa.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/import", response_class=HTMLResponse)
|
|
def fragment_import(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Import — include zona de upload."""
|
|
require_login(request)
|
|
return templates.TemplateResponse("_upload.html", _ctx(request))
|
|
|
|
|
|
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
|
def fragment_coada(request: Request) -> HTMLResponse:
|
|
""""coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt
|
|
sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din
|
|
bookmark-uri/HTMX vechi. Nu da 404."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.TemplateResponse("_acasa.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/nomenclator", response_class=HTMLResponse)
|
|
def fragment_nomenclator(request: Request) -> HTMLResponse:
|
|
"""Browser nomenclator RAR (cache local upsert-at de worker la fiecare login).
|
|
|
|
Necesita autentificare: este un fragment al dashboard-ului intern, nu un
|
|
endpoint public. Fara sesiune -> redirect /login (via require_login).
|
|
"""
|
|
require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return templates.TemplateResponse(
|
|
"_nomenclator.html", {"request": request, "rows": rows}
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/integrare", response_class=HTMLResponse)
|
|
def fragment_integrare(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Integrare (hub documentatie + exemple cod)."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
html = _render_integrare(request, conn, account_id)
|
|
return HTMLResponse(content=html)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/jurnal", response_class=HTMLResponse)
|
|
def fragment_jurnal(
|
|
request: Request,
|
|
tip: str | None = None,
|
|
nivel: str | None = None,
|
|
data_de: str | None = None,
|
|
data_pana: str | None = None,
|
|
cont: str | None = None,
|
|
page: int = 0,
|
|
) -> HTMLResponse:
|
|
"""Tab Jurnal: evenimente app_events paginate + filtre, scoped pe cont.
|
|
|
|
Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _jurnal_context(
|
|
request, conn, account_id,
|
|
tip=tip, nivel=nivel, data_de=data_de, data_pana=data_pana, cont=cont, page=page,
|
|
)
|
|
return templates.TemplateResponse("_jurnal.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/banner", response_class=HTMLResponse)
|
|
def fragment_banner(request: Request) -> HTMLResponse:
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
counts = _status_counts(conn, account_id)
|
|
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
|
return templates.TemplateResponse("_banner.html", {
|
|
"request": request,
|
|
"blocked": blocked,
|
|
"account_active": _account_active(conn, account_id),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
|
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
|
|
|
|
Ordinea: needs_mapping, needs_data, error. Returneaza lista goala daca nu
|
|
exista nicio stare blocata.
|
|
"""
|
|
rezultat = []
|
|
for status in ("needs_mapping", "needs_data", "error"):
|
|
n = counts.get(status, 0)
|
|
if n > 0:
|
|
rezultat.append((eticheta_stare(status), n))
|
|
return rezultat
|
|
|
|
|
|
def _pills_categorii(counts: dict[str, int]) -> list[dict]:
|
|
"""Pill-uri pentru starile cu problema.
|
|
|
|
Reutilizeaza contoarele deja calculate din _status_counts (fara PII/VIN per rand).
|
|
Returneaza lista goala daca nu exista nicio stare blocata.
|
|
"""
|
|
# Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
|
|
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
|
|
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
|
|
PILL_DEFS = [
|
|
("needs_mapping", "Lipsa cod", "--warn"),
|
|
("needs_data", "Date incomplete", "--err"),
|
|
("error", "Eroare", "--err"),
|
|
]
|
|
return [
|
|
{"status": status, "label": label, "color_var": color_var, "n": counts.get(status, 0)}
|
|
for status, label, color_var in PILL_DEFS
|
|
if counts.get(status, 0) > 0
|
|
]
|
|
|
|
|
|
def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = False, tab_activ: str = "acasa") -> dict:
|
|
"""Construieste dictionarul de context pentru _status.html.
|
|
|
|
Accepta o conexiune deja deschisa (nu deschide alta). Folosit de fragment_status
|
|
si de web_confirma_import (OOB swap dupa commit).
|
|
"""
|
|
counts = _status_counts(conn, account_id)
|
|
hb = read_heartbeat(conn)
|
|
worker_alive = _worker_alive(hb)
|
|
rar_state = _rar_state(hb, worker_alive)
|
|
worker_lbl = eticheta_worker(worker_alive)
|
|
rar_ok = rar_state == "ok"
|
|
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
|
|
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
|
|
|
# D6 (strip sanatate mereu-vizibil): text compus + stare verde/rosu
|
|
sanatate_ok = worker_alive and rar_ok
|
|
if not worker_alive:
|
|
sanatate_text = "Blocat: worker oprit — declaratiile NU pleaca"
|
|
elif not rar_ok:
|
|
sanatate_text = "Blocat: RAR inaccesibil — declaratiile NU pleaca"
|
|
else:
|
|
sanatate_text = "Declaratiile curg normal"
|
|
|
|
status_ctx = {
|
|
"request": request,
|
|
"worker_lbl": worker_lbl,
|
|
"rar_lbl": rar_lbl,
|
|
"worker_ok": worker_alive,
|
|
"rar_ok": rar_ok,
|
|
"sanatate_ok": sanatate_ok,
|
|
"sanatate_text": sanatate_text,
|
|
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
|
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
|
|
"counts_queued": counts.get("queued", 0),
|
|
"counts_sent": counts.get("sent", 0),
|
|
"sent_today": counts.get("sent_today", 0),
|
|
"sent_month": counts.get("sent_month", 0),
|
|
"blocate_total": blocate_total,
|
|
"blocate_defalcat": _blocate_defalcat(counts),
|
|
"pills_categorii": _pills_categorii(counts),
|
|
"account_active": _account_active(conn, account_id),
|
|
"tab_activ": tab_activ,
|
|
"mapari_badge": counts.get("needs_mapping", 0),
|
|
"oob": oob,
|
|
}
|
|
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
|
|
status_ctx.update(_plan_ctx(conn, account_id))
|
|
return status_ctx
|
|
|
|
|
|
@router.get("/_fragments/status", response_class=HTMLResponse)
|
|
def fragment_status(request: Request) -> HTMLResponse:
|
|
"""Bara de status persistenta cu etichete umane.
|
|
|
|
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
|
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
|
Logica in routes.py (nu in template) pentru testabilitate.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
tab_activ = request.query_params.get("tab", "acasa")
|
|
ctx = _build_status_ctx(request, conn, account_id, tab_activ=tab_activ)
|
|
return templates.TemplateResponse("_status.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/trimiteri-versiune", response_class=JSONResponse)
|
|
def fragment_trimiteri_versiune(request: Request) -> JSONResponse:
|
|
"""Semnatura curenta a trimiterilor contului (JSON usor). Pollerul "Date noi" o
|
|
compara cu versiunea cu care s-a randat tabelul; daca difera, arata nudge-ul de
|
|
reincarcare — tabelul nu se mai schimba singur."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
return JSONResponse({"v": _trimiteri_versiune(conn, account_id)})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _iso_date_prefix(value: object) -> str | None:
|
|
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
|
|
|
|
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
|
|
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
|
|
fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
|
|
(ex. '05.12.2024') intorc None si sunt excluse din filtru.
|
|
"""
|
|
s = str(value or "").strip()
|
|
if len(s) < 10:
|
|
return None
|
|
prefix = s[:10]
|
|
try:
|
|
datetime.strptime(prefix, "%Y-%m-%d")
|
|
return prefix
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
|
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
|
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _eticheta_problema(status: str, motiv: str) -> str:
|
|
"""Eticheta umana scurta a problemei pentru randul de tabel.
|
|
|
|
Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe
|
|
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (DRY, fara al
|
|
3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand.
|
|
|
|
Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*.
|
|
Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta
|
|
scurta -> fallback-ul garanteaza un text ne-gol chiar la `rar_error` lipsa/corupt.
|
|
"""
|
|
if status not in _STARI_CU_PROBLEMA:
|
|
return ""
|
|
return motiv or eticheta_scurta(status)
|
|
|
|
|
|
def _submission_row_view(r) -> dict:
|
|
"""Imbogateste un rand de submission cu campuri afisabile umane."""
|
|
eticheta = eticheta_stare(r["status"])
|
|
motiv = motiv_uman(r["status"], r["rar_error"])
|
|
return {
|
|
"id": r["id"],
|
|
"status": r["status"],
|
|
# pill = eticheta scurta; textul lung ramane ca tooltip (title=).
|
|
"stare_scurt": eticheta_scurta(r["status"]),
|
|
"stare_text": eticheta[0],
|
|
"stare_css": eticheta[2],
|
|
"prez": prezentare_din_payload(r["payload_json"]),
|
|
"id_prezentare": r["id_prezentare"],
|
|
"updated_at": format_data_rar(r["updated_at"]),
|
|
"motiv": motiv,
|
|
# eticheta umana scurta a problemei sub pill (text, nu cod brut).
|
|
"eticheta_problema": _eticheta_problema(r["status"], motiv),
|
|
# randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
|
|
# stergere bulk; sent/sending/queued raman read-only (fara checkbox).
|
|
"gestionabil": r["status"] in _GESTIONABILE_WEB,
|
|
}
|
|
|
|
|
|
_PAGE_SIZE = 25 # Marime pagina fixa
|
|
|
|
|
|
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
|
def fragment_submissions(
|
|
request: Request,
|
|
status: str | None = None,
|
|
vehicul: str | None = None,
|
|
data_de: str | None = None,
|
|
data_pana: str | None = None,
|
|
page: int = 1,
|
|
) -> HTMLResponse:
|
|
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare.
|
|
|
|
Totalul se calculeaza DIFERIT dupa tipul de filtru:
|
|
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
|
|
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
|
|
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
|
|
"""
|
|
account_id = require_login(request)
|
|
status = (status or "").strip() or None
|
|
vehicul_q = (vehicul or "").strip().upper() or None
|
|
data_de = (data_de or "").strip() or None
|
|
data_pana = (data_pana or "").strip() or None
|
|
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
|
|
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
|
|
|
|
page = max(1, page) # pre-clamp >= 1
|
|
|
|
conn = get_connection()
|
|
try:
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
where = [scope_sql]
|
|
params: list = list(scope_params)
|
|
if status:
|
|
where.append("status=?")
|
|
params.append(status)
|
|
where_sql = " AND ".join(where)
|
|
|
|
if filtru_python:
|
|
# Calea B: fetch-all, filtreaza in Python, slice.
|
|
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
|
|
rows_db = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
|
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
|
params,
|
|
).fetchall()
|
|
|
|
view_all: list[dict] = []
|
|
for r in rows_db:
|
|
v = _submission_row_view(r)
|
|
prez = v["prez"]
|
|
if vehicul_q:
|
|
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
|
|
if vehicul_q not in hay:
|
|
continue
|
|
if data_de or data_pana:
|
|
# Extragem portiunea YYYY-MM-DD.
|
|
d_prefix = _iso_date_prefix(prez["data_prestatie"])
|
|
if d_prefix is None:
|
|
continue
|
|
if data_de and d_prefix < data_de:
|
|
continue
|
|
if data_pana and d_prefix > data_pana:
|
|
continue
|
|
view_all.append(v)
|
|
|
|
total = len(view_all)
|
|
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
|
page = max(1, min(page, pages)) # clamp la nr. de pagini
|
|
offset = (page - 1) * _PAGE_SIZE
|
|
view = view_all[offset:offset + _PAGE_SIZE]
|
|
|
|
else:
|
|
# Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ)
|
|
total = conn.execute(
|
|
f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params
|
|
).fetchone()[0]
|
|
|
|
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
|
page = max(1, min(page, pages)) # clamp la nr. de pagini
|
|
offset = (page - 1) * _PAGE_SIZE
|
|
|
|
rows_db = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
|
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC "
|
|
"LIMIT ? OFFSET ?",
|
|
params + [_PAGE_SIZE, offset],
|
|
).fetchall()
|
|
view = [_submission_row_view(r) for r in rows_db]
|
|
|
|
page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0
|
|
page_end = min(page * _PAGE_SIZE, total)
|
|
|
|
return templates.TemplateResponse("_submissions.html", {
|
|
"request": request,
|
|
"rows": view,
|
|
"filtru_activ": filtru_activ,
|
|
"csrf_token": get_csrf_token(request),
|
|
# Paginare
|
|
"total": total,
|
|
"page": page,
|
|
"pages": pages,
|
|
"page_start": page_start,
|
|
"page_end": page_end,
|
|
# Filtre curente pentru linkurile de paginare (pastreaza filtrele)
|
|
"f_status": status or "",
|
|
"f_vehicul": vehicul_q or "",
|
|
"f_data_de": data_de or "",
|
|
"f_data_pana": data_pana or "",
|
|
# Pill-uri (OOB) + stare activa + versiune pentru nudge-ul "Date noi".
|
|
"pills_categorii": _pills_categorii(_status_counts(conn, account_id)),
|
|
"status_filtru": status or "",
|
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# Stari ne-trimise blocate pe care le putem corecta inline.
|
|
_CORECTABILE = ("needs_data", "needs_mapping")
|
|
# Stari cu select editabil cod_prestatie (superset al _CORECTABILE: error primeste
|
|
# select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
|
|
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
|
|
# Stari gestionabile prin lifecycle web: sterge / re-pune in coada.
|
|
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _render_submissions(request: Request, conn, account_id: int,
|
|
*, message: str | None = None) -> HTMLResponse:
|
|
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk.
|
|
|
|
`message`: sumar optional (ex. "2 reusite, 0 esuate") afisat ca banner la
|
|
inceputul fragmentului — folosit de bulk-fix (US-010, PRD 5.15).
|
|
"""
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
rows = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
|
f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200",
|
|
scope_params,
|
|
).fetchall()
|
|
view = [_submission_row_view(r) for r in rows]
|
|
return templates.TemplateResponse("_submissions.html", {
|
|
"request": request,
|
|
"rows": view,
|
|
"filtru_activ": False,
|
|
"csrf_token": get_csrf_token(request),
|
|
"pills_categorii": _pills_categorii(_status_counts(conn, account_id)),
|
|
"status_filtru": "",
|
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
|
"bulk_message": message, # US-010 (PRD 5.15): sumar bulk-fix
|
|
})
|
|
|
|
|
|
def _payload_form_values(payload_json) -> dict:
|
|
"""Valori brute pentru prefill-ul formularului de corectie."""
|
|
try:
|
|
data = json.loads(payload_json) if payload_json else {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except (ValueError, TypeError):
|
|
data = {}
|
|
return {
|
|
"form_vin": data.get("vin") or "",
|
|
"form_nr": data.get("nr_inmatriculare") or "",
|
|
"form_data": data.get("data_prestatie") or "",
|
|
"form_odo_final": data.get("odometru_final") or "",
|
|
"form_odo_initial": data.get("odometru_initial") or "",
|
|
}
|
|
|
|
|
|
def _prestatii_chips_from_payload(payload_json) -> list[dict]:
|
|
"""Extrage lista de chips prestatii din payload_json pentru _form_editare.html (US-007).
|
|
|
|
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}.
|
|
Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''.
|
|
"""
|
|
try:
|
|
data = json.loads(payload_json) if payload_json else {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except (ValueError, TypeError):
|
|
data = {}
|
|
chips = []
|
|
for item in (data.get("prestatii") or []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
chips.append({
|
|
"cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(),
|
|
"cod_op_service": (item.get("cod_op_service") or "").strip(),
|
|
"denumire": (item.get("denumire") or "").strip(),
|
|
})
|
|
return chips
|
|
|
|
|
|
def _has_r_odo_chips(prestatii_chips: list[dict]) -> bool:
|
|
"""True daca orice chip are cod R-ODO sau I-ODO (trigger pentru reveal odo initial)."""
|
|
return any(c.get("cod_prestatie") in ("R-ODO", "I-ODO") for c in prestatii_chips)
|
|
|
|
|
|
def _nemapate_pentru_submission(row, nomenclator: list[dict], conn=None) -> list[dict]:
|
|
"""Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy + enriched.
|
|
|
|
Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json,
|
|
aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din
|
|
`nomenclator` (pasat de apelant — evita un SELECT redundant in _detaliu_ctx). Goala
|
|
daca randul nu e needs_mapping sau nu are operatii nemapate reale (ex. needs_mapping
|
|
din auto_send=0 — codul exista deja, doar trimiterea e oprita).
|
|
|
|
L14-S6: cand `conn` e dat, adauga `sugestie_principala` (GOLD partajat > SILVER >
|
|
embeddings) si `surse_sugestie` din `enrich_suggestions`. SUGGESTION-ONLY (#13).
|
|
"""
|
|
if row["status"] != "needs_mapping":
|
|
return []
|
|
try:
|
|
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
if not isinstance(content, dict):
|
|
content = {}
|
|
except (ValueError, TypeError):
|
|
content = {}
|
|
# Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off).
|
|
if conn is not None:
|
|
ensure_embeddings_corpus(conn, nomenclator)
|
|
seen: set[str] = set()
|
|
out: list[dict] = []
|
|
for item in content.get("prestatii") or []:
|
|
if not isinstance(item, dict) or (item.get("cod_prestatie") or ""):
|
|
continue
|
|
op = (item.get("cod_op_service") or "").strip()
|
|
if not op or op in seen:
|
|
continue
|
|
seen.add(op)
|
|
entry: dict = {
|
|
"cod_op_service": op,
|
|
"denumire": item.get("denumire"),
|
|
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
|
|
"sugestie_principala": None,
|
|
"surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False},
|
|
}
|
|
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
|
|
if conn is not None:
|
|
enriched = enrich_suggestions(conn, item.get("denumire"))
|
|
entry["sugestie_principala"] = enriched["sugestie_principala"]
|
|
entry["surse_sugestie"] = enriched["surse"]
|
|
out.append(entry)
|
|
return out
|
|
|
|
|
|
def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|
error: bool = False, corectie_errors: list | None = None,
|
|
conn=None, account_id: int | None = None) -> dict:
|
|
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission.
|
|
|
|
`conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune
|
|
`nemapate_inline` + `nomenclator` pentru maparea inline din panou.
|
|
"""
|
|
eticheta = eticheta_stare(row["status"])
|
|
nemapate_inline: list[dict] = []
|
|
nomenclator: list[dict] = []
|
|
# Nomenclatorul complet, incarcat pentru needs_mapping si refolosit mai jos.
|
|
_nomenclator_complet: list[dict] = []
|
|
if conn is not None and row["status"] == "needs_mapping":
|
|
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
|
|
_nomenclator_complet = load_nomenclator(conn)
|
|
# L14-S6: pasam conn pt enrich_suggestions (GOLD/SILVER/embeddings, suggestion-only)
|
|
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet, conn=conn)
|
|
nomenclator = _nomenclator_complet if nemapate_inline else []
|
|
|
|
# Nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in formularul
|
|
# /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet daca
|
|
# e deja incarcat (needs_mapping), altfel incarca fresh.
|
|
nomenclator_rar: list[dict] = []
|
|
if conn is not None and row["status"] in _EDITABILE_OP:
|
|
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
|
|
|
|
# cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
|
|
cod_prestatie_curent = ""
|
|
try:
|
|
_pd = json.loads(row["payload_json"] or "{}")
|
|
_prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else []
|
|
if _prestatii and isinstance(_prestatii[0], dict):
|
|
cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper()
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
ctx = {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"id": row["id"],
|
|
"status": row["status"],
|
|
"stare_text": eticheta[0],
|
|
"stare_css": eticheta[2],
|
|
"stare_subtext": eticheta[1],
|
|
"prez": prezentare_din_payload(row["payload_json"]),
|
|
"id_prezentare": row["id_prezentare"],
|
|
"rar_status_code": row["rar_status_code"],
|
|
"rar_error": row["rar_error"],
|
|
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
|
"erori_3n": parse_erori(row["rar_error"]),
|
|
"retry_count": row["retry_count"],
|
|
"created_at": format_data_rar(row["created_at"]),
|
|
"updated_at": format_data_rar(row["updated_at"]),
|
|
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
|
|
# randuri ne-trimise blocate sunt corectabile; sent/sending nu
|
|
"editabil": row["status"] in _CORECTABILE,
|
|
# error/needs_data/needs_mapping pot fi sterse / re-puse in coada
|
|
"gestionabil": row["status"] in _GESTIONABILE_WEB,
|
|
# mapare inline (operatii nemapate ale acestui rand + nomenclator)
|
|
"nemapate_inline": nemapate_inline,
|
|
"nomenclator": nomenclator,
|
|
# select cod_prestatie pentru stari editabile
|
|
"nomenclator_rar": nomenclator_rar,
|
|
"cod_prestatie_curent": cod_prestatie_curent,
|
|
"corectie_msg": message,
|
|
"corectie_error": error,
|
|
"corectie_errors": corectie_errors or [],
|
|
# US-007 (PRD 5.15): chips prestatii + obs pentru formularul slim.
|
|
# prestatii_chips: lista {cod_prestatie, cod_op_service, denumire} pentru _chips_prestatii.html.
|
|
# has_r_odo: True daca chips contin R-ODO/I-ODO (trigger reveal odo initial, D10c).
|
|
# obs_val: text liber observatii (campul obs din payload_json).
|
|
# form_chips_url: endpoint HTMX pentru add/remove chip (E6 server-driven).
|
|
}
|
|
# Chips context (US-007): derivat din payload_json
|
|
_pjson = row["payload_json"]
|
|
prestatii_chips = _prestatii_chips_from_payload(_pjson)
|
|
ctx["prestatii_chips"] = prestatii_chips
|
|
ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips)
|
|
ctx["form_chips_url"] = "/form-chips"
|
|
# US-009: submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html.
|
|
# Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste
|
|
# → butonul nu apare (corect: /form-chips nu are scop de submission).
|
|
ctx["chips_submission_id"] = row["id"]
|
|
try:
|
|
_pdata = json.loads(_pjson or "{}")
|
|
ctx["obs_val"] = (str(_pdata.get("obs") or "") if isinstance(_pdata, dict) else "").strip()
|
|
except (ValueError, TypeError):
|
|
ctx["obs_val"] = ""
|
|
ctx.update(_payload_form_values(row["payload_json"]))
|
|
return ctx
|
|
|
|
|
|
def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
|
|
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta)."""
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
return conn.execute(
|
|
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
|
[submission_id] + scope_params,
|
|
).fetchone()
|
|
|
|
|
|
# Campuri afisate in detaliul trimiterii. payload_json e plaintext si se foloseste
|
|
# doar pentru campurile derivate (prezentare_din_payload).
|
|
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
|
|
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
|
|
|
|
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
|
|
(acelasi mesaj, nu confirmam existenta).
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row, conn=conn, account_id=account_id),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse)
|
|
async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Mapare inline din panoul de detaliu: alege cod RAR pentru o operatie nemapata.
|
|
|
|
Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica
|
|
noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL
|
|
SAU import batch), deblocand si randurile-frate cu aceeasi operatie. Scoped pe sesiune
|
|
(404 cross-account/inexistent), CSRF obligatoriu, gard pe status needs_mapping.
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
cod_op_service = str(form.get("cod_op_service") or "").strip()
|
|
cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper()
|
|
auto_send = str(form.get("auto_send") or "") not in ("", "false", "0", "off")
|
|
conn = get_connection()
|
|
try:
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
if row["status"] != "needs_mapping":
|
|
raise HTTPException(status_code=403, detail="trimitere fara operatii de mapat")
|
|
if not cod_op_service or not cod_prestatie:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row, conn=conn, account_id=account_id, error=True,
|
|
message="Alege un cod RAR pentru operatie."),
|
|
)
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
|
|
).fetchone()
|
|
if not exists:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row, conn=conn, account_id=account_id, error=True,
|
|
message=f"Cod necunoscut in nomenclator: {cod_prestatie}."),
|
|
)
|
|
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send)
|
|
# L14-S6: inregistreaza confirmare umana in GOLD partajat (shared_mappings).
|
|
# Gaseste denumirea operatiei din payload (cheia partajata e denumirea, nu cod_op_service).
|
|
try:
|
|
_pj = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
_den_gold = None
|
|
for _pit in (_pj.get("prestatii") or []):
|
|
if isinstance(_pit, dict) and _pit.get("cod_op_service") == cod_op_service:
|
|
_den_gold = _pit.get("denumire")
|
|
break
|
|
_record_gold_validation(
|
|
conn, _den_gold, cod_op_service, cod_prestatie,
|
|
provenance=f"account_{account_or_default(account_id)}/mapeaza_inline",
|
|
)
|
|
except Exception:
|
|
pass # best-effort: confirmare GOLD nu blocheaza fluxul principal
|
|
# Re-rezolva scoped pe canalul randului: batch_id None (API) sau batch import.
|
|
reresolve_account(conn, account_id, batch_id=row["batch_id"])
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
eticheta = eticheta_stare(row2["status"])
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, conn=conn, account_id=account_id,
|
|
message=f"Mapat {cod_op_service} -> {cod_prestatie}. "
|
|
f"Stare noua: {eticheta[0]}."),
|
|
)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/trimitere/{submission_id}/corecteaza", response_class=HTMLResponse)
|
|
async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Corectie inline pentru randuri ne-trimise blocate (needs_data/needs_mapping).
|
|
|
|
Re-valideaza (validation.py, fara reguli noi), reconstruieste payload_json,
|
|
recalculeaza idempotency_key (canonicalize -> build_key, ca la enqueue) si
|
|
re-pune randul in 'queued' (re-enqueue). NU atinge worker-ul / masina de stari.
|
|
Randurile sent/sending/queued/error raman read-only (gard explicit -> 403).
|
|
Coliziune de idempotency detectata INAINTE de UPDATE (fara 500/duplicat).
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
conn = get_connection()
|
|
try:
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
# Gard read-only: doar randurile blocate ne-trimise sunt corectabile.
|
|
if row["status"] not in _CORECTABILE:
|
|
raise HTTPException(status_code=403, detail="trimitere read-only (deja procesata)")
|
|
|
|
try:
|
|
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
if not isinstance(content, dict):
|
|
content = {}
|
|
except (ValueError, TypeError):
|
|
content = {}
|
|
|
|
# Aplica DOAR campurile prezente in form (negoale).
|
|
for camp in ("vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "odometru_initial"):
|
|
val = form.get(camp)
|
|
if isinstance(val, str) and val.strip() != "":
|
|
content[camp] = val.strip()
|
|
|
|
# obs: text liber optional (US-005 PRD 5.15). Permite si string gol (sterge obs).
|
|
# None = absent din form (neschimbat); "" = curatare explicita.
|
|
obs_val = form.get("obs")
|
|
if isinstance(obs_val, str):
|
|
content["obs"] = obs_val.strip()
|
|
|
|
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
|
|
# US-006 (PRD 5.15): form.getlist permite N coduri; fiecare se ataseaza itemului
|
|
# corespondent din prestatii (by index), pastrand cod_op_service/denumire (D7/E1).
|
|
# US-007 (PRD 5.15): form-ul slim trimite TOATE itemele (inclusiv "" pentru nemapate),
|
|
# permitand 1-1 aliniere by-index chiar cand un item de mijloc ramane nemapat.
|
|
# Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899).
|
|
codes_raw = form.getlist("cod_prestatie")
|
|
# Acceptam lista cu "" pentru pozitii nemapate (US-007); filtrare doar pt detectia
|
|
# "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie).
|
|
codes_positional = [
|
|
c.strip().upper() if isinstance(c, str) else ""
|
|
for c in codes_raw
|
|
]
|
|
# US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
|
|
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
|
|
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
|
|
# Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
|
|
_flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
|
if _flat_picker and _flat_picker not in codes_positional:
|
|
codes_positional.append(_flat_picker)
|
|
for _i in range(len(codes_positional)):
|
|
if not codes_positional[_i]:
|
|
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
|
|
if _op_val:
|
|
codes_positional[_i] = _op_val
|
|
# Verifica daca cel putin un cod non-gol a fost trimis
|
|
codes_nonempty = [c for c in codes_positional if c]
|
|
if codes_nonempty:
|
|
# Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide)
|
|
for cod in codes_nonempty:
|
|
exists_nom = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
|
).fetchone()
|
|
if not exists_nom:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id, error=True,
|
|
message=f"Cod RAR necunoscut in nomenclator: {cod}. "
|
|
"Alege un cod valid din lista.",
|
|
),
|
|
)
|
|
# Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index.
|
|
# Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE).
|
|
# Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping.
|
|
existing = content.get("prestatii") or []
|
|
new_prestatii = []
|
|
for i, cod in enumerate(codes_positional):
|
|
if i >= len(existing) and not cod:
|
|
continue # extra pozitii goale fara item corespondent — sarite
|
|
item = dict(existing[i]) if i < len(existing) else {}
|
|
if cod:
|
|
item["cod_prestatie"] = cod
|
|
# E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta
|
|
new_prestatii.append(item)
|
|
# Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul.
|
|
# Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza.
|
|
seen_pairs: set = set()
|
|
deduped: list = []
|
|
for item in new_prestatii:
|
|
pair = (item.get("cod_op_service"), item.get("cod_prestatie"))
|
|
if pair not in seen_pairs:
|
|
seen_pairs.add(pair)
|
|
deduped.append(item)
|
|
content["prestatii"] = deduped
|
|
# else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii
|
|
# detecteaza operatii nemapate si randul ramane needs_mapping.
|
|
|
|
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
|
|
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
|
|
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
|
|
mapping_meta = load_mapping_meta(conn, account_id)
|
|
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
|
valid_codes = load_nomenclator_codes(conn) or None
|
|
text_rules = load_text_rules(conn, account_id)
|
|
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
|
content["prestatii"] = resolved
|
|
|
|
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
|
|
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
|
|
|
|
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
|
canon = canonicalize_row(content)
|
|
content.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
payload_json = json.dumps(content, ensure_ascii=False)
|
|
|
|
if unmapped:
|
|
conn.execute(
|
|
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
|
|
"updated_at=datetime('now') WHERE id=?",
|
|
(payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), row["id"]),
|
|
)
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, conn=conn, account_id=account_id, error=True,
|
|
message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."),
|
|
)
|
|
|
|
# US-001 (PRD 5.11): ramura auto_send eliminata din corectie.
|
|
|
|
errors = validate_prezentare(content)
|
|
if errors:
|
|
# Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp.
|
|
conn.execute(
|
|
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
|
"updated_at=datetime('now') WHERE id=?",
|
|
(payload_json, json.dumps(errors, ensure_ascii=False), row["id"]),
|
|
)
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, conn=conn, account_id=account_id,
|
|
message="Mai sunt campuri invalide — vezi mai jos.",
|
|
error=True, corectie_errors=errors),
|
|
)
|
|
|
|
# Valid: recalculeaza cheia. Coliziune cu alt rand -> opreste, fara 500/duplicat.
|
|
new_key = build_key(account_id, canon)
|
|
if new_key != row["idempotency_key"]:
|
|
dup = conn.execute(
|
|
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
|
(new_key, row["id"]),
|
|
).fetchone()
|
|
if dup:
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row2, conn=conn, account_id=account_id,
|
|
message=f"Exista deja o trimitere identica (rand #{dup['id']}). Corectia a fost oprita.",
|
|
error=True,
|
|
),
|
|
)
|
|
|
|
try:
|
|
conn.execute(
|
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
|
"updated_at=datetime('now') WHERE id=?",
|
|
(new_key, payload_json, row["id"]),
|
|
)
|
|
except sqlite3.IntegrityError:
|
|
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
|
|
# pre-check-ul a trecut dar alt rand a primit cheia intre timp. Fara 500.
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, conn=conn, account_id=account_id, error=True,
|
|
message="Exista deja o trimitere identica. Corectia a fost oprita."),
|
|
)
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, conn=conn, account_id=account_id,
|
|
message="Corectat — randul a fost re-pus in coada."),
|
|
)
|
|
# Pe succes, lista se reincarca (trimiteriChanged) si modalul se inchide
|
|
# (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
|
|
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
|
|
return resp
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada. #
|
|
# Peste helper-ul submissions_admin. CSRF enforce; scoped pe sesiune. #
|
|
# =========================================================================== #
|
|
|
|
@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse)
|
|
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
|
|
|
|
Daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza
|
|
codul in payload, recalculeaza cheia de idempotency si re-pune in coada direct (fara
|
|
`requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune (404
|
|
cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu starea
|
|
noua + nudge `trimiteriChanged` pentru lista.
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
conn = get_connection()
|
|
try:
|
|
# Prelucrare coduri_prestatie (lista multi-select) pentru starea error, inaintea
|
|
# requeue_submission standard care nu actualizeaza cheia de idempotency.
|
|
# US-006 (PRD 5.15): form.getlist; cod_op_service/denumire RAMAN pe item (E1 IRON RULE).
|
|
codes_raw = form.getlist("cod_prestatie")
|
|
codes = [c.strip().upper() for c in codes_raw if isinstance(c, str) and c.strip()]
|
|
|
|
if codes:
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
if row["status"] != "error":
|
|
# cod_prestatie acceptat DOAR pentru starea error prin /repune
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="modificarea cod_prestatie prin repune e valida doar pentru starea error",
|
|
)
|
|
# Valideaza FIECARE cod fata de nomenclator (ORA-12899)
|
|
for cod in codes:
|
|
exists_nom = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
|
).fetchone()
|
|
if not exists_nom:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id,
|
|
error=True,
|
|
message=f"Cod RAR necunoscut: {cod}. Alege un cod valid.",
|
|
),
|
|
)
|
|
# Parseaza payload
|
|
try:
|
|
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
if not isinstance(content, dict):
|
|
content = {}
|
|
except (ValueError, TypeError):
|
|
content = {}
|
|
# Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index.
|
|
# E1 IRON RULE: cod_op_service/denumire RAMAN pe item (pop-ul vechi ELIMINAT).
|
|
# ITERAM peste `existing`, NU peste `codes`: formularul /repune trimite un
|
|
# SINGUR select cod_prestatie, deci pentru un rand cu 2+ prestatii o iterare
|
|
# pe codes ar fi pastrat doar len(codes) itemi -> prestatii[1:] PIERDUTE ->
|
|
# declaratie incompleta la RAR (FINALIZATA ireversibil). Aplicam codes pozitional
|
|
# si pastram intacte toate prestatiile existente fara cod nou.
|
|
existing = content.get("prestatii") or []
|
|
new_prestatii = []
|
|
for i in range(max(len(existing), len(codes))):
|
|
item = dict(existing[i]) if i < len(existing) else {}
|
|
if i < len(codes):
|
|
item["cod_prestatie"] = codes[i]
|
|
# E1: cod_op_service/denumire NU se sterg — perechea op<->cod ramane intacta
|
|
new_prestatii.append(item)
|
|
# Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul
|
|
seen_pairs: set = set()
|
|
deduped: list = []
|
|
for item in new_prestatii:
|
|
pair = (item.get("cod_op_service"), item.get("cod_prestatie"))
|
|
if pair not in seen_pairs:
|
|
seen_pairs.add(pair)
|
|
deduped.append(item)
|
|
content["prestatii"] = deduped
|
|
# Re-rezolva prestatii cu noul cod
|
|
mapping_meta = load_mapping_meta(conn, account_id)
|
|
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
|
valid_codes = load_nomenclator_codes(conn) or None
|
|
text_rules = load_text_rules(conn, account_id)
|
|
resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
|
content["prestatii"] = resolved
|
|
# Canonicalize + rebuild idempotency key
|
|
canon = canonicalize_row(content)
|
|
payload_json = json.dumps(content, ensure_ascii=False)
|
|
new_key = build_key(account_id, canon)
|
|
# Verifica coliziune (numai daca cheia s-a schimbat)
|
|
if new_key != row["idempotency_key"]:
|
|
dup = conn.execute(
|
|
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
|
(new_key, row["id"]),
|
|
).fetchone()
|
|
if dup:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id,
|
|
error=True,
|
|
message=f"Exista deja o trimitere identica (rand #{dup['id']}).",
|
|
),
|
|
)
|
|
try:
|
|
conn.execute(
|
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
|
"updated_at=datetime('now') WHERE id=? AND account_id=?",
|
|
(new_key, payload_json, row["id"], account_id),
|
|
)
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id,
|
|
error=True,
|
|
message="Exista deja o trimitere identica. Operatia a fost oprita.",
|
|
),
|
|
)
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row2, conn=conn, account_id=account_id,
|
|
message="Cod actualizat — randul a fost re-pus in coada.",
|
|
),
|
|
)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
|
|
# Cale normala: fara cod_prestatie → delega la requeue_submission
|
|
try:
|
|
requeue_submission(conn, account_id, submission_id)
|
|
except SubmissionNotFound:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
except SubmissionStateConflict:
|
|
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row, conn=conn, account_id=account_id,
|
|
message="Re-pus in coada — pleaca la urmatoarea trimitere."),
|
|
)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/trimitere/{submission_id}/sterge", response_class=HTMLResponse)
|
|
async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Sterge un rand blocat din dashboard. Scoped pe sesiune; sent/sending interzis (409)."""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
conn = get_connection()
|
|
try:
|
|
try:
|
|
delete_submission(conn, account_id, submission_id)
|
|
except SubmissionNotFound:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
except SubmissionStateConflict:
|
|
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
|
|
resp = HTMLResponse(
|
|
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
|
|
)
|
|
# 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. 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()
|
|
|
|
|
|
# =========================================================================== #
|
|
# US-010 (PRD 5.15): Bulk-fix — aplica un cod RAR la selectia de randuri #
|
|
# blocate. Reuse form #bulk-trimiteri + validare cod din post_corectie. #
|
|
# Regiune izolata in routes.py (fara conflict cu alte endpoints). #
|
|
# =========================================================================== #
|
|
|
|
|
|
@router.post("/trimiteri/bulk-fix", response_class=HTMLResponse)
|
|
async def post_bulk_fix(request: Request) -> HTMLResponse:
|
|
"""Aplica un cod RAR la TOATE randurile blocate selectate (US-010, PRD 5.15).
|
|
|
|
Reuse form #bulk-trimiteri (checkbox-uri pe gestionabil). Fiecare rand e
|
|
re-validat + idempotenta recalculata individual — un rand invalid nu pica lotul.
|
|
Sumar 'N reusite, M esuate'. Scoped pe cont (cross-account = silentios sarite).
|
|
Randurile sent/sending/queued sarite silentios (gard gestionabil, ca sterge-bulk).
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
ids = form.getlist("submission_id")
|
|
cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper()
|
|
|
|
conn = get_connection()
|
|
try:
|
|
# Validare cod fata de nomenclator INAINTE de procesarea randurilor
|
|
# (aceeasi regula ORA-12899 ca la corectie individuala: RAR accepta NUMAI coduri valide)
|
|
if not cod_prestatie:
|
|
resp = _render_submissions(
|
|
request, conn, account_id,
|
|
message="Cod RAR lipsa — introdu un cod inainte de aplicare.",
|
|
)
|
|
return resp
|
|
|
|
exists_nom = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
|
|
).fetchone()
|
|
if not exists_nom:
|
|
resp = _render_submissions(
|
|
request, conn, account_id,
|
|
message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie}. "
|
|
"Alege un cod valid din lista.",
|
|
)
|
|
return resp
|
|
|
|
reusite = 0
|
|
esuate = 0
|
|
|
|
# Maparea contului + nomenclator + reguli text NU depind de rand -> incarcate
|
|
# O DATA inainte de bucla (evita 3xN query-uri redundante pe bulk-fix).
|
|
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)
|
|
|
|
for raw in ids:
|
|
try:
|
|
sid = int(str(raw))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
# 404-before-409: rand inexistent SAU al altui cont -> sarit silentios
|
|
row = _fetch_submission_scoped(conn, account_id, sid)
|
|
if not row:
|
|
continue
|
|
|
|
# Doar randuri gestionabile (blocate); sent/sending/queued sarite silentios
|
|
if row["status"] not in _GESTIONABILE_WEB:
|
|
continue
|
|
|
|
# Aplica cod_prestatie la itemele prestatii nemapate (fara cod_prestatie)
|
|
# Itemele cu cod existent sunt pastrate (E1: cod_op_service/denumire intacte)
|
|
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 []
|
|
new_prestatii = []
|
|
for item in prestatii:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
it = dict(item)
|
|
# Aplica cod DOAR la itemii fara cod_prestatie (nemapati)
|
|
if not it.get("cod_prestatie"):
|
|
it["cod_prestatie"] = cod_prestatie
|
|
new_prestatii.append(it)
|
|
# Lista goala (rand fara prestatii) -> adauga un item cu cod direct
|
|
if not new_prestatii:
|
|
new_prestatii = [{"cod_prestatie": cod_prestatie}]
|
|
content["prestatii"] = new_prestatii
|
|
|
|
# Re-resolve cu maparea curenta a contului (ca reresolve_account;
|
|
# mapping/valid_codes/text_rules hoistate inainte de bucla)
|
|
resolved, unmapped = resolve_prestatii(
|
|
content.get("prestatii"), mapping, valid_codes, text_rules
|
|
)
|
|
content["prestatii"] = resolved
|
|
|
|
if unmapped:
|
|
# Inca nemapat (ex. alte operatii fara cod) -> persista needs_mapping
|
|
payload_json = json.dumps(content, ensure_ascii=False)
|
|
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), sid),
|
|
)
|
|
esuate += 1
|
|
continue
|
|
|
|
# Canonicalizare (strip ".0", 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)
|
|
|
|
# Validare individuala — un rand invalid nu pica lotul (AC US-010)
|
|
errors = validate_prezentare(content)
|
|
if errors:
|
|
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), sid),
|
|
)
|
|
esuate += 1
|
|
continue
|
|
|
|
# Recalcul idempotenta — coliziune detectata INAINTE de UPDATE (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, sid),
|
|
).fetchone()
|
|
if dup:
|
|
esuate += 1
|
|
continue
|
|
|
|
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, sid),
|
|
)
|
|
reusite += 1
|
|
except sqlite3.IntegrityError:
|
|
# Plasa de siguranta TOCTOU pe UNIQUE(idempotency_key)
|
|
esuate += 1
|
|
|
|
# Compune mesajul sumar "N reusite, M esuate" (AC US-010)
|
|
parts: list[str] = []
|
|
if reusite:
|
|
suffix_r = "a" if reusite == 1 else "e"
|
|
parts.append(f"{reusite} reusit{suffix_r}")
|
|
if esuate:
|
|
suffix_e = "a" if esuate == 1 else "e"
|
|
parts.append(f"{esuate} esuat{suffix_e}")
|
|
message: str | None = (", ".join(parts) + ".") if parts else None
|
|
|
|
resp = _render_submissions(request, conn, account_id, message=message)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# US-007 (PRD 5.15): Endpoint /form-chips — re-randare chips prestatii (E6). #
|
|
# Preia starea curenta din form + actiunea (add/remove) si re-randeaza #
|
|
# _chips_prestatii.html. Fara persistenta mid-edit (stare in input-uri form). #
|
|
# Minim si izolat (regiune noua, fara conflict cu post_corecteaza). #
|
|
# =========================================================================== #
|
|
|
|
|
|
@router.post("/form-chips", response_class=HTMLResponse)
|
|
async def post_form_chips(request: Request) -> HTMLResponse:
|
|
"""Re-randeaza sectiunea chips prestatii (HTMX server-driven, E6, US-007).
|
|
|
|
Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie,
|
|
chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza
|
|
_chips_prestatii.html actualizat. Fara scriere in DB (stateless mid-edit).
|
|
Auth: sesiune activa; CSRF verificat.
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
|
|
# Reconstruct current chips state from parallel hidden inputs (emise de _chips_prestatii.html).
|
|
# Toate cele 3 liste sunt aceeasi lungime (emise index-by-index in template).
|
|
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
|
|
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
|
|
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
|
|
|
|
# Aliniaza listele la lungimea maxima (defensive)
|
|
n = max(len(cod_list), len(op_list), len(den_list)) if (cod_list or op_list or den_list) else 0
|
|
chips: list[dict] = []
|
|
for i in range(n):
|
|
chips.append({
|
|
"cod_prestatie": cod_list[i] if i < len(cod_list) else "",
|
|
"cod_op_service": op_list[i] if i < len(op_list) else "",
|
|
"denumire": den_list[i] if i < len(den_list) else "",
|
|
})
|
|
|
|
action = str(form.get("chips_action") or "").strip()
|
|
chips_extra_error = False # T-C1/T-E4 (5.16): semnal pentru add_extra esuat
|
|
|
|
conn = get_connection()
|
|
try:
|
|
if action == "add":
|
|
# Adauga cod la operatia specificata prin chips_add_op_index
|
|
try:
|
|
op_idx = int(str(form.get("chips_add_op_index") or 0))
|
|
except (ValueError, TypeError):
|
|
op_idx = 0
|
|
add_cod = str(form.get(f"chips_add_cod_{op_idx}") or "").strip().upper()
|
|
if add_cod and 0 <= op_idx < len(chips):
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod,)
|
|
).fetchone()
|
|
if exists:
|
|
chips[op_idx]["cod_prestatie"] = add_cod
|
|
|
|
elif action == "add_flat":
|
|
# Adauga cod nou in lista plata (fara op_service)
|
|
add_cod_flat = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
|
if add_cod_flat:
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_flat,)
|
|
).fetchone()
|
|
if exists:
|
|
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
|
|
|
|
elif action == "add_extra":
|
|
# US-005 (5.16): Adauga cod RAR liber (extra, fara op_service) in modul operatii.
|
|
# Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item E4 pastrat).
|
|
# T-C1/T-E4: select gol sau cod invalid → chips_extra_error = True (semnal vizibil).
|
|
add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
|
if add_cod_extra:
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_extra,)
|
|
).fetchone()
|
|
if exists:
|
|
# Dedup per-item (E4): nu adauga un chip extra identic deja existent
|
|
existing_pairs = {
|
|
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
|
|
for c in chips
|
|
}
|
|
if ("", add_cod_extra) not in existing_pairs:
|
|
chips.append({"cod_prestatie": add_cod_extra, "cod_op_service": "", "denumire": ""})
|
|
else:
|
|
chips_extra_error = True # cod necunoscut in nomenclator
|
|
else:
|
|
chips_extra_error = True # select gol
|
|
|
|
elif action == "remove":
|
|
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata)
|
|
try:
|
|
remove_idx = int(str(form.get("chips_remove_index") or 0))
|
|
except (ValueError, TypeError):
|
|
remove_idx = 0
|
|
if 0 <= remove_idx < len(chips):
|
|
chips[remove_idx]["cod_prestatie"] = ""
|
|
|
|
elif action == "remove_flat":
|
|
# Sterge un chip plat dupa cod (in mod fara op_service)
|
|
remove_cod = str(form.get("chips_remove_cod") or "").strip().upper()
|
|
chips = [
|
|
c for c in chips
|
|
if not (not c.get("cod_op_service") and c.get("cod_prestatie") == remove_cod)
|
|
]
|
|
|
|
# Compute has_r_odo dupa actiune
|
|
has_r_odo = _has_r_odo_chips(chips)
|
|
|
|
# Incarca nomenclatorul pentru picker
|
|
nomenclator_rar = load_nomenclator(conn)
|
|
finally:
|
|
conn.close()
|
|
|
|
return templates.TemplateResponse("_chips_prestatii.html", {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"prestatii_chips": chips,
|
|
"nomenclator_rar": nomenclator_rar,
|
|
"has_r_odo": has_r_odo,
|
|
"form_chips_url": "/form-chips",
|
|
"chips_section_id": "chips-section",
|
|
"chips_extra_error": chips_extra_error, # T-C1/T-E4 (5.16)
|
|
})
|
|
|
|
|
|
# =========================================================================== #
|
|
# US-009 (PRD 5.15): Salvare mapare din chip. #
|
|
# Reuse EXACT save_mapping + reresolve_account (ca maparea inline 5.7). #
|
|
# Scoped pe sesiune (404 cross-account), CSRF obligatoriu. #
|
|
# =========================================================================== #
|
|
|
|
|
|
@router.post("/trimitere/{submission_id}/salveaza-regula-chip", response_class=HTMLResponse)
|
|
async def post_salveaza_regula_din_chip(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Salveaza regula op->cod din chip (US-009, PRD 5.15).
|
|
|
|
Reuse EXACT save_mapping + reresolve_account (acelasi mecanism ca maparea inline 5.7).
|
|
Scoped pe sesiune (404 cross-account/inexistent), CSRF obligatoriu.
|
|
Re-rezolva deblocand si submission-urile frate cu aceeasi operatie (pe batch_id).
|
|
auto_send implicit False (conservator — userul poate activa din tab-ul Mapari).
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
cod_op_service = str(form.get("salveaza_op") or "").strip()
|
|
cod_prestatie = str(form.get("salveaza_cod") or "").strip().upper()
|
|
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 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="Operatia sau codul RAR lipsesc pentru salvarea regulii."),
|
|
)
|
|
# Valideaza codul in nomenclator (invariant ORA-12899)
|
|
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}."),
|
|
)
|
|
# Reuse EXACT save_mapping + reresolve_account (ca post_mapeaza_inline 5.7)
|
|
save_mapping(conn, account_id, cod_op_service, cod_prestatie, False)
|
|
# L14-S6: inregistreaza confirmare umana in GOLD partajat (shared_mappings).
|
|
try:
|
|
_pj2 = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
_den_chip = None
|
|
for _pit2 in (_pj2.get("prestatii") or []):
|
|
if isinstance(_pit2, dict) and _pit2.get("cod_op_service") == cod_op_service:
|
|
_den_chip = _pit2.get("denumire")
|
|
break
|
|
_record_gold_validation(
|
|
conn, _den_chip, cod_op_service, cod_prestatie,
|
|
provenance=f"account_{account_or_default(account_id)}/salveaza_chip",
|
|
)
|
|
except Exception:
|
|
pass # best-effort
|
|
# Re-rezolva scoped pe canalul randului: batch_id None (API) sau batch import.
|
|
# Deblocheaza si submission-urile frate cu aceeasi operatie.
|
|
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"Regula salvata: {cod_op_service} -> {cod_prestatie}. "
|
|
f"Stare noua: {eticheta[0]}."),
|
|
)
|
|
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. 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.
|
|
|
|
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: 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),
|
|
denumire: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul.
|
|
|
|
L14-S6: parametrul optional `denumire` permite inregistrarea in GOLD partajat
|
|
(shared_mappings) cu cheia corecta (denumire normalizata, nu cod_op_service).
|
|
Formularul din _mapari.html include un input hidden `denumire` per operatie.
|
|
"""
|
|
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)
|
|
# L14-S6: inregistreaza confirmare umana in GOLD partajat (doar cu denumire reala).
|
|
_record_gold_validation(
|
|
conn, denumire, cod_op_service, cod,
|
|
provenance=f"account_{account_or_default(account_id)}/mapare_tab",
|
|
)
|
|
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()
|
|
|
|
|
|
# =========================================================================== #
|
|
# 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),
|
|
denumire: str | None = Form(None),
|
|
) -> 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.
|
|
|
|
L14-S6: `denumire` optional actualizeaza si GOLD partajat (shared_mappings).
|
|
"""
|
|
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)
|
|
# L14-S6: actualizeaza GOLD partajat la editarea maparii salvate. Formularul
|
|
# din _mapari.html NU trimite denumire (maparea salvata nu retine textul uman
|
|
# original), deci de regula nu se scrie GOLD aici -> evita poluarea cu cod_op_service.
|
|
_record_gold_validation(
|
|
conn, denumire, cod_op_service, cod,
|
|
provenance=f"account_{account_or_default(account_id)}/editeaza_mapare",
|
|
)
|
|
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()
|
|
|
|
|
|
# =========================================================================== #
|
|
# 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.
|
|
"""
|
|
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}.",
|
|
)
|
|
# 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: 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>'
|
|
)
|
|
)
|
|
|
|
|
|
# =========================================================================== #
|
|
# 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, reviewed 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
|
|
rows: list[dict[str, Any]] = []
|
|
overrides: list[dict[str, Any]] = []
|
|
reviewed_flags: list[bool] = []
|
|
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 {})
|
|
reviewed_flags.append(bool(r["reviewed"]))
|
|
|
|
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)
|
|
mapping_meta = load_mapping_meta(conn, acct)
|
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
|
# Paritate cu commit-ul: preview-ul web trebuie sa aplice ACELEASI reguli text +
|
|
# validare nomenclator, 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,
|
|
reviewed=reviewed_flags[i],
|
|
)
|
|
|
|
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 (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")
|
|
|
|
# Enrichment UI: adauga campuri pre-computate necesare template-ului.
|
|
# Toate consumatorii (preview complet, rand single via _preview_one_row)
|
|
# obtin automat campurile adaugate aici.
|
|
for row in preview_rows:
|
|
# view-model prez (vehicul/operatie/cod RAR) — prezentare_din_payload
|
|
# accepta dict direct (nu e nevoie de serializare/deserializare JSON).
|
|
row["prez"] = prezentare_din_payload(row["resolved"])
|
|
# Eticheta umana + clasa CSS pentru pill — din STARI_PREVIEW, nu STARI_SUBMISSION
|
|
# (eticheta_stare ridica KeyError pe ok/already_sent/duplicate_in_file).
|
|
_etq, _css = STARI_PREVIEW.get(
|
|
row["resolved_status"],
|
|
(row["resolved_status"], f"s-{row['resolved_status']}"),
|
|
)
|
|
row["stare_eticheta"] = _etq
|
|
row["stare_css"] = _css
|
|
# Nota umana formatata — errors e lista Python, NU JSON string;
|
|
# nota_umana_preview o trateaza corect (fara repr Python brut in Note).
|
|
row["nota_umana"] = nota_umana_preview(
|
|
row["resolved_status"],
|
|
row.get("errors") or [],
|
|
row.get("flags") or [],
|
|
)
|
|
|
|
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)
|
|
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
|
|
|
|
_sample = parsed.rows[:3]
|
|
return templates.TemplateResponse("_mapcoloane.html", {
|
|
"request": request,
|
|
"import_id": batch_id_int,
|
|
"filename": filename,
|
|
"columns": parsed.columns,
|
|
"sample_rows": _sample,
|
|
"prima_inregistrare": _sample[0] if _sample else None,
|
|
"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()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Editare celule in preview: mod editare pe rand. #
|
|
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section. #
|
|
# Status rederivat DOAR prin _resolve_row_for_preview (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` (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 _raspuns_rand_salvat(import_id: int, row: dict) -> HTMLResponse:
|
|
"""Raspuns dupa salvarea/confirmarea unui rand de preview.
|
|
|
|
NU mai face OOB swap pe `<tr>` (htmx 1.9 pierde un `<tr>` brut la parsarea unui
|
|
fragment care nu incepe cu context de tabel -> swap-ul esua tacit, randul ramanea
|
|
cu starea veche; cauza confirmata a bug-ului 'editez si ramane la fel'). In schimb:
|
|
- `reincarcaPreview` (HX-Trigger) -> #import-section isi reincarca preview-ul complet
|
|
(rand + contoare + colaps deja-trimise, toate corecte, fara fragilitatea OOB).
|
|
- `randSalvat` (HX-Trigger, cu detalii) -> base.html arata un toast cu numarul randului
|
|
si noua stare, apoi evidentiaza randul dupa reload (feedback vizual clar: ce s-a salvat).
|
|
- `inchideModal` (HX-Trigger-After-Settle) -> inchide modalul.
|
|
"""
|
|
payload = {
|
|
"reincarcaPreview": True,
|
|
"randSalvat": {
|
|
"nr": row["row_index"] + 1,
|
|
"rowIndex": row["row_index"],
|
|
"stare": row.get("stare_eticheta") or "",
|
|
"stareCss": row.get("stare_css") or "",
|
|
},
|
|
}
|
|
resp = HTMLResponse(content='<div style="display:none;"></div>')
|
|
resp.headers["HX-Trigger"] = json.dumps(payload)
|
|
resp.headers["HX-Trigger-After-Settle"] = "inchideModal"
|
|
return resp
|
|
|
|
|
|
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-modal", response_class=HTMLResponse)
|
|
def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Fragment editare rand preview in modalul global (#detaliu-modal-body).
|
|
|
|
US-006 (PRD 5.12): inlocuieste editarea inline (tr.preview-edit) care cauza
|
|
colapsare vizuala si eroare JS la Anuleaza (R5). Randeaza _editare_preview_modal.html.
|
|
Campurile vehicul/data/odometru sunt preluate din starea curenta (resolved + override).
|
|
"""
|
|
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")
|
|
res = row.get("resolved") or {}
|
|
err_map: dict[str, str] = {}
|
|
fix_map: dict[str, str] = {}
|
|
for e in (row.get("errors") or []):
|
|
if isinstance(e, dict) and e.get("field"):
|
|
err_map[e["field"]] = e.get("message") or e.get("msg") or ""
|
|
if e.get("fix"):
|
|
fix_map[e["field"]] = e["fix"]
|
|
# US-007: chips prestatii + obs pentru formularul slim
|
|
_preview_chips = [
|
|
{
|
|
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
|
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
|
"denumire": (p.get("denumire") or "").strip(),
|
|
}
|
|
for p in (res.get("prestatii") or [])
|
|
if isinstance(p, dict)
|
|
]
|
|
_preview_has_r_odo = _has_r_odo_chips(_preview_chips)
|
|
_preview_nomenclator = load_nomenclator(conn)
|
|
return templates.TemplateResponse("_editare_preview_modal.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"row_index": row_index,
|
|
"csrf_token": get_csrf_token(request),
|
|
"vin": res.get("vin") or "",
|
|
"stare_css": row.get("stare_css") or "",
|
|
"stare_eticheta": row.get("stare_eticheta") or "",
|
|
"form_nr": res.get("nr_inmatriculare") or "",
|
|
"form_vin": res.get("vin") or "",
|
|
"form_data": res.get("data_prestatie") or "",
|
|
"form_odo_final": str(res.get("odometru_final") or ""),
|
|
"form_odo_initial": str(res.get("odometru_initial") or ""),
|
|
"err_map": err_map,
|
|
"fix_map": fix_map,
|
|
"vin_context": res.get("vin") or "",
|
|
"btn_label": "Salveaza",
|
|
"message": None,
|
|
# T2 (US-007): butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
|
|
"is_needs_review": row.get("resolved_status") == "needs_review",
|
|
# US-007: chips slim
|
|
"prestatii_chips": _preview_chips,
|
|
"has_r_odo": _preview_has_r_odo,
|
|
"obs_val": (res.get("obs") or "").strip(),
|
|
"nomenclator_rar": _preview_nomenclator,
|
|
"form_chips_url": "/form-chips",
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@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:
|
|
"""Fragment editare rand preview in modal — alias al /editare-modal.
|
|
|
|
US-006: editarea inline eliminata; ruta pastrata pentru compatibilitate cu
|
|
apeluri externe / teste existente. Delega la web_rand_editare_modal.
|
|
"""
|
|
return web_rand_editare_modal(request, import_id, row_index)
|
|
|
|
|
|
@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:
|
|
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
|
|
|
|
US-006 (PRD 5.12):
|
|
- Succes: raspuns cu OOB pe rand (#preview-row-N) + OOB contoare (#preview-rezumat) +
|
|
header HX-Trigger-After-Settle:inchideModal (modalul se inchide, OOB se aplica).
|
|
- Eroare camp: re-randeaza _editare_preview_modal.html cu valorile introduse + erorile
|
|
per-camp; modalul RAMANE DESCHIS; NU se emite inchideModal.
|
|
|
|
INVARIANT CRITIC (R2): submissions NEATINS — override-only pe import_rows.override_json,
|
|
NU re-queue, NU insereaza in submissions.
|
|
"""
|
|
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 formularul modal cu 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:
|
|
# Eroare de validare: re-randeaza formularul in modal cu valorile introduse.
|
|
# Modalul RAMANE DESCHIS (fara HX-Trigger-After-Settle:inchideModal).
|
|
res = row.get("resolved") or {}
|
|
err_map: dict[str, str] = {}
|
|
fix_map: dict[str, str] = {}
|
|
for e in field_errors:
|
|
if e.get("field"):
|
|
err_map[e["field"]] = e.get("message") or e.get("msg") or ""
|
|
if e.get("fix"):
|
|
fix_map[e["field"]] = e["fix"]
|
|
# US-007: chips context pentru re-randare modal cu erori
|
|
_err_chips = [
|
|
{
|
|
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
|
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
|
"denumire": (p.get("denumire") or "").strip(),
|
|
}
|
|
for p in (res.get("prestatii") or [])
|
|
if isinstance(p, dict)
|
|
]
|
|
_err_nomenclator = load_nomenclator(conn)
|
|
return templates.TemplateResponse("_editare_preview_modal.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"row_index": row_index,
|
|
"csrf_token": get_csrf_token(request),
|
|
"vin": res.get("vin") or "",
|
|
"stare_css": row.get("stare_css") or "",
|
|
"stare_eticheta": row.get("stare_eticheta") or "",
|
|
# Valorile DIN FORM (pentru ca userul sa vada ce a introdus):
|
|
"form_nr": str(form.get("nr_inmatriculare") or res.get("nr_inmatriculare") or ""),
|
|
"form_vin": str(form.get("vin") or res.get("vin") or ""),
|
|
"form_data": str(form.get("data_prestatie") or res.get("data_prestatie") or ""),
|
|
"form_odo_final": str(form.get("odometru_final") or res.get("odometru_final") or ""),
|
|
"form_odo_initial": str(form.get("odometru_initial") or res.get("odometru_initial") or ""),
|
|
"err_map": err_map,
|
|
"fix_map": fix_map,
|
|
"vin_context": res.get("vin") or "",
|
|
"btn_label": "Salveaza",
|
|
"message": "Mai sunt valori invalide — corecteaza campurile marcate.",
|
|
# US-007: chips slim
|
|
"prestatii_chips": _err_chips,
|
|
"has_r_odo": _has_r_odo_chips(_err_chips),
|
|
"obs_val": str(form.get("obs") or res.get("obs") or "").strip(),
|
|
"nomenclator_rar": _err_nomenclator,
|
|
"form_chips_url": "/form-chips",
|
|
})
|
|
|
|
# Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
|
|
return _raspuns_rand_salvat(import_id, row)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/rand/{row_index}/confirma-review", response_class=HTMLResponse)
|
|
async def web_confirma_review(
|
|
request: Request,
|
|
import_id: int,
|
|
row_index: int,
|
|
) -> HTMLResponse:
|
|
"""Confirma explicit valorile unui rand needs_review → seteaza reviewed=1 in DB.
|
|
|
|
US-007 (PRD 5.12), T2: butonul 'Confirma valorile' din modal seteaza reviewed=1
|
|
pentru randul indicat. La recalcul (_web_compute_preview), randul cu reviewed=1
|
|
si fara erori de validare reale devine ok (nu mai e blocat de flaguri ambigue).
|
|
|
|
Guard: 404 cross-account (scoping JOIN), 409 batch committed.
|
|
Raspunde OOB (rand + contoare) + HX-Trigger-After-Settle:inchideModal,
|
|
identic cu web_editeaza_rand la succes.
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
acct = account_or_default(account_id)
|
|
|
|
conn = get_connection()
|
|
try:
|
|
# Scoping: JOIN verifica ca batch-ul apartine contului si ca randul exista.
|
|
# Acelasi tipar ca apply_row_override (404 cross-account, 409 committed).
|
|
row_db = conn.execute(
|
|
"SELECT r.id AS rid, b.status AS bstatus "
|
|
"FROM import_rows r JOIN import_batches b ON b.id = r.batch_id "
|
|
"WHERE b.id=? AND b.account_id=? AND r.row_index=?",
|
|
(import_id, acct, row_index),
|
|
).fetchone()
|
|
if not row_db:
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
if row_db["bstatus"] == "committed":
|
|
raise HTTPException(status_code=409, detail="batch deja comis; confirmarea nu mai are efect")
|
|
|
|
# Seteaza reviewed=1 — marcaj separat, NU camp de continut (NU intra in override_json/payload).
|
|
conn.execute("UPDATE import_rows SET reviewed=1 WHERE id=?", (row_db["rid"],))
|
|
|
|
# Recalculeaza preview: randul cu reviewed=1 + fara erori reale devine ok.
|
|
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")
|
|
|
|
# Reincarca preview-ul complet + toast + inchide modal (identic cu succes editeaza).
|
|
return _raspuns_rand_salvat(import_id, row)
|
|
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.post("/_import/{import_id}/mapare-operatii", response_class=HTMLResponse)
|
|
async def web_mapare_operatii(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Un singur POST salveaza toate maparile de operatii (US-004).
|
|
|
|
Primeste perechi (cod_op_service, cod_prestatie) ca liste paralele din un singur
|
|
<form> cu un select per operatie. Apeleaza save_mapping pentru fiecare pereche cu
|
|
cod ales (reuse EXACT, fara logica noua). Perechile cu cod_prestatie gol sunt ignorate.
|
|
D#12: per-item — cod invalid -> skip + sumar, restul salvate. O singura recompute
|
|
_web_compute_preview + re-randare #import-section la final.
|
|
CSRF + scoped sesiune (404 cross-account via _web_compute_preview) + guard committed 409.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
|
|
# Guard batch committed (409)
|
|
acct = account_or_default(account_id)
|
|
batch = conn.execute(
|
|
"SELECT id, status FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
raise HTTPException(status_code=404, detail="batch de import inexistent sau inaccesibil")
|
|
if batch["status"] == "committed":
|
|
raise HTTPException(status_code=409, detail="batch deja comis; maparea nu mai are efect")
|
|
|
|
# Extrage listele paralele din form (getlist pentru valori multiple cu acelasi name)
|
|
ops_list = form.getlist("cod_op_service")
|
|
codes_list = form.getlist("cod_prestatie")
|
|
|
|
salvate: list[str] = []
|
|
sarite_invalide: list[str] = []
|
|
|
|
for cod_op_service, cod_prestatie in zip(ops_list, codes_list):
|
|
cod_op_service = str(cod_op_service or "").strip()
|
|
cod_prestatie = str(cod_prestatie or "").strip().upper()
|
|
|
|
# Ignora perechile fara cod ales (selectul ramas pe "— alege cod RAR —")
|
|
if not cod_op_service or not cod_prestatie:
|
|
continue
|
|
|
|
# Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
|
|
).fetchone()
|
|
if not exists:
|
|
sarite_invalide.append(f"{cod_op_service} ({cod_prestatie} necunoscut)")
|
|
continue
|
|
|
|
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send=False)
|
|
salvate.append(f"{cod_op_service} -> {cod_prestatie}")
|
|
|
|
# Compune mesajul sumar
|
|
parts: list[str] = []
|
|
if salvate:
|
|
parts.append(f"Salvate: {', '.join(salvate)}.")
|
|
if sarite_invalide:
|
|
parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.")
|
|
message = " ".join(parts) if parts else None
|
|
error = bool(sarite_invalide) and not salvate
|
|
|
|
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
|
|
))
|
|
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).
|
|
account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
|
|
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
|
|
|
|
# US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul
|
|
# de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8).
|
|
# Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok'
|
|
# in DB (recalculat de _web_compute_preview), asa ca interogarea de mai jos include corect
|
|
# TOATE randurile gata de trimis.
|
|
|
|
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 DOAR randurile ok din DB.
|
|
# D#8 (PRD 5.12): gate derivat din DB reviewed — randurile needs_review confirmate
|
|
# de operator via /confirma-review au resolved_status='ok' (recalculat de
|
|
# _web_compute_preview in calea /confirma-review). Randurile needs_review
|
|
# neconfirmate sunt excluse (nu au reviewed=1 => raman needs_review in DB).
|
|
# Fallback defensiv: includes si needs_review cu reviewed=1 (daca DB a ramas
|
|
# neactualizat din vreun motiv — ex. restart intre confirma-review si preview refresh).
|
|
ok_rows_db = conn.execute(
|
|
"SELECT row_index, raw_json, override_json, resolved_status, reviewed "
|
|
"FROM import_rows "
|
|
"WHERE batch_id=? AND (resolved_status='ok' OR "
|
|
"(resolved_status='needs_review' AND reviewed=1)) "
|
|
"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]] = []
|
|
|
|
for r in ok_rows_db:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
except Exception:
|
|
continue
|
|
to_enqueue.append({
|
|
"row_index": r["row_index"],
|
|
"data": row_data,
|
|
"override": _override_of(r),
|
|
"status": r["resolved_status"],
|
|
})
|
|
|
|
n_total_ok = len(to_enqueue)
|
|
|
|
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
|
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). Canal web.
|
|
from ..config import get_settings as _get_settings_plan
|
|
from ..plans import PLANS as _PLANS, effective_tier as _effective_tier, monthly_usage as _monthly_usage
|
|
_plan_settings = _get_settings_plan()
|
|
if _plan_settings.enforce_plans and n_total_ok > 0:
|
|
from datetime import datetime, timezone as _tz
|
|
_acct_row = conn.execute(
|
|
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
_now_plan = datetime.now(_tz.utc)
|
|
_et = _effective_tier(_acct_row, _now_plan)
|
|
_plan_limit = _PLANS[_et].get("monthly_limit")
|
|
if _plan_limit is not None:
|
|
_usage = _monthly_usage(conn, acct, _now_plan)
|
|
if _usage + n_total_ok > _plan_limit:
|
|
_remaining = max(0, _plan_limit - _usage)
|
|
log_event(
|
|
"plan_limita_lunara_atinsa",
|
|
account_id=acct,
|
|
nivel="WARNING",
|
|
mesaj=f"Import web de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})",
|
|
context={
|
|
"n_to_enqueue": n_total_ok, "usage": _usage,
|
|
"plan_limit": _plan_limit, "tier": _et,
|
|
},
|
|
conn=conn,
|
|
)
|
|
_err_msg = (
|
|
f"Ai atins limita planului Gratuit: {_usage}/{_plan_limit} prezentari luna aceasta."
|
|
f" Mai poti trimite {_remaining} luna aceasta."
|
|
f" Treci pe Standard sau Pro, sau asteapta luna viitoare."
|
|
)
|
|
_prev_result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(_prev_result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=_err_msg))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, message=_err_msg, error=True, **_prev_result
|
|
))
|
|
|
|
# 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()}
|
|
# 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 — 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 — 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:
|
|
# 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
|
|
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 + OOB swap al
|
|
# #trimiteri-section (injecteaza _coada.html cu lista proaspata) +
|
|
# header HX-Trigger: trimiteriChanged (declanseza reincarcarea automata).
|
|
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
|
succes_msg = (
|
|
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
|
|
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
|
|
)
|
|
|
|
# Calculeaza contextele (necesita conn deschis) inainte de finally.
|
|
acasa_ctx = _get_acasa_context(request, conn, account_id)
|
|
acasa_ctx["status_filtru"] = ""
|
|
acasa_ctx["oob"] = True # adauga hx-swap-oob="outerHTML" la <section>
|
|
|
|
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
|
|
|
|
# Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus).
|
|
upload_html = templates.get_template("_upload.html").render(
|
|
_ctx(request, are_trimiteri=True, message=succes_msg)
|
|
)
|
|
coada_html = templates.get_template("_coada.html").render(acasa_ctx)
|
|
status_html = templates.get_template("_status.html").render(status_ctx)
|
|
|
|
return HTMLResponse(
|
|
content=upload_html + "\n" + coada_html + "\n" + status_html,
|
|
headers={"HX-Trigger": "trimiteriChanged"},
|
|
)
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Sectiune "Contul meu": rotire cheie API + creds RAR din UI. #
|
|
# Rute web proprii scoped pe sesiune (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,
|
|
account_meta: dict | None = None,
|
|
date_firma_mesaj: str | None = None,
|
|
date_firma_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,
|
|
account_meta=account_meta or {},
|
|
date_firma_mesaj=date_firma_mesaj,
|
|
date_firma_eroare=date_firma_eroare,
|
|
),
|
|
)
|
|
|
|
|
|
def _fetch_account_meta(conn, acct: int) -> dict:
|
|
"""Intoarce metadatele contului (id, name, cui, email) pentru sectiunea 'Date firma'."""
|
|
row = conn.execute(
|
|
"SELECT id, name, cui, email FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
if not row:
|
|
return {"id": acct, "name": "", "cui": "", "email": ""}
|
|
return {
|
|
"id": row["id"],
|
|
"name": row["name"] or "",
|
|
"cui": row["cui"] or "",
|
|
"email": row["email"] or "",
|
|
}
|
|
|
|
|
|
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
|
def fragment_cont(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR + date firma."""
|
|
account_id = require_login(request)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
return _render_cont(request, are_creds=are_creds, account_meta=account_meta)
|
|
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"])
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
return _render_cont(request, api_key=new_key, are_creds=are_creds, account_meta=account_meta)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/cont/date-firma", response_class=HTMLResponse)
|
|
async def cont_date_firma(request: Request) -> HTMLResponse:
|
|
"""Actualizeaza datele firmei (companie, email, CUI) pentru contul din sesiune.
|
|
|
|
Valideaza campurile (reuse _norm_cui / _norm_email din accounts.py), verifica
|
|
unicitatea CUI-ului, actualizeaza accounts.name/email/cui. CSRF enforce.
|
|
Scoped pe contul sesiunii (nu poate atinge alt cont).
|
|
"""
|
|
from ..accounts import _norm_cui, _norm_email
|
|
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
acct = account_or_default(account_id)
|
|
|
|
companie_raw = str(form.get("companie") or "").strip()
|
|
email_raw = str(form.get("email") or "")
|
|
cui_raw = str(form.get("cui") or "")
|
|
|
|
# Validare companie
|
|
if not companie_raw:
|
|
conn = get_connection()
|
|
try:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta=account_meta,
|
|
date_firma_eroare="Compania (numele firmei) este obligatorie.",
|
|
)
|
|
|
|
# Normalizare si validare email
|
|
try:
|
|
email_norm = _norm_email(email_raw)
|
|
except ValueError as exc:
|
|
conn = get_connection()
|
|
try:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
|
date_firma_eroare=f"Email invalid: {exc}",
|
|
)
|
|
|
|
if not email_norm:
|
|
conn = get_connection()
|
|
try:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
|
date_firma_eroare="Email-ul de contact este obligatoriu.",
|
|
)
|
|
|
|
# Normalizare si validare CUI
|
|
try:
|
|
cui_norm = _norm_cui(cui_raw)
|
|
except ValueError as exc:
|
|
conn = get_connection()
|
|
try:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
|
date_firma_eroare=f"CUI invalid: {exc}",
|
|
)
|
|
|
|
if not cui_norm:
|
|
conn = get_connection()
|
|
try:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
|
date_firma_eroare="CUI-ul firmei este obligatoriu.",
|
|
)
|
|
|
|
# Actualizare in DB
|
|
conn = get_connection()
|
|
try:
|
|
try:
|
|
conn.execute(
|
|
"UPDATE accounts SET name=?, email=?, cui=? WHERE id=?",
|
|
(companie_raw, email_norm, cui_norm, acct),
|
|
)
|
|
except sqlite3.IntegrityError:
|
|
# CUI duplicat (index partial unic ux_accounts_cui)
|
|
existing = conn.execute(
|
|
"SELECT id FROM accounts WHERE cui=? AND id!=?", (cui_norm, acct)
|
|
).fetchone()
|
|
owner = existing["id"] if existing else "?"
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_norm},
|
|
date_firma_eroare=(
|
|
f"CUI-ul {cui_norm} este deja folosit de alt cont (id={owner}). "
|
|
"Foloseste un CUI diferit sau contacteaza administratorul."
|
|
),
|
|
)
|
|
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
account_meta=account_meta,
|
|
date_firma_mesaj="Datele firmei au fost salvate.",
|
|
)
|
|
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.
|
|
|
|
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).
|
|
|
|
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"])
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
creds_eroare="Email si parola sunt obligatorii.",
|
|
account_meta=account_meta,
|
|
)
|
|
|
|
enc = encrypt_creds({"email": email, "password": parola})
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
return _render_cont(
|
|
request,
|
|
are_creds=True,
|
|
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
|
account_meta=account_meta,
|
|
)
|
|
finally:
|
|
conn.close()
|