Textul din bannerul de import (0 medii) si din antetul formularului de credentiale nu spunea concret ce mediu foloseste instanta curenta. Vechiul "Trimiterea va folosi configuratia globala" era jargon, iar "Pentru a activa Testare sau Productie" nu clarifica relatia instanta<->mediu. - Adauga globalul Jinja `mediu_instanta()` = eticheta umana a ancorei globale AUTOPASS_RAR_ENV (Testare/Productie), fallback sigur pe Testare. - `_upload.html`: bannerul de 0 medii numeste concret mediul global al instantei pe care cad trimiterile pana la activarea unui mediu. - `_cont.html`: nota onesta sub antetul "Credentiale RAR" — instanta ruleaza pe mediul global X, ambele medii se pot configura aici (fiecare validat separat), iar la 0 medii active trimiterile cad pe mediul global al instantei. Fara selector nou si fara schimbari in logica de scriere a credentialelor (A1, aliniat PRD 5.20: instanta = ancora de fallback pentru env). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4885 lines
206 KiB
Python
4885 lines
206 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_env,
|
|
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
|
|
from ..rar_env import MediuIndisponibil, medii_disponibile_cont, rar_env_efectiv_cont, rezolva_rar_env
|
|
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
|
|
|
|
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
|
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
|
|
|
|
|
def _import_env_ctx(conn, account_id: int) -> dict:
|
|
"""Contextul de mediu RAR pentru paginile de import (US-009, PRD 5.20).
|
|
|
|
Intoarce {'medii': list[str], 'env_default': str} pentru template-ul _upload.html.
|
|
Un mediu e disponibil = activat SI are credentiale. La 0 medii template afiseaza
|
|
un banner non-blocant; la 1 eticheta statica; la >=2 selector.
|
|
"""
|
|
medii = medii_disponibile_cont(conn, account_id)
|
|
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
|
|
return {"medii": medii, "env_default": env_default}
|
|
|
|
|
|
router = APIRouter(tags=["web"])
|
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
|
# Expune parse_erori si eticheta_env in toate template-urile
|
|
templates.env.globals["parse_erori"] = parse_erori
|
|
templates.env.globals["eticheta_env"] = eticheta_env
|
|
|
|
|
|
def _mediu_instanta() -> str:
|
|
"""Eticheta umana a mediului GLOBAL al instantei care ruleaza (AUTOPASS_RAR_ENV).
|
|
|
|
E ancora de fallback (PRD 5.20): cand un cont nu are niciun mediu RAR configurat,
|
|
trimiterile cad pe acest mediu global. Expusa in template-uri ca mesaj onest despre
|
|
ce mediu foloseste instanta curenta. Nu arunca niciodata.
|
|
"""
|
|
env = get_settings().rar_env
|
|
return "Productie" if env == "prod" else "Testare"
|
|
|
|
|
|
# Expune mediul global al instantei (ancora fallback PRD 5.20) pentru mesaje oneste.
|
|
templates.env.globals["mediu_instanta"] = _mediu_instanta
|
|
|
|
_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)
|
|
# US-013: citim exclusiv sloturile per-env (legacy accounts.rar_creds_enc a fost dropat).
|
|
row = conn.execute(
|
|
"SELECT id, name, cui, email, rar_creds_test_enc, rar_creds_prod_enc "
|
|
"FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and (
|
|
row["rar_creds_test_enc"] or row["rar_creds_prod_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)
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
cont_ctx = {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"api_key": None,
|
|
"creds_mesaj": None,
|
|
"creds_eroare": None,
|
|
"rot_eroare": None,
|
|
"account_meta": account_meta,
|
|
"date_firma_mesaj": None,
|
|
"date_firma_eroare": None,
|
|
"creds_test_mesaj": None,
|
|
"creds_test_eroare": None,
|
|
"creds_prod_mesaj": None,
|
|
"creds_prod_eroare": None,
|
|
"creds_default_eroare": None,
|
|
"creds_default_mesaj": None,
|
|
**env_ctx,
|
|
}
|
|
# 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_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row_creds and (row_creds["rar_creds_test_enc"] or row_creds["rar_creds_prod_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,
|
|
# US-010 (PRD 5.20): mediul implicit al contului pentru badge de sectiune.
|
|
"env_default": rar_env_efectiv_cont(conn, account_id) or "test",
|
|
}
|
|
|
|
|
|
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."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, **env_ctx))
|
|
|
|
|
|
@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"
|
|
|
|
# US-011 (5.20): mediul RAR activ per cont pentru indicatorul din statusbar.
|
|
medii_disp = medii_disponibile_cont(conn, account_id)
|
|
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
|
|
|
|
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-011: indicator mediu RAR + toggle conditionat
|
|
"env_default": env_default,
|
|
"medii_disponibile": medii_disp,
|
|
"csrf_token": get_csrf_token(request),
|
|
}
|
|
# 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,
|
|
# US-010 (PRD 5.20): mediul RAR tinta — badge in lista.
|
|
"rar_env": r["rar_env"] if "rar_env" in r.keys() else None,
|
|
}
|
|
|
|
|
|
_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, rar_env 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, rar_env 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,
|
|
# US-010 (PRD 5.20): mediul RAR tinta — badge in detaliu.
|
|
"rar_env": row["rar_env"] if "rar_env" in row.keys() else None,
|
|
# 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,
|
|
rar_env: str | None = None,
|
|
) -> 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.
|
|
|
|
`rar_env`: mediul RAR ales de operator; None = default efectiv al contului
|
|
(sau ancora globala daca contul nu are medii configurate). Folosit la calculul
|
|
cheii de idempotenta la preview — trebuie sa coincida cu env-ul de la commit.
|
|
"""
|
|
acct = account_or_default(account_id)
|
|
# Mediul folosit la calculul cheii de idempotenta (preview == commit).
|
|
preview_env = rar_env or rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
|
|
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"], preview_env)
|
|
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),
|
|
rar_env: 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:
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names, **env_ctx))
|
|
except FileTooLarge as e:
|
|
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=str(e), eroare_upload=eroare_upload, **env_ctx
|
|
))
|
|
except HeaderError as e:
|
|
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload, **env_ctx
|
|
))
|
|
except UnicodeDecodeError as e:
|
|
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload, **env_ctx
|
|
))
|
|
except Exception as e:
|
|
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload, **env_ctx
|
|
))
|
|
|
|
conn = get_connection()
|
|
try:
|
|
sig = _signature(parsed.columns)
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
|
|
# Rezolva mediul RAR ales — cerut din form sau default cont (fallback ancora globala).
|
|
# La 0 medii: rezolva_rar_env cade pe ancora globala (rar_env config), non-blocant.
|
|
try:
|
|
upload_env = rezolva_rar_env(conn, account_id, rar_env or None)
|
|
except MediuIndisponibil as e:
|
|
# US-012: audit mediu cerut dar indisponibil (fallback silentios, non-blocant).
|
|
log_event(
|
|
"rar_env_blocat",
|
|
nivel="WARNING",
|
|
account_id=account_id,
|
|
context={"env": e.env},
|
|
conn=conn,
|
|
)
|
|
upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
except ValueError:
|
|
upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
|
|
# 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, rar_env=upload_env)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=result, **env_ctx
|
|
))
|
|
return templates.TemplateResponse("_preview_import.html", {
|
|
"request": request,
|
|
"import_id": batch_id_int,
|
|
"message": "Mapare retinuta aplicata automat.",
|
|
"csrf_token": get_csrf_token(request),
|
|
"rar_env": upload_env,
|
|
**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),
|
|
"rar_env": upload_env,
|
|
})
|
|
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:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error="Batch de import inexistent sau expirat.", **env_ctx
|
|
))
|
|
|
|
# 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),
|
|
)
|
|
|
|
# Mediu RAR transmis din form-ul de mapare (daca exista) sau default cont
|
|
form_rar_env = str(form.get("rar_env") or "").strip() or None
|
|
try:
|
|
mapare_env = rezolva_rar_env(conn, account_id, form_rar_env)
|
|
except MediuIndisponibil as e:
|
|
# US-012: audit mediu cerut dar indisponibil la mapare coloane (fallback silentios).
|
|
log_event(
|
|
"rar_env_blocat",
|
|
nivel="WARNING",
|
|
account_id=account_id,
|
|
context={"env": e.env},
|
|
conn=conn,
|
|
)
|
|
mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
except ValueError:
|
|
mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
|
|
# Computa preview
|
|
result = _web_compute_preview(conn, import_id, account_id, rar_env=mapare_env)
|
|
if isinstance(result, str):
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result, **env_ctx))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, rar_env=mapare_env, **result
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/{import_id}/preview", response_class=HTMLResponse)
|
|
def web_preview_import(
|
|
request: Request,
|
|
import_id: int,
|
|
rar_env: str | None = None,
|
|
) -> HTMLResponse:
|
|
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
# Rezolva mediul pentru preview (din query param sau default cont)
|
|
try:
|
|
preview_env = rezolva_rar_env(conn, account_id, rar_env)
|
|
except MediuIndisponibil as e:
|
|
# US-012: audit mediu cerut dar indisponibil la preview (fallback silentios).
|
|
log_event(
|
|
"rar_env_blocat",
|
|
nivel="WARNING",
|
|
account_id=account_id,
|
|
context={"env": e.env},
|
|
conn=conn,
|
|
)
|
|
preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
except ValueError:
|
|
preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
result = _web_compute_preview(conn, import_id, account_id, rar_env=preview_env)
|
|
if isinstance(result, str):
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"error": result,
|
|
"csrf_token": get_csrf_token(request),
|
|
**env_ctx,
|
|
})
|
|
return templates.TemplateResponse("_preview_import.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"csrf_token": get_csrf_token(request),
|
|
"rar_env": preview_env,
|
|
**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)."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _import_env_ctx(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, **env_ctx))
|
|
|
|
|
|
@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
|
|
|
|
# Mediu RAR din form (selectat in preview); None = default cont (fallback ancora globala)
|
|
rar_env_cerut = str(form.get("rar_env") or "").strip() or None
|
|
|
|
# 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)
|
|
|
|
# Rezolva mediul RAR tinta al lotului (US-009): form > default cont > ancora globala.
|
|
try:
|
|
env = rezolva_rar_env(conn, account_id, rar_env_cerut)
|
|
except MediuIndisponibil as e:
|
|
# US-012: audit mediu cerut dar indisponibil la commit import (fallback silentios).
|
|
log_event(
|
|
"rar_env_blocat",
|
|
nivel="WARNING",
|
|
account_id=account_id,
|
|
context={"env": e.env},
|
|
conn=conn,
|
|
)
|
|
env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
except ValueError:
|
|
env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
|
|
|
# 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, env)
|
|
|
|
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, rar_env) "
|
|
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'), ?)",
|
|
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index, env),
|
|
)
|
|
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).
|
|
env_ctx_success = _import_env_ctx(conn, account_id)
|
|
upload_html = templates.get_template("_upload.html").render(
|
|
_ctx(request, are_trimiteri=True, message=succes_msg, **env_ctx_success)
|
|
)
|
|
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). #
|
|
# =========================================================================== #
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# US-007 (PRD 5.20): Validare credentiale RAR env-aware
|
|
#
|
|
# Premisa confirmata live (2026-06-29): creds de productie NU se valideaza pe RAR
|
|
# test si invers (401 incrucisat). Deci login-ul de proba TREBUIE sa loveasca
|
|
# endpoint-ul mediului caruia ii apartin credentialele.
|
|
#
|
|
# Puncte de validare existente:
|
|
# - /cont/test-rar-creds (testeaza integrarea RAR, fara efecte secundare)
|
|
# Puncte non-aplicabile (nu colecteaza/valideaza creds RAR):
|
|
# - signup (/signup): nu colecteaza credentiale RAR — creare cont platforma, nu RAR
|
|
# - preview import: nu valideaza credentiale RAR
|
|
# Puncte viitoare (US-008):
|
|
# - /cont/rar-creds la salvare creds per-mediu (va apela _valideaza_login_rar)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _eticheta_mediu_rar(env: str) -> str:
|
|
"""Eticheta umana a mediului RAR pentru mesaje de eroare/succes.
|
|
|
|
'test' -> 'TESTARE', 'prod' -> 'PRODUCTIE'.
|
|
"""
|
|
return "PRODUCTIE" if env == "prod" else "TESTARE"
|
|
|
|
|
|
def _valideaza_login_rar(
|
|
settings,
|
|
email: str,
|
|
password: str,
|
|
env: str,
|
|
) -> tuple[bool, str | None]:
|
|
"""Valideaza credentialele RAR prin login pe mediul specificat (US-007, PRD 5.20).
|
|
|
|
Creeaza un RarClient cu base_url-ul mediului `env` (NU base_url-ul global),
|
|
deoarece RAR test si RAR prod sunt sisteme separate cu credentiale separate.
|
|
|
|
Parametri
|
|
---------
|
|
settings: configuratia aplicatiei (pentru base_url_test/prod si timeout)
|
|
email: email-ul contului RAR
|
|
password: parola contului RAR
|
|
env: mediul tinta: 'test' sau 'prod'
|
|
|
|
Returneaza
|
|
----------
|
|
(True, None) la succes (login reusit)
|
|
(False, mesaj) la esec; `mesaj` include eticheta mediului ('TESTARE'/'PRODUCTIE'),
|
|
ex. 'Credentiale RAR invalide pe TESTARE.'
|
|
"""
|
|
env_label = _eticheta_mediu_rar(env)
|
|
try:
|
|
with RarClient(settings, base_url=base_url_pentru_env(settings, env)) as rar:
|
|
rar.login(email, password)
|
|
return True, None
|
|
except RarAuthError:
|
|
return False, f"Credentiale RAR invalide pe {env_label}."
|
|
except RarError as exc:
|
|
return False, f"Eroare la conectare RAR ({env_label}): {exc}"
|
|
|
|
|
|
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,
|
|
# Per-env (US-008, PRD 5.20): starea mediilor RAR Testare + Productie.
|
|
test_enabled: bool = False,
|
|
prod_enabled: bool = True,
|
|
test_disponibil: bool = False,
|
|
prod_disponibil: bool = False,
|
|
rar_env_default: str = "prod",
|
|
medii_disponibile: list | None = None,
|
|
creds_test_mesaj: str | None = None,
|
|
creds_test_eroare: str | None = None,
|
|
creds_prod_mesaj: str | None = None,
|
|
creds_prod_eroare: str | None = None,
|
|
creds_default_eroare: str | None = None,
|
|
creds_default_mesaj: 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,
|
|
test_enabled=test_enabled,
|
|
prod_enabled=prod_enabled,
|
|
test_disponibil=test_disponibil,
|
|
prod_disponibil=prod_disponibil,
|
|
rar_env_default=rar_env_default,
|
|
medii_disponibile=medii_disponibile or [],
|
|
creds_test_mesaj=creds_test_mesaj,
|
|
creds_test_eroare=creds_test_eroare,
|
|
creds_prod_mesaj=creds_prod_mesaj,
|
|
creds_prod_eroare=creds_prod_eroare,
|
|
creds_default_eroare=creds_default_eroare,
|
|
creds_default_mesaj=creds_default_mesaj,
|
|
),
|
|
)
|
|
|
|
|
|
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 "",
|
|
}
|
|
|
|
|
|
def _fetch_cont_env_state(conn, acct: int) -> dict:
|
|
"""Starea mediilor RAR per env pentru sectiunea 'Credentiale RAR' din _cont.html (US-008).
|
|
|
|
Returneaza un dict compatibil cu parametrii per-env ai _render_cont:
|
|
are_creds -- True daca ORICE credentiale RAR sunt configurate (legacy SAU per-env)
|
|
test_enabled -- bifa activare Testare
|
|
prod_enabled -- bifa activare Productie
|
|
test_disponibil -- Testare activata SI cu creds (poate trimite)
|
|
prod_disponibil -- Productie activata SI cu creds (poate trimite)
|
|
rar_env_default -- mediul implicit al contului
|
|
medii_disponibile -- lista mediilor disponibile (subset din ['test','prod'])
|
|
"""
|
|
row = conn.execute(
|
|
"SELECT rar_test_enabled, rar_prod_enabled, "
|
|
"rar_creds_test_enc, rar_creds_prod_enc, rar_env_default "
|
|
"FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
if not row:
|
|
return {
|
|
"are_creds": False,
|
|
"test_enabled": False,
|
|
"prod_enabled": True,
|
|
"test_disponibil": False,
|
|
"prod_disponibil": False,
|
|
"rar_env_default": "prod",
|
|
"medii_disponibile": [],
|
|
}
|
|
test_enabled = bool(row["rar_test_enabled"])
|
|
prod_enabled = bool(row["rar_prod_enabled"])
|
|
test_disponibil = test_enabled and bool(row["rar_creds_test_enc"])
|
|
prod_disponibil = prod_enabled and bool(row["rar_creds_prod_enc"])
|
|
medii: list[str] = []
|
|
if test_disponibil:
|
|
medii.append("test")
|
|
if prod_disponibil:
|
|
medii.append("prod")
|
|
# US-013: are_creds bazat EXCLUSIV pe sloturile per-env (legacy rar_creds_enc dropat).
|
|
are_creds = bool(
|
|
row["rar_creds_test_enc"] or row["rar_creds_prod_enc"]
|
|
)
|
|
return {
|
|
"are_creds": are_creds,
|
|
"test_enabled": test_enabled,
|
|
"prod_enabled": prod_enabled,
|
|
"test_disponibil": test_disponibil,
|
|
"prod_disponibil": prod_disponibil,
|
|
"rar_env_default": row["rar_env_default"] or "prod",
|
|
"medii_disponibile": medii,
|
|
}
|
|
|
|
|
|
@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:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(request, account_meta=account_meta, **env_ctx)
|
|
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)
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(request, api_key=new_key, account_meta=account_meta, **env_ctx)
|
|
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)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
account_meta=account_meta,
|
|
date_firma_eroare="Compania (numele firmei) este obligatorie.",
|
|
**env_ctx,
|
|
)
|
|
|
|
# Normalizare si validare email
|
|
try:
|
|
email_norm = _norm_email(email_raw)
|
|
except ValueError as exc:
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
|
date_firma_eroare=f"Email invalid: {exc}",
|
|
**env_ctx,
|
|
)
|
|
|
|
if not email_norm:
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
|
date_firma_eroare="Email-ul de contact este obligatoriu.",
|
|
**env_ctx,
|
|
)
|
|
|
|
# Normalizare si validare CUI
|
|
try:
|
|
cui_norm = _norm_cui(cui_raw)
|
|
except ValueError as exc:
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
|
date_firma_eroare=f"CUI invalid: {exc}",
|
|
**env_ctx,
|
|
)
|
|
|
|
if not cui_norm:
|
|
conn = get_connection()
|
|
try:
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
|
date_firma_eroare="CUI-ul firmei este obligatoriu.",
|
|
**env_ctx,
|
|
)
|
|
|
|
# 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)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(
|
|
request,
|
|
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."
|
|
),
|
|
**env_ctx,
|
|
)
|
|
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(
|
|
request,
|
|
account_meta=account_meta,
|
|
date_firma_mesaj="Datele firmei au fost salvate.",
|
|
**env_ctx,
|
|
)
|
|
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:
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
creds_eroare="Email si parola sunt obligatorii.",
|
|
account_meta=account_meta,
|
|
**env_ctx,
|
|
)
|
|
|
|
enc = encrypt_creds({"email": email, "password": parola})
|
|
# US-013: scrie in slotul per-env al ancorei globale (nu mai exista coloana legacy).
|
|
_env_w = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
f"UPDATE accounts SET rar_creds_{_env_w}_enc=?, rar_{_env_w}_enabled=1 WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(
|
|
request,
|
|
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
|
account_meta=account_meta,
|
|
**env_ctx,
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/cont/test-rar-creds", response_class=HTMLResponse)
|
|
def cont_test_rar_creds(
|
|
request: Request,
|
|
rar_email: str = Form(""),
|
|
rar_parola: str = Form(""),
|
|
rar_env: str = Form(default=""),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Testeaza credentialele RAR prin login real pe mediul specificat (US-007, PRD 5.20).
|
|
|
|
Fara efecte secundare: nu salveaza nimic, nu creeaza submission. Pur validare.
|
|
Camp parola NICIODATA re-pus in raspuns.
|
|
|
|
Decizie env (documentata US-007):
|
|
- param `rar_env` explicit ('test'/'prod') -> folosit direct
|
|
- altfel -> rar_env_efectiv_cont (default-ul contului) sau ancora globala settings.rar_env
|
|
- signup nu colecteaza creds RAR, deci nu apeleaza aceasta functie
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
|
|
email = rar_email.strip()
|
|
parola = rar_parola.strip()
|
|
|
|
if not email or not parola:
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False,
|
|
"mesaj": "Email si parola sunt obligatorii."},
|
|
)
|
|
|
|
# Determina env-ul de validare
|
|
settings = get_settings()
|
|
env_cerut = (rar_env or "").strip().lower()
|
|
if env_cerut in ("test", "prod"):
|
|
env = env_cerut
|
|
else:
|
|
# Fallback: env-ul efectiv al contului (default) sau ancora globala
|
|
conn = get_connection()
|
|
try:
|
|
env = rar_env_efectiv_cont(conn, account_id) or settings.rar_env or "test"
|
|
finally:
|
|
conn.close()
|
|
|
|
ok, mesaj_eroare = _valideaza_login_rar(settings, email, parola, env)
|
|
if ok:
|
|
env_label = _eticheta_mediu_rar(env)
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": True,
|
|
"mesaj": f"Credentiale RAR valide pe {env_label}."},
|
|
)
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False, "mesaj": mesaj_eroare},
|
|
)
|
|
|
|
|
|
# =========================================================================== #
|
|
# US-008 (PRD 5.20): Configurare medii RAR per cont — Testare + Productie. #
|
|
# Ruta noua /cont/rar-medii: gestioneaza bifa activare, credentiale si #
|
|
# mediul implicit separat pentru fiecare din cele doua medii RAR. #
|
|
# =========================================================================== #
|
|
|
|
@router.post("/cont/rar-medii", response_class=HTMLResponse)
|
|
async def cont_rar_medii(request: Request) -> HTMLResponse:
|
|
"""Salveaza configuratia mediilor RAR per cont (US-008, PRD 5.20).
|
|
|
|
Doua sectiuni independente (Testare / Productie): fiecare cu bifa de activare
|
|
si campuri email/parola. La salvare, pentru fiecare mediu activat cu creds noi:
|
|
- valideaza prin login pe acel env (US-007) — RAR test si prod sunt sisteme separate;
|
|
- OK -> cripteaza cu Fernet si scrie in rar_creds_{env}_enc + enabled=1;
|
|
- esec login -> eroare per-env, mediul NU devine disponibil (creds nesalvate).
|
|
|
|
Activarea Productie pentru prima oara necesita checkbox-ul de confirmare
|
|
(constientizare L.142 — trimiterile sunt declaratii oficiale, finale si fara anulare).
|
|
|
|
Mediul implicit (rar_env_default) poate fi setat DOAR pe un mediu disponibil
|
|
(validat server-side post-update; altfel eroare, valoarea veche ramane).
|
|
|
|
Parolele NICIODATA reflectate inapoi in pagina (camp gol cu placeholder).
|
|
"""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
acct = account_or_default(account_id)
|
|
|
|
test_enabled_form = form.get("test_enabled") == "1"
|
|
prod_enabled_form = form.get("prod_enabled") == "1"
|
|
prod_confirmare = form.get("prod_confirmare") == "1"
|
|
test_email = str(form.get("test_email") or "").strip()
|
|
test_parola = str(form.get("test_parola") or "").strip()
|
|
prod_email = str(form.get("prod_email") or "").strip()
|
|
prod_parola = str(form.get("prod_parola") or "").strip()
|
|
rar_env_default_form = str(form.get("rar_env_default") or "").strip()
|
|
|
|
settings = get_settings()
|
|
creds_test_eroare: str | None = None
|
|
creds_test_mesaj: str | None = None
|
|
creds_prod_eroare: str | None = None
|
|
creds_prod_mesaj: str | None = None
|
|
creds_default_eroare: str | None = None
|
|
creds_default_mesaj: str | None = None
|
|
|
|
conn = get_connection()
|
|
try:
|
|
# Starea curenta din DB (inainte de update) — necesara pt logica de confirmare prod
|
|
# si pt a loga DOAR schimbari reale (US-012).
|
|
row_before = conn.execute(
|
|
"SELECT rar_test_enabled, rar_prod_enabled, rar_env_default FROM accounts WHERE id=?",
|
|
(acct,),
|
|
).fetchone()
|
|
was_test_enabled = bool(row_before["rar_test_enabled"]) if row_before else False
|
|
was_prod_enabled = bool(row_before["rar_prod_enabled"]) if row_before else False
|
|
prev_env_default = row_before["rar_env_default"] if row_before else None
|
|
|
|
# Confirmare obligatorie la PRIMA activare Productie (constientizare L.142).
|
|
# Nu se cere daca Productie era deja activata (confirmare unica per-activare).
|
|
if prod_enabled_form and not was_prod_enabled and not prod_confirmare:
|
|
creds_prod_eroare = (
|
|
"Bifati confirmarea de mai jos pentru a activa mediul Productie: "
|
|
"\"Inteleg ca trimiterile pe Productie sunt declaratii reale (L.142)\"."
|
|
)
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(
|
|
request,
|
|
account_meta=account_meta,
|
|
creds_prod_eroare=creds_prod_eroare,
|
|
**env_ctx,
|
|
)
|
|
|
|
# --- Procesare Testare ---
|
|
# Tipuri de audit US-012: 'rar_env_activat' / 'rar_env_dezactivat' / 'rar_env_default_schimbat'.
|
|
if test_enabled_form:
|
|
if test_email and test_parola:
|
|
# Ambele campuri completate -> valideaza prin login pe RAR Testare.
|
|
ok, mesaj = _valideaza_login_rar(settings, test_email, test_parola, "test")
|
|
if ok:
|
|
enc = encrypt_creds({"email": test_email, "password": test_parola})
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_test_enabled=1, rar_creds_test_enc=? WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
creds_test_mesaj = "Credentiale Testare salvate si validate."
|
|
# Activare reala: creds noi salvate (schimbare efectiva indiferent de starea anterioara).
|
|
log_event(
|
|
"rar_env_activat",
|
|
account_id=acct,
|
|
mesaj="Credentiale Testare salvate si validate.",
|
|
context={"env": "test"},
|
|
conn=conn,
|
|
)
|
|
else:
|
|
# Login esuat: nu schimbam creds sau enabled; eroare per-env.
|
|
creds_test_eroare = mesaj
|
|
elif test_email or test_parola:
|
|
# Doar unul din campuri completat -> eroare (nu pot fi partial).
|
|
creds_test_eroare = "Email si parola Testare trebuie completate impreuna."
|
|
else:
|
|
# Activat fara creds noi -> marcheaza enabled (creds existente, daca sunt, raman).
|
|
conn.execute("UPDATE accounts SET rar_test_enabled=1 WHERE id=?", (acct,))
|
|
# Loga activare doar daca era dezactivat (0->1 e schimbare reala).
|
|
if not was_test_enabled:
|
|
log_event(
|
|
"rar_env_activat",
|
|
account_id=acct,
|
|
mesaj="Mediu Testare activat (creds existente).",
|
|
context={"env": "test"},
|
|
conn=conn,
|
|
)
|
|
else:
|
|
# Dezactivat -> disabled=0; creds raman pentru posibila re-activare ulterioara.
|
|
conn.execute("UPDATE accounts SET rar_test_enabled=0 WHERE id=?", (acct,))
|
|
# Loga dezactivare doar daca era activat (1->0 e schimbare reala).
|
|
if was_test_enabled:
|
|
log_event(
|
|
"rar_env_dezactivat",
|
|
account_id=acct,
|
|
mesaj="Mediu Testare dezactivat.",
|
|
context={"env": "test"},
|
|
conn=conn,
|
|
)
|
|
|
|
# --- Procesare Productie ---
|
|
if prod_enabled_form:
|
|
if prod_email and prod_parola:
|
|
ok, mesaj = _valideaza_login_rar(settings, prod_email, prod_parola, "prod")
|
|
if ok:
|
|
enc = encrypt_creds({"email": prod_email, "password": prod_parola})
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=? WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
creds_prod_mesaj = "Credentiale Productie salvate si validate."
|
|
# Activare reala: creds noi salvate.
|
|
log_event(
|
|
"rar_env_activat",
|
|
account_id=acct,
|
|
mesaj="Credentiale Productie salvate si validate.",
|
|
context={"env": "prod"},
|
|
conn=conn,
|
|
)
|
|
else:
|
|
creds_prod_eroare = mesaj
|
|
elif prod_email or prod_parola:
|
|
creds_prod_eroare = "Email si parola Productie trebuie completate impreuna."
|
|
else:
|
|
conn.execute("UPDATE accounts SET rar_prod_enabled=1 WHERE id=?", (acct,))
|
|
# Loga activare doar daca era dezactivat (0->1).
|
|
if not was_prod_enabled:
|
|
log_event(
|
|
"rar_env_activat",
|
|
account_id=acct,
|
|
mesaj="Mediu Productie activat (creds existente).",
|
|
context={"env": "prod"},
|
|
conn=conn,
|
|
)
|
|
else:
|
|
conn.execute("UPDATE accounts SET rar_prod_enabled=0 WHERE id=?", (acct,))
|
|
# Loga dezactivare doar daca era activat (1->0).
|
|
if was_prod_enabled:
|
|
log_event(
|
|
"rar_env_dezactivat",
|
|
account_id=acct,
|
|
mesaj="Mediu Productie dezactivat.",
|
|
context={"env": "prod"},
|
|
conn=conn,
|
|
)
|
|
|
|
# --- Mediu implicit (validat post-update contra mediilor disponibile) ---
|
|
if rar_env_default_form and rar_env_default_form in ("test", "prod"):
|
|
from ..rar_env import medii_disponibile as _medii_disponibile_fn
|
|
row_after = conn.execute(
|
|
"SELECT rar_test_enabled, rar_prod_enabled, "
|
|
"rar_creds_test_enc, rar_creds_prod_enc "
|
|
"FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
medii_post = _medii_disponibile_fn(row_after)
|
|
if rar_env_default_form in medii_post:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_env_default=? WHERE id=?",
|
|
(rar_env_default_form, acct),
|
|
)
|
|
creds_default_mesaj = "Mediu implicit actualizat."
|
|
# Loga schimbarea default doar daca valoarea s-a schimbat efectiv (US-012).
|
|
if rar_env_default_form != prev_env_default:
|
|
log_event(
|
|
"rar_env_default_schimbat",
|
|
account_id=acct,
|
|
mesaj=f"Mediu implicit schimbat in '{rar_env_default_form}'.",
|
|
context={"env": rar_env_default_form},
|
|
conn=conn,
|
|
)
|
|
else:
|
|
creds_default_eroare = (
|
|
"Mediul ales nu e disponibil — activeaza-l si adauga credentiale valide intai."
|
|
)
|
|
|
|
account_meta = _fetch_account_meta(conn, acct)
|
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
|
return _render_cont(
|
|
request,
|
|
account_meta=account_meta,
|
|
creds_test_mesaj=creds_test_mesaj,
|
|
creds_test_eroare=creds_test_eroare,
|
|
creds_prod_mesaj=creds_prod_mesaj,
|
|
creds_prod_eroare=creds_prod_eroare,
|
|
creds_default_eroare=creds_default_eroare,
|
|
creds_default_mesaj=creds_default_mesaj,
|
|
**env_ctx,
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_fragments/status/toggle-env", response_class=HTMLResponse)
|
|
async def fragment_status_toggle_env(request: Request) -> HTMLResponse:
|
|
"""Comuta rar_env_default intre mediile disponibile ale contului (US-011, PRD 5.20).
|
|
|
|
Valideaza ca noul mediu e in lista mediilor disponibile inainte de UPDATE.
|
|
Intoarce statusbar-ul actualizat (acelasi format ca /_fragments/status).
|
|
Ignorat silentios daca contul are un singur mediu sau zero medii disponibile.
|
|
"""
|
|
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:
|
|
medii = medii_disponibile_cont(conn, account_id)
|
|
if len(medii) >= 2:
|
|
env_curent = rar_env_efectiv_cont(conn, account_id) or medii[0]
|
|
# Alterneaza la urmatorul mediu din lista disponibile (ciclic)
|
|
idx_curent = medii.index(env_curent) if env_curent in medii else 0
|
|
env_nou = medii[(idx_curent + 1) % len(medii)]
|
|
# Dubla validare: env_nou trebuie sa fie in medii disponibile
|
|
if env_nou in medii:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_env_default=? WHERE id=?",
|
|
(env_nou, acct),
|
|
)
|
|
# US-012: audit comutare mediu implicit din statusbar.
|
|
log_event(
|
|
"rar_env_default_schimbat",
|
|
account_id=acct,
|
|
mesaj=f"Mediu implicit comutat in '{env_nou}' via statusbar.",
|
|
context={"env": env_nou},
|
|
conn=conn,
|
|
)
|
|
conn.commit()
|
|
|
|
ctx = _build_status_ctx(request, conn, account_id, tab_activ="acasa")
|
|
return templates.TemplateResponse("_status.html", ctx)
|
|
finally:
|
|
conn.close()
|