8 stories TDD (echipa Sonnet, lead orchestreaza). US-001 scoate hold-ul auto_send din mapare (has_no_auto_send->False, simbol pastrat; cod rezolvat->queued). US-002 scoate bifa auto_send din UI. US-003 preview pas 3 in format .tabel-trimiteri (STARI_PREVIEW + nota_umana_preview, fara repr Python; view-model prez). US-004 filtre layout/stil ca referinta + buton Custom. US-005 navigatie Trimiteri/Mapari sub contoare pe toate paginile. US-006 import <details> nativ colapsabil. US-007 post-commit reveal (OOB _coada/_status + HX-Trigger). US-008 auto-refresh dupa actiuni (nudge eliminat). VERIFY context curat PASS (8/8). /code-review high: 3 buguri reparate (tab nav la self-refresh, pill Custom valori stale, nota_umana_preview precedenta needs_mapping). 934 passed, 1 skipped. Backend trimitere + schema NEATINSE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2974 lines
120 KiB
Python
2974 lines
120 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 sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from .. import __version__
|
|
from .. import errors as _errors
|
|
from ..auth import rotate_api_key
|
|
from ..payload_view import prezentare_din_payload
|
|
from ..web.csrf import get_csrf_token, verify_csrf
|
|
from .labels import (
|
|
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
|
STARI_PREVIEW,
|
|
eticheta_rar,
|
|
eticheta_scurta,
|
|
eticheta_stare,
|
|
eticheta_worker,
|
|
format_data_rar,
|
|
motiv_uman,
|
|
nota_umana_preview,
|
|
parse_erori,
|
|
)
|
|
from ..web.session import require_login
|
|
from ..api.v1.import_router import (
|
|
_already_sent_lookup,
|
|
_build_idempotency_key,
|
|
_CANONICAL_SYNONYMS,
|
|
_fuzzy_suggest_column,
|
|
_resolve_row_for_preview,
|
|
_signature,
|
|
apply_row_override,
|
|
EDIT_FIELDS,
|
|
)
|
|
from ..config import get_settings
|
|
from ..crypto import decrypt_creds, encrypt_creds
|
|
from ..db import get_connection, read_app_events, read_heartbeat
|
|
from ..idempotency import build_key, canonicalize_row
|
|
from ..validation import validate_prezentare
|
|
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
|
|
from ..users import is_account_admin
|
|
from ..submissions_admin import (
|
|
SubmissionNotFound,
|
|
SubmissionStateConflict,
|
|
delete_submission,
|
|
requeue_submission,
|
|
)
|
|
from ..mapping import (
|
|
DEFAULT_ACCOUNT_ID,
|
|
_emite_text_rule_hits,
|
|
account_or_default,
|
|
account_scope_clause,
|
|
delete_text_rule,
|
|
has_no_auto_send,
|
|
load_mapping_meta,
|
|
load_nomenclator,
|
|
load_nomenclator_codes,
|
|
load_text_rules,
|
|
normalize_for_match,
|
|
pending_unmapped,
|
|
reresolve_account,
|
|
resolve_prestatii,
|
|
save_mapping,
|
|
save_text_rule,
|
|
suggest_codes,
|
|
text_rules_overlap,
|
|
)
|
|
|
|
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
|
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
|
|
|
router = APIRouter(tags=["web"])
|
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
|
# Expune parse_erori in toate template-urile
|
|
templates.env.globals["parse_erori"] = parse_erori
|
|
|
|
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _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()
|
|
return {r["status"]: int(r["n"]) for r in rows}
|
|
|
|
|
|
def _trimiteri_versiune(conn, account_id: int) -> str:
|
|
"""Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent
|
|
updated_at. Se schimba la orice insert/update/delete -> nudge-ul "Date noi" o compara
|
|
fara a re-randa tabelul."""
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), '') AS m FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL))",
|
|
(account_id, account_id),
|
|
).fetchone()
|
|
return f"{row['n']}:{row['m']}"
|
|
|
|
|
|
def _account_active(conn, account_id: int) -> bool:
|
|
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
|
|
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
return bool(row["active"]) if row else True
|
|
|
|
|
|
def _worker_alive(hb) -> bool:
|
|
if hb is None or not hb["last_beat"]:
|
|
return False
|
|
try:
|
|
last = datetime.fromisoformat(hb["last_beat"])
|
|
except ValueError:
|
|
return False
|
|
age = (datetime.now(timezone.utc) - last).total_seconds()
|
|
return age <= get_settings().worker_heartbeat_stale_s
|
|
|
|
|
|
def _rar_state(hb, worker_alive: bool) -> str:
|
|
"""Eticheta de disponibilitate RAR, derivata din ultimul login reusit.
|
|
|
|
Nu interogam RAR live aici (dashboard-ul degradeaza la ultima stare cunoscuta
|
|
a cozii). JWT TTL = 30h: un login mai vechi de atat inseamna ca nu mai stim
|
|
sigur ca RAR raspunde -> "indisponibil?". Fara niciun login -> necunoscut.
|
|
"""
|
|
if not worker_alive:
|
|
return "necunoscut (worker oprit)"
|
|
last = hb["last_rar_login_ok"] if hb else None
|
|
if not last:
|
|
return "fara login reusit inca"
|
|
try:
|
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
|
except (ValueError, TypeError):
|
|
return "necunoscut"
|
|
return "indisponibil?" if age > 108000 else "ok"
|
|
|
|
|
|
# "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
|
|
acct = account_or_default(account_id)
|
|
|
|
# Pas 1: are credentiale RAR configurate?
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
|
|
# Pas 3: are cel putin un submission (trimis sau in coada)?
|
|
row_sub = conn.execute(
|
|
"SELECT 1 FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1",
|
|
(acct, acct),
|
|
).fetchone()
|
|
are_trimiteri = row_sub is not None
|
|
|
|
# Pas 2 (optional): are cheie API activa?
|
|
row_key = conn.execute(
|
|
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1",
|
|
(acct,),
|
|
).fetchone()
|
|
are_cheie_folosita = row_key is not None
|
|
|
|
# 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),
|
|
}
|
|
|
|
|
|
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
|
|
"""Randeaza panoul Acasa ca string HTML.
|
|
|
|
`status`: deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in
|
|
sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
|
|
"""
|
|
if conn is None:
|
|
return templates.get_template("_acasa.html").render(
|
|
{"request": request, "csrf_token": get_csrf_token(request)}
|
|
)
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
# `status or ""`: campul hidden de filtru ar randa literal "None" cu un None Python
|
|
# (Jinja `default('')` inlocuieste doar undefined), trimitand status=None la poll.
|
|
ctx["status_filtru"] = status or ""
|
|
return templates.get_template("_acasa.html").render(ctx)
|
|
|
|
|
|
def _render_panel_import(request: Request) -> str:
|
|
"""Randeaza panoul Import ca string HTML (include _upload.html)."""
|
|
return templates.get_template("_upload.html").render({
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str:
|
|
""""coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt
|
|
sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
|
|
return _render_panel_acasa(request, conn, account_id)
|
|
|
|
|
|
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Mapari ca string HTML (3 sectiuni: de rezolvat / op salvate / formate)."""
|
|
return templates.get_template("_mapari.html").render({
|
|
"request": request,
|
|
"pending": pending_unmapped(conn, account_id),
|
|
"saved_mappings": _load_saved_op_mappings(conn, account_id),
|
|
"column_formats": _load_column_formats(conn, account_id),
|
|
"text_rules": load_text_rules(conn, account_id),
|
|
"nomenclator": load_nomenclator(conn),
|
|
"message": None,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Cont ca string HTML."""
|
|
from ..mapping import account_or_default
|
|
acct = account_or_default(account_id)
|
|
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
return templates.get_template("_cont.html").render({
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"api_key": None,
|
|
"are_creds": are_creds,
|
|
"creds_mesaj": None,
|
|
"creds_eroare": None,
|
|
"rot_eroare": None,
|
|
})
|
|
|
|
|
|
def _render_panel_nomenclator(request: Request, conn) -> str:
|
|
"""Randeaza panoul Nomenclator ca string HTML."""
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return templates.get_template("_nomenclator.html").render({
|
|
"request": request,
|
|
"rows": rows,
|
|
})
|
|
|
|
|
|
def _render_integrare(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Integrare ca string HTML (hub documentatie + exemple cod).
|
|
|
|
Calculeaza are_cheie (chei API active pe cont) si are_creds (credentiale RAR
|
|
configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj.
|
|
"""
|
|
from ..mapping import account_or_default
|
|
from .integrare_examples import exemple as _exemple
|
|
|
|
acct = account_or_default(account_id)
|
|
row_creds = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row_creds and row_creds["rar_creds_enc"])
|
|
|
|
row_key = conn.execute(
|
|
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,)
|
|
).fetchone()
|
|
are_cheie = row_key is not None
|
|
|
|
base_url = str(request.base_url).rstrip("/")
|
|
ex = _exemple(base_url, acct)
|
|
csrf_token = get_csrf_token(request)
|
|
|
|
return templates.get_template("_integrare.html").render({
|
|
"request": request,
|
|
"account_id": acct,
|
|
"base_url": base_url,
|
|
"exemple": ex,
|
|
"are_cheie": are_cheie,
|
|
"are_creds": are_creds,
|
|
"csrf_token": csrf_token,
|
|
})
|
|
|
|
|
|
_JURNAL_PAGE_SIZE = 50
|
|
|
|
|
|
def _jurnal_context(
|
|
request: Request, conn, account_id: int, *,
|
|
tip: str | None = None, nivel: str | None = None,
|
|
data_de: str | None = None, data_pana: str | None = None,
|
|
cont: str | None = None, page: int = 0,
|
|
) -> dict:
|
|
"""Context pentru tab-ul Jurnal: evenimente paginate + filtre + scope.
|
|
|
|
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
|
|
contului sau (regula NULL->cont 1, ca restul UI-ului).
|
|
"""
|
|
admin = is_account_admin(conn, account_id)
|
|
tip = (tip or "").strip() or None
|
|
nivel = (nivel or "").strip() or None
|
|
data_de = (data_de or "").strip() or None
|
|
data_pana = (data_pana or "").strip() or None
|
|
page = max(0, page)
|
|
|
|
if admin:
|
|
cont_filtru = None
|
|
if cont and str(cont).strip():
|
|
try:
|
|
cont_filtru = int(cont)
|
|
except (ValueError, TypeError):
|
|
cont_filtru = None
|
|
scope_account = cont_filtru # None = toate conturile
|
|
else:
|
|
scope_account = account_or_default(account_id)
|
|
|
|
offset = page * _JURNAL_PAGE_SIZE
|
|
rows = read_app_events(
|
|
conn, account_id=scope_account, tip=tip, nivel=nivel,
|
|
date_from=data_de, date_to=data_pana,
|
|
limit=_JURNAL_PAGE_SIZE + 1, offset=offset,
|
|
)
|
|
has_more = len(rows) > _JURNAL_PAGE_SIZE
|
|
rows = rows[:_JURNAL_PAGE_SIZE]
|
|
|
|
evenimente = []
|
|
for r in rows:
|
|
evenimente.append({
|
|
"ts": format_data_rar(r["ts"]),
|
|
"sursa": r["sursa"],
|
|
"tip": r["tip"],
|
|
"nivel": r["nivel"],
|
|
"account_id": r["account_id"],
|
|
"cod": r["cod"],
|
|
"mesaj": r["mesaj"],
|
|
})
|
|
tipuri = [r["tip"] for r in conn.execute("SELECT DISTINCT tip FROM app_events ORDER BY tip").fetchall()]
|
|
|
|
return {
|
|
"request": request,
|
|
"evenimente": evenimente,
|
|
"tipuri": tipuri,
|
|
"is_admin": admin,
|
|
"f_tip": tip or "",
|
|
"f_nivel": nivel or "",
|
|
"f_data_de": data_de or "",
|
|
"f_data_pana": data_pana or "",
|
|
"f_cont": (cont or "") if admin else "",
|
|
"page": page,
|
|
"has_more": has_more,
|
|
"prev_page": page - 1 if page > 0 else None,
|
|
"next_page": page + 1 if has_more else None,
|
|
}
|
|
|
|
|
|
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Jurnal ca string HTML."""
|
|
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
|
|
|
|
|
|
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, status: str | None = None) -> str:
|
|
"""Randeaza panoul corespunzator unui tab ca string HTML."""
|
|
if tab == "acasa":
|
|
return _render_panel_acasa(request, conn, account_id, status=status)
|
|
if tab == "jurnal":
|
|
return _render_panel_jurnal(request, conn, account_id)
|
|
if tab == "import":
|
|
return _render_panel_import(request)
|
|
if tab == "coada":
|
|
return _render_panel_coada(request, conn, account_id)
|
|
if tab == "mapari":
|
|
return _render_panel_mapari(request, conn, account_id)
|
|
if tab == "cont":
|
|
return _render_panel_cont(request, conn, account_id)
|
|
if tab == "nomenclator":
|
|
return _render_panel_nomenclator(request, conn)
|
|
if tab == "integrare":
|
|
return _render_integrare(request, conn, account_id)
|
|
return _render_panel_acasa(request)
|
|
|
|
|
|
@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").
|
|
"""
|
|
account_id = require_login(request)
|
|
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
|
conn = get_connection()
|
|
try:
|
|
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
|
|
# Badge contoare pe tab-uri: 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),
|
|
}
|
|
return templates.TemplateResponse("dashboard.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
|
def fragment_acasa(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Acasa."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.TemplateResponse("_acasa.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/import", response_class=HTMLResponse)
|
|
def fragment_import(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Import — include zona de upload."""
|
|
require_login(request)
|
|
return templates.TemplateResponse("_upload.html", _ctx(request))
|
|
|
|
|
|
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
|
def fragment_coada(request: Request) -> HTMLResponse:
|
|
""""coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt
|
|
sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din
|
|
bookmark-uri/HTMX vechi. Nu da 404."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.TemplateResponse("_acasa.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/nomenclator", response_class=HTMLResponse)
|
|
def fragment_nomenclator(request: Request) -> HTMLResponse:
|
|
"""Browser nomenclator RAR (cache local upsert-at de worker la fiecare login)."""
|
|
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)
|
|
return {
|
|
"request": request,
|
|
"worker_lbl": worker_lbl,
|
|
"rar_lbl": rar_lbl,
|
|
"worker_ok": worker_alive,
|
|
"rar_ok": rar_ok,
|
|
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
|
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
|
|
"counts_queued": counts.get("queued", 0),
|
|
"counts_sent": counts.get("sent", 0),
|
|
"blocate_total": blocate_total,
|
|
"blocate_defalcat": _blocate_defalcat(counts),
|
|
"pills_categorii": _pills_categorii(counts),
|
|
"account_active": _account_active(conn, account_id),
|
|
"tab_activ": tab_activ,
|
|
"mapari_badge": counts.get("needs_mapping", 0),
|
|
"oob": oob,
|
|
}
|
|
|
|
|
|
@router.get("/_fragments/status", response_class=HTMLResponse)
|
|
def fragment_status(request: Request) -> HTMLResponse:
|
|
"""Bara de status persistenta cu etichete umane.
|
|
|
|
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
|
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
|
Logica in routes.py (nu in template) pentru testabilitate.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
tab_activ = request.query_params.get("tab", "acasa")
|
|
ctx = _build_status_ctx(request, conn, account_id, tab_activ=tab_activ)
|
|
return templates.TemplateResponse("_status.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/trimiteri-versiune", response_class=JSONResponse)
|
|
def fragment_trimiteri_versiune(request: Request) -> JSONResponse:
|
|
"""Semnatura curenta a trimiterilor contului (JSON usor). Pollerul "Date noi" o
|
|
compara cu versiunea cu care s-a randat tabelul; daca difera, arata nudge-ul de
|
|
reincarcare — tabelul nu se mai schimba singur."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
return JSONResponse({"v": _trimiteri_versiune(conn, account_id)})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _iso_date_prefix(value: object) -> str | None:
|
|
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
|
|
|
|
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
|
|
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
|
|
fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
|
|
(ex. '05.12.2024') intorc None si sunt excluse din filtru.
|
|
"""
|
|
s = str(value or "").strip()
|
|
if len(s) < 10:
|
|
return None
|
|
prefix = s[:10]
|
|
try:
|
|
datetime.strptime(prefix, "%Y-%m-%d")
|
|
return prefix
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
|
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
|
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _eticheta_problema(status: str, motiv: str) -> str:
|
|
"""Eticheta umana scurta a problemei pentru randul de tabel.
|
|
|
|
Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe
|
|
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (DRY, fara al
|
|
3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand.
|
|
|
|
Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*.
|
|
Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta
|
|
scurta -> fallback-ul garanteaza un text ne-gol chiar la `rar_error` lipsa/corupt.
|
|
"""
|
|
if status not in _STARI_CU_PROBLEMA:
|
|
return ""
|
|
return motiv or eticheta_scurta(status)
|
|
|
|
|
|
def _submission_row_view(r) -> dict:
|
|
"""Imbogateste un rand de submission cu campuri afisabile umane."""
|
|
eticheta = eticheta_stare(r["status"])
|
|
motiv = motiv_uman(r["status"], r["rar_error"])
|
|
return {
|
|
"id": r["id"],
|
|
"status": r["status"],
|
|
# pill = eticheta scurta; textul lung ramane ca tooltip (title=).
|
|
"stare_scurt": eticheta_scurta(r["status"]),
|
|
"stare_text": eticheta[0],
|
|
"stare_css": eticheta[2],
|
|
"prez": prezentare_din_payload(r["payload_json"]),
|
|
"id_prezentare": r["id_prezentare"],
|
|
"updated_at": format_data_rar(r["updated_at"]),
|
|
"motiv": motiv,
|
|
# eticheta umana scurta a problemei sub pill (text, nu cod brut).
|
|
"eticheta_problema": _eticheta_problema(r["status"], motiv),
|
|
# randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
|
|
# stergere bulk; sent/sending/queued raman read-only (fara checkbox).
|
|
"gestionabil": r["status"] in _GESTIONABILE_WEB,
|
|
}
|
|
|
|
|
|
_PAGE_SIZE = 25 # Marime pagina fixa
|
|
|
|
|
|
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
|
def fragment_submissions(
|
|
request: Request,
|
|
status: str | None = None,
|
|
vehicul: str | None = None,
|
|
data_de: str | None = None,
|
|
data_pana: str | None = None,
|
|
page: int = 1,
|
|
) -> HTMLResponse:
|
|
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare.
|
|
|
|
Totalul se calculeaza DIFERIT dupa tipul de filtru:
|
|
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
|
|
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
|
|
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
|
|
"""
|
|
account_id = require_login(request)
|
|
status = (status or "").strip() or None
|
|
vehicul_q = (vehicul or "").strip().upper() or None
|
|
data_de = (data_de or "").strip() or None
|
|
data_pana = (data_pana or "").strip() or None
|
|
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
|
|
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
|
|
|
|
page = max(1, page) # pre-clamp >= 1
|
|
|
|
conn = get_connection()
|
|
try:
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
where = [scope_sql]
|
|
params: list = list(scope_params)
|
|
if status:
|
|
where.append("status=?")
|
|
params.append(status)
|
|
where_sql = " AND ".join(where)
|
|
|
|
if filtru_python:
|
|
# Calea B: fetch-all, filtreaza in Python, slice.
|
|
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
|
|
rows_db = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
|
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
|
params,
|
|
).fetchall()
|
|
|
|
view_all: list[dict] = []
|
|
for r in rows_db:
|
|
v = _submission_row_view(r)
|
|
prez = v["prez"]
|
|
if vehicul_q:
|
|
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
|
|
if vehicul_q not in hay:
|
|
continue
|
|
if data_de or data_pana:
|
|
# Extragem portiunea YYYY-MM-DD.
|
|
d_prefix = _iso_date_prefix(prez["data_prestatie"])
|
|
if d_prefix is None:
|
|
continue
|
|
if data_de and d_prefix < data_de:
|
|
continue
|
|
if data_pana and d_prefix > data_pana:
|
|
continue
|
|
view_all.append(v)
|
|
|
|
total = len(view_all)
|
|
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
|
page = max(1, min(page, pages)) # clamp la nr. de pagini
|
|
offset = (page - 1) * _PAGE_SIZE
|
|
view = view_all[offset:offset + _PAGE_SIZE]
|
|
|
|
else:
|
|
# Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ)
|
|
total = conn.execute(
|
|
f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params
|
|
).fetchone()[0]
|
|
|
|
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
|
page = max(1, min(page, pages)) # clamp la nr. de pagini
|
|
offset = (page - 1) * _PAGE_SIZE
|
|
|
|
rows_db = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
|
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC "
|
|
"LIMIT ? OFFSET ?",
|
|
params + [_PAGE_SIZE, offset],
|
|
).fetchall()
|
|
view = [_submission_row_view(r) for r in rows_db]
|
|
|
|
page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0
|
|
page_end = min(page * _PAGE_SIZE, total)
|
|
|
|
return templates.TemplateResponse("_submissions.html", {
|
|
"request": request,
|
|
"rows": view,
|
|
"filtru_activ": filtru_activ,
|
|
"csrf_token": get_csrf_token(request),
|
|
# Paginare
|
|
"total": total,
|
|
"page": page,
|
|
"pages": pages,
|
|
"page_start": page_start,
|
|
"page_end": page_end,
|
|
# Filtre curente pentru linkurile de paginare (pastreaza filtrele)
|
|
"f_status": status or "",
|
|
"f_vehicul": vehicul_q or "",
|
|
"f_data_de": data_de or "",
|
|
"f_data_pana": data_pana or "",
|
|
# Pill-uri (OOB) + stare activa + versiune pentru nudge-ul "Date noi".
|
|
"pills_categorii": _pills_categorii(_status_counts(conn, account_id)),
|
|
"status_filtru": status or "",
|
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# Stari ne-trimise blocate pe care le putem corecta inline.
|
|
_CORECTABILE = ("needs_data", "needs_mapping")
|
|
# Stari cu select editabil cod_prestatie (superset al _CORECTABILE: error primeste
|
|
# select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
|
|
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
|
|
# Stari gestionabile prin lifecycle web: sterge / re-pune in coada.
|
|
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse:
|
|
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk."""
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
rows = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
|
f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200",
|
|
scope_params,
|
|
).fetchall()
|
|
view = [_submission_row_view(r) for r in rows]
|
|
return templates.TemplateResponse("_submissions.html", {
|
|
"request": request,
|
|
"rows": view,
|
|
"filtru_activ": False,
|
|
"csrf_token": get_csrf_token(request),
|
|
"pills_categorii": _pills_categorii(_status_counts(conn, account_id)),
|
|
"status_filtru": "",
|
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
|
})
|
|
|
|
|
|
def _payload_form_values(payload_json) -> dict:
|
|
"""Valori brute pentru prefill-ul formularului de corectie."""
|
|
try:
|
|
data = json.loads(payload_json) if payload_json else {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except (ValueError, TypeError):
|
|
data = {}
|
|
return {
|
|
"form_vin": data.get("vin") or "",
|
|
"form_nr": data.get("nr_inmatriculare") or "",
|
|
"form_data": data.get("data_prestatie") or "",
|
|
"form_odo_final": data.get("odometru_final") or "",
|
|
"form_odo_initial": data.get("odometru_initial") or "",
|
|
}
|
|
|
|
|
|
def _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]:
|
|
"""Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy.
|
|
|
|
Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json,
|
|
aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din
|
|
`nomenclator` (pasat de apelant — evita un SELECT redundant in _detaliu_ctx). Goala
|
|
daca randul nu e needs_mapping sau nu are operatii nemapate reale (ex. needs_mapping
|
|
din auto_send=0 — codul exista deja, doar trimiterea e oprita).
|
|
"""
|
|
if row["status"] != "needs_mapping":
|
|
return []
|
|
try:
|
|
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
if not isinstance(content, dict):
|
|
content = {}
|
|
except (ValueError, TypeError):
|
|
content = {}
|
|
seen: set[str] = set()
|
|
out: list[dict] = []
|
|
for item in content.get("prestatii") or []:
|
|
if not isinstance(item, dict) or (item.get("cod_prestatie") or ""):
|
|
continue
|
|
op = (item.get("cod_op_service") or "").strip()
|
|
if not op or op in seen:
|
|
continue
|
|
seen.add(op)
|
|
out.append({
|
|
"cod_op_service": op,
|
|
"denumire": item.get("denumire"),
|
|
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
|
|
})
|
|
return out
|
|
|
|
|
|
def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|
error: bool = False, corectie_errors: list | None = None,
|
|
conn=None, account_id: int | None = None) -> dict:
|
|
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission.
|
|
|
|
`conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune
|
|
`nemapate_inline` + `nomenclator` pentru maparea inline din panou.
|
|
"""
|
|
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)
|
|
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
|
|
nomenclator = _nomenclator_complet if nemapate_inline else []
|
|
|
|
# Nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in formularul
|
|
# /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet daca
|
|
# e deja incarcat (needs_mapping), altfel incarca fresh.
|
|
nomenclator_rar: list[dict] = []
|
|
if conn is not None and row["status"] in _EDITABILE_OP:
|
|
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
|
|
|
|
# cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
|
|
cod_prestatie_curent = ""
|
|
try:
|
|
_pd = json.loads(row["payload_json"] or "{}")
|
|
_prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else []
|
|
if _prestatii and isinstance(_prestatii[0], dict):
|
|
cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper()
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
ctx = {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"id": row["id"],
|
|
"status": row["status"],
|
|
"stare_text": eticheta[0],
|
|
"stare_css": eticheta[2],
|
|
"stare_subtext": eticheta[1],
|
|
"prez": prezentare_din_payload(row["payload_json"]),
|
|
"id_prezentare": row["id_prezentare"],
|
|
"rar_status_code": row["rar_status_code"],
|
|
"rar_error": row["rar_error"],
|
|
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
|
"erori_3n": parse_erori(row["rar_error"]),
|
|
"retry_count": row["retry_count"],
|
|
"created_at": format_data_rar(row["created_at"]),
|
|
"updated_at": format_data_rar(row["updated_at"]),
|
|
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
|
|
# randuri ne-trimise blocate sunt corectabile; sent/sending nu
|
|
"editabil": row["status"] in _CORECTABILE,
|
|
# error/needs_data/needs_mapping pot fi sterse / re-puse in coada
|
|
"gestionabil": row["status"] in _GESTIONABILE_WEB,
|
|
# mapare inline (operatii nemapate ale acestui rand + nomenclator)
|
|
"nemapate_inline": nemapate_inline,
|
|
"nomenclator": nomenclator,
|
|
# select cod_prestatie pentru stari editabile
|
|
"nomenclator_rar": nomenclator_rar,
|
|
"cod_prestatie_curent": cod_prestatie_curent,
|
|
"corectie_msg": message,
|
|
"corectie_error": error,
|
|
"corectie_errors": corectie_errors or [],
|
|
}
|
|
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)
|
|
# 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()
|
|
|
|
# Injectare cod_prestatie din form INAINTE de resolve_prestatii. Oglindeste
|
|
# validarea din post_mapeaza_inline (nomenclator check). Codul nou e injectat in
|
|
# prima prestatie (index 0); build_key il include in hash.
|
|
_cod_raw = form.get("cod_prestatie")
|
|
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
|
if cod_prestatie_form:
|
|
exists_nom = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
|
|
).fetchone()
|
|
if not exists_nom:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id, error=True,
|
|
message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie_form}. "
|
|
"Alege un cod valid din lista.",
|
|
),
|
|
)
|
|
prestatii_form = content.get("prestatii")
|
|
if isinstance(prestatii_form, list) and prestatii_form:
|
|
p0 = dict(prestatii_form[0])
|
|
p0["cod_prestatie"] = cod_prestatie_form
|
|
content["prestatii"] = [p0] + list(prestatii_form[1:])
|
|
|
|
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
|
|
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
|
|
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
|
|
mapping_meta = load_mapping_meta(conn, account_id)
|
|
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
|
valid_codes = load_nomenclator_codes(conn) or None
|
|
text_rules = load_text_rules(conn, account_id)
|
|
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
|
content["prestatii"] = resolved
|
|
|
|
# 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, message="Mai sunt campuri invalide — vezi mai jos.",
|
|
error=True, corectie_errors=errors),
|
|
)
|
|
|
|
# Valid: recalculeaza cheia. Coliziune cu alt rand -> opreste, fara 500/duplicat.
|
|
new_key = build_key(account_id, canon)
|
|
if new_key != row["idempotency_key"]:
|
|
dup = conn.execute(
|
|
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
|
(new_key, row["id"]),
|
|
).fetchone()
|
|
if dup:
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row2,
|
|
message=f"Exista deja o trimitere identica (rand #{dup['id']}). Corectia a fost oprita.",
|
|
error=True,
|
|
),
|
|
)
|
|
|
|
try:
|
|
conn.execute(
|
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
|
"updated_at=datetime('now') WHERE id=?",
|
|
(new_key, payload_json, row["id"]),
|
|
)
|
|
except sqlite3.IntegrityError:
|
|
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
|
|
# pre-check-ul a trecut dar alt rand a primit cheia intre timp. Fara 500.
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, error=True,
|
|
message="Exista deja o trimitere identica. Corectia a fost oprita."),
|
|
)
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
|
|
)
|
|
# 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 cod_prestatie pentru starea error (inaintea requeue_submission
|
|
# standard, care nu actualizeaza cheia de idempotency).
|
|
_cod_raw = form.get("cod_prestatie")
|
|
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
|
|
|
if cod_prestatie_form:
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
if row["status"] != "error":
|
|
# cod_prestatie acceptat DOAR pentru starea error prin /repune
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="modificarea cod_prestatie prin repune e valida doar pentru starea error",
|
|
)
|
|
# Valideaza cod-ul fata de nomenclator
|
|
exists_nom = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
|
|
).fetchone()
|
|
if not exists_nom:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id,
|
|
error=True,
|
|
message=f"Cod RAR necunoscut: {cod_prestatie_form}. Alege un cod valid.",
|
|
),
|
|
)
|
|
# Parseaza payload si injecteaza cod_prestatie
|
|
try:
|
|
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
if not isinstance(content, dict):
|
|
content = {}
|
|
except (ValueError, TypeError):
|
|
content = {}
|
|
prestatii = content.get("prestatii") or []
|
|
if isinstance(prestatii, list) and prestatii:
|
|
p0 = dict(prestatii[0])
|
|
p0["cod_prestatie"] = cod_prestatie_form
|
|
# sterge cod_op_service/denumire daca exista (codul direct preia prioritate)
|
|
p0.pop("cod_op_service", None)
|
|
content["prestatii"] = [p0] + list(prestatii[1:])
|
|
# Re-rezolva prestatii cu noul cod
|
|
mapping_meta = load_mapping_meta(conn, account_id)
|
|
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
|
valid_codes = load_nomenclator_codes(conn) or None
|
|
text_rules = load_text_rules(conn, account_id)
|
|
resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
|
content["prestatii"] = resolved
|
|
# Canonicalize + rebuild idempotency key
|
|
canon = canonicalize_row(content)
|
|
payload_json = json.dumps(content, ensure_ascii=False)
|
|
new_key = build_key(account_id, canon)
|
|
# Verifica coliziune (numai daca cheia s-a schimbat)
|
|
if new_key != row["idempotency_key"]:
|
|
dup = conn.execute(
|
|
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
|
(new_key, row["id"]),
|
|
).fetchone()
|
|
if dup:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id,
|
|
error=True,
|
|
message=f"Exista deja o trimitere identica (rand #{dup['id']}).",
|
|
),
|
|
)
|
|
try:
|
|
conn.execute(
|
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
|
"updated_at=datetime('now') WHERE id=? AND account_id=?",
|
|
(new_key, payload_json, row["id"], account_id),
|
|
)
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
return templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row, conn=conn, account_id=account_id,
|
|
error=True,
|
|
message="Exista deja o trimitere identica. Operatia a fost oprita.",
|
|
),
|
|
)
|
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(
|
|
request, row2, conn=conn, account_id=account_id,
|
|
message="Cod actualizat — randul a fost re-pus in coada.",
|
|
),
|
|
)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
|
|
# Cale normala: fara cod_prestatie → delega la requeue_submission
|
|
try:
|
|
requeue_submission(conn, account_id, submission_id)
|
|
except SubmissionNotFound:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
except SubmissionStateConflict:
|
|
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
|
|
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
|
resp = templates.TemplateResponse(
|
|
"_trimitere_detaliu.html",
|
|
_detaliu_ctx(request, row, conn=conn, account_id=account_id,
|
|
message="Re-pus in coada — pleaca la urmatoarea trimitere."),
|
|
)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/trimitere/{submission_id}/sterge", response_class=HTMLResponse)
|
|
async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
|
"""Sterge un rand blocat din dashboard. Scoped pe sesiune; sent/sending interzis (409)."""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
conn = get_connection()
|
|
try:
|
|
try:
|
|
delete_submission(conn, account_id, submission_id)
|
|
except SubmissionNotFound:
|
|
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
|
except SubmissionStateConflict:
|
|
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
|
|
resp = HTMLResponse(
|
|
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
|
|
)
|
|
# 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()
|
|
|
|
|
|
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),
|
|
) -> HTMLResponse:
|
|
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
conn = get_connection()
|
|
try:
|
|
cod = cod_prestatie.strip().upper()
|
|
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
|
|
if not exists:
|
|
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
|
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
|
|
stats = reresolve_account(conn, account_id)
|
|
msg = (
|
|
f"Mapat {cod_op_service.strip()} -> {cod}. "
|
|
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
|
|
f"{stats['still_blocked']} inca nemapate."
|
|
)
|
|
return _render_mapari(request, conn, account_id, message=msg)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Mapari operatii salvate: editare cod/auto-send + stergere. #
|
|
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
|
|
# =========================================================================== #
|
|
|
|
@router.post("/mapari/salvate", response_class=HTMLResponse)
|
|
def post_editeaza_mapare_salvata(
|
|
request: Request,
|
|
cod_op_service: str = Form(...),
|
|
cod_prestatie: str = Form(...),
|
|
csrf_token: str | None = Form(None),
|
|
auto_send: bool = Form(False),
|
|
) -> HTMLResponse:
|
|
"""Editeaza o mapare op->cod salvata (cod RAR / auto-send) + re-rezolva blocatele.
|
|
|
|
Scoped pe contul sesiunii (save_mapping foloseste account_or_default(sesiune) —
|
|
cross-account imposibil). Respinge cod inexistent in nomenclator.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
conn = get_connection()
|
|
try:
|
|
cod = cod_prestatie.strip().upper()
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
|
).fetchone()
|
|
if not exists:
|
|
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
|
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
|
|
stats = reresolve_account(conn, account_id)
|
|
msg = (
|
|
f"Mapare actualizata: {cod_op_service.strip()} -> {cod}. "
|
|
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
|
|
f"{stats['still_blocked']} inca nemapate."
|
|
)
|
|
return _render_mapari(request, conn, account_id, message=msg)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/mapari/salvate/sterge", response_class=HTMLResponse)
|
|
def post_sterge_mapare_salvata(
|
|
request: Request,
|
|
cod_op_service: str = Form(...),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Sterge o mapare op->cod salvata. Scoped pe contul sesiunii."""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"DELETE FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
|
|
(acct, cod_op_service.strip()),
|
|
)
|
|
return _render_mapari(
|
|
request, conn, account_id,
|
|
message=f"Mapare stearsa: {cod_op_service.strip()}.",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Reguli automate (text): substring -> cod RAR. #
|
|
# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.#
|
|
# =========================================================================== #
|
|
|
|
@router.post("/mapari/reguli-text", response_class=HTMLResponse)
|
|
def post_salveaza_regula_text(
|
|
request: Request,
|
|
pattern: str = Form(...),
|
|
cod_prestatie: str = Form(...),
|
|
csrf_token: str | None = Form(None),
|
|
auto_send: bool = Form(False),
|
|
) -> HTMLResponse:
|
|
"""Salveaza o regula text (substring -> cod RAR) + re-rezolva submission-urile blocate.
|
|
|
|
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
|
|
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
|
|
respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N"
|
|
+ trigger trimiteriChanged (refresh lista), ca maparea inline.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
conn = get_connection()
|
|
try:
|
|
pat = (pattern or "").strip()
|
|
cod = (cod_prestatie or "").strip().upper()
|
|
if not pat or not cod:
|
|
return _render_mapari(
|
|
request, conn, account_id,
|
|
message="Completeaza textul cautat si codul RAR.",
|
|
)
|
|
valid_codes = load_nomenclator_codes(conn)
|
|
if valid_codes and cod not in valid_codes:
|
|
return _render_mapari(
|
|
request, conn, account_id,
|
|
message=f"Cod RAR necunoscut in nomenclator: {cod}.",
|
|
)
|
|
# avertisment neblocant daca regula noua se suprapune (substring,
|
|
# oricare directie) cu una existenta. Calculam INAINTE de save, fata de
|
|
# regulile curente, ca pattern-ul nou sa nu se compare cu sine.
|
|
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
|
|
save_text_rule(conn, account_id, pat, cod, auto_send)
|
|
stats = reresolve_account(conn, account_id)
|
|
msg = f"Regula salvata. Deblocate: {stats['requeued']}."
|
|
if overlap:
|
|
parti = "; ".join(
|
|
f"«{r.get('pattern')}» -> {r.get('cod_prestatie')}" for r in overlap
|
|
)
|
|
msg += (
|
|
f" Se suprapune cu regula {parti}; "
|
|
"ordinea (priority, id) decide care se aplica prima."
|
|
)
|
|
resp = _render_mapari(request, conn, account_id, message=msg)
|
|
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
|
return resp
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/mapari/reguli-text/sterge", response_class=HTMLResponse)
|
|
def post_sterge_regula_text(
|
|
request: Request,
|
|
pattern: str = Form(...),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Sterge o regula text. Scoped pe contul sesiunii."""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
conn = get_connection()
|
|
try:
|
|
pat = (pattern or "").strip()
|
|
delete_text_rule(conn, account_id, pat)
|
|
return _render_mapari(
|
|
request, conn, account_id,
|
|
message=f"Regula stearsa: «{pat}».",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/mapari/reguli-text/preview", response_class=HTMLResponse)
|
|
def post_preview_regula_text(
|
|
request: Request,
|
|
pattern: str = Form(""),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Preview pre-salvare: cate operatii nemapate ar potrivi regula.
|
|
|
|
NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu
|
|
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
|
|
(needs_mapping, reuse pending_unmapped) al caror text (denumire sau
|
|
cod_op_service, normalizat) CONTINE pattern-ul + intoarce pana la 3 exemple.
|
|
Pattern gol -> fragment gol (nu numara „tot"). Scoped pe contul sesiunii.
|
|
"""
|
|
from markupsafe import escape
|
|
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
pat = normalize_for_match(pattern)
|
|
if not pat:
|
|
return HTMLResponse(content="")
|
|
conn = get_connection()
|
|
try:
|
|
pending = pending_unmapped(conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
|
|
matches = []
|
|
for entry in pending:
|
|
text = normalize_for_match(entry.get("denumire") or entry.get("cod_op_service"))
|
|
if pat in text:
|
|
matches.append(entry)
|
|
|
|
if not matches:
|
|
return HTMLResponse(
|
|
content='<span class="muted" style="font-size:12px;">Nicio potrivire acum.</span>'
|
|
)
|
|
|
|
exemple = ", ".join(
|
|
f"«{escape((e.get('denumire') or e.get('cod_op_service') or '').strip())}»"
|
|
for e in matches[:3]
|
|
)
|
|
n = len(matches)
|
|
plural = "operatie nemapata" if n == 1 else "operatii nemapate"
|
|
return HTMLResponse(
|
|
content=(
|
|
f'<span class="muted" style="font-size:12px;">'
|
|
f'Potriveste {n} {plural}: {exemple}.</span>'
|
|
)
|
|
)
|
|
|
|
|
|
# =========================================================================== #
|
|
# Formate de coloane salvate: editare format data + stergere. #
|
|
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
|
|
# =========================================================================== #
|
|
|
|
@router.post("/formate-coloane/editeaza", response_class=HTMLResponse)
|
|
async def post_editeaza_format_coloane(
|
|
request: Request,
|
|
format_id: int = Form(...),
|
|
format_data: str | None = Form(None),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Editeaza un format de coloane salvat (format data). Scoped pe cont prin id+account_id.
|
|
|
|
json_mapare optional (string JSON valid) — daca e dat, inlocuieste maparea coloanelor.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
form = await request.form()
|
|
json_mapare_raw = form.get("json_mapare")
|
|
conn = get_connection()
|
|
try:
|
|
owned = conn.execute(
|
|
"SELECT 1 FROM column_mappings WHERE id=? AND account_id=?",
|
|
(format_id, acct),
|
|
).fetchone()
|
|
if not owned:
|
|
return _render_mapari(
|
|
request, conn, account_id, message="Format inexistent sau inaccesibil."
|
|
)
|
|
fmt = (format_data or "").strip() or None
|
|
if isinstance(json_mapare_raw, str) and json_mapare_raw.strip():
|
|
try:
|
|
jm = json.loads(json_mapare_raw)
|
|
if not isinstance(jm, dict):
|
|
raise ValueError
|
|
except (ValueError, TypeError):
|
|
return _render_mapari(
|
|
request, conn, account_id, message="Mapare coloane invalida (JSON)."
|
|
)
|
|
conn.execute(
|
|
"UPDATE column_mappings SET json_mapare=?, format_data=? WHERE id=? AND account_id=?",
|
|
(json.dumps(jm, ensure_ascii=False), fmt, format_id, acct),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE column_mappings SET format_data=? WHERE id=? AND account_id=?",
|
|
(fmt, format_id, acct),
|
|
)
|
|
return _render_mapari(request, conn, account_id, message="Format de coloane actualizat.")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/formate-coloane/sterge", response_class=HTMLResponse)
|
|
def post_sterge_format_coloane(
|
|
request: Request,
|
|
format_id: int = Form(...),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Sterge un format de coloane salvat. Scoped pe cont prin id+account_id."""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"DELETE FROM column_mappings WHERE id=? AND account_id=?",
|
|
(format_id, acct),
|
|
)
|
|
return _render_mapari(request, conn, account_id, message="Format de coloane sters.")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Import UI (U5) — upload → mapare coloane → preview → confirmare #
|
|
# Consuma helper-e din import_router fara a edita fisierul backend. #
|
|
# Toate rutele /_import/* returneaza fragmente HTML (target #import-section). #
|
|
# =========================================================================== #
|
|
|
|
def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict]) -> list[dict]:
|
|
"""Operatii distincte nemapate dintr-un preview de import (staging), cu sugestii fuzzy.
|
|
|
|
Echivalentul lui pending_unmapped() dar pe randuri de PREVIEW (import in staging,
|
|
inca neexistente ca submissions). Aduna doar prestatiile fara cod_prestatie
|
|
(cele cu auto_send=0 au deja cod -> nu apar aici). Sortare: cele mai blocate intai.
|
|
"""
|
|
agg: dict[str, dict[str, Any]] = {}
|
|
for row in preview_rows:
|
|
if row.get("resolved_status") != "needs_mapping":
|
|
continue
|
|
for item in (row.get("resolved", {}).get("prestatii") or []):
|
|
if not isinstance(item, dict) or item.get("cod_prestatie"):
|
|
continue
|
|
op = (item.get("cod_op_service") or "").strip()
|
|
if not op:
|
|
continue
|
|
entry = agg.setdefault(op, {"cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0})
|
|
if not entry["denumire"] and item.get("denumire"):
|
|
entry["denumire"] = item.get("denumire")
|
|
entry["blocked"] += 1
|
|
out: list[dict] = []
|
|
for entry in agg.values():
|
|
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
|
|
out.append(entry)
|
|
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
|
return out
|
|
|
|
|
|
def _web_compute_preview(
|
|
conn,
|
|
import_id: int,
|
|
account_id: int,
|
|
) -> dict[str, Any] | str:
|
|
"""Calculeaza preview pentru un batch; intoarce date sau str cu mesaj de eroare.
|
|
|
|
Reutilizeaza _resolve_row_for_preview, _already_sent_lookup, _signature
|
|
din import_router. Nu repeta logica de rezolvare — only orchestrare.
|
|
"""
|
|
acct = account_or_default(account_id)
|
|
|
|
batch = conn.execute(
|
|
"SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
return "Batch de import inexistent sau inaccesibil."
|
|
|
|
raw_rows_db = conn.execute(
|
|
"SELECT row_index, raw_json, override_json 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]] = []
|
|
for r in raw_rows_db:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
except Exception:
|
|
row_data = {}
|
|
rows.append(row_data)
|
|
try:
|
|
ov = decrypt_creds(r["override_json"]) if r["override_json"] else None
|
|
except Exception:
|
|
ov = None
|
|
overrides.append(ov or {})
|
|
|
|
col_names = list(rows[0].keys()) if rows else []
|
|
sig = _signature(col_names)
|
|
|
|
mapping_row = conn.execute(
|
|
"SELECT json_mapare, format_data FROM column_mappings WHERE account_id=? AND signature_coloane=?",
|
|
(acct, sig),
|
|
).fetchone()
|
|
if not mapping_row:
|
|
return "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
|
|
|
|
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
|
format_data: str | None = mapping_row["format_data"]
|
|
|
|
# Mapare operatii (o singura incarcare)
|
|
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,
|
|
)
|
|
|
|
key: str | None = None
|
|
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
|
try:
|
|
key = _build_idempotency_key(account_id, info["resolved"])
|
|
keys_for_lookup.append(key)
|
|
key_to_indices.setdefault(key, []).append(i)
|
|
except Exception:
|
|
pass
|
|
|
|
preview_rows.append({
|
|
"row_index": i,
|
|
"resolved_status": info["resolved_status"],
|
|
"resolved": info["resolved"],
|
|
"errors": info["errors"],
|
|
"flags": info["flags"],
|
|
"idempotency_key": key,
|
|
})
|
|
|
|
# Already_sent: batch lookup (fara N+1)
|
|
unique_keys = list(set(keys_for_lookup))
|
|
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
|
|
|
# Aplica already_sent si duplicate_in_file
|
|
for row in preview_rows:
|
|
k = row.get("idempotency_key")
|
|
if not k:
|
|
continue
|
|
if k in already_sent_map and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
|
row["resolved_status"] = "already_sent"
|
|
row["already_sent_info"] = already_sent_map[k]
|
|
continue
|
|
indices_same_key = key_to_indices.get(k, [])
|
|
if len(indices_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
|
row["resolved_status"] = "duplicate_in_file"
|
|
row["duplicate_with"] = [idx for idx in indices_same_key if idx != row["row_index"]]
|
|
|
|
# Rezumat stari
|
|
summary: dict[str, int] = {}
|
|
for row in preview_rows:
|
|
s = row["resolved_status"]
|
|
summary[s] = summary.get(s, 0) + 1
|
|
|
|
# Actualizeaza contoare in import_batches
|
|
conn.execute(
|
|
"UPDATE import_batches SET ok=?, needs_mapping=?, needs_data=?, needs_review=?, "
|
|
"already_sent=?, duplicate_in_file=? WHERE id=?",
|
|
(
|
|
summary.get("ok", 0),
|
|
summary.get("needs_mapping", 0),
|
|
summary.get("needs_data", 0),
|
|
summary.get("needs_review", 0),
|
|
summary.get("already_sent", 0),
|
|
summary.get("duplicate_in_file", 0),
|
|
import_id,
|
|
),
|
|
)
|
|
|
|
# Actualizeaza resolved_status in import_rows
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
conn.executemany(
|
|
"UPDATE import_rows SET resolved_status=? WHERE batch_id=? AND row_index=?",
|
|
[(row["resolved_status"], import_id, row["row_index"]) for row in preview_rows],
|
|
)
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
|
|
# Enrichment UI: adauga campuri pre-computate necesare template-ului.
|
|
# Toate consumatorii (preview complet, rand single via _preview_one_row)
|
|
# obtin automat campurile adaugate aici.
|
|
for row in preview_rows:
|
|
# view-model prez (vehicul/operatie/cod RAR) — prezentare_din_payload
|
|
# accepta dict direct (nu e nevoie de serializare/deserializare JSON).
|
|
row["prez"] = prezentare_din_payload(row["resolved"])
|
|
# Eticheta umana + clasa CSS pentru pill — din STARI_PREVIEW, nu STARI_SUBMISSION
|
|
# (eticheta_stare ridica KeyError pe ok/already_sent/duplicate_in_file).
|
|
_etq, _css = STARI_PREVIEW.get(
|
|
row["resolved_status"],
|
|
(row["resolved_status"], f"s-{row['resolved_status']}"),
|
|
)
|
|
row["stare_eticheta"] = _etq
|
|
row["stare_css"] = _css
|
|
# Nota umana formatata — errors e lista Python, NU JSON string;
|
|
# nota_umana_preview o trateaza corect (fara repr Python brut in Note).
|
|
row["nota_umana"] = nota_umana_preview(
|
|
row["resolved_status"],
|
|
row.get("errors") or [],
|
|
row.get("flags") or [],
|
|
)
|
|
|
|
nomenclator = load_nomenclator(conn)
|
|
return {
|
|
"rows": preview_rows,
|
|
"summary": summary,
|
|
"total": len(preview_rows),
|
|
"filename": batch["filename"],
|
|
"unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator),
|
|
"nomenclator": nomenclator,
|
|
}
|
|
|
|
|
|
@router.post("/_import/upload", response_class=HTMLResponse)
|
|
async def web_upload_import(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
sheet_name: str | None = Form(None),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
|
|
|
Daca maparea de coloane exista deja (signature match): computa preview imediat.
|
|
Daca nu: intoarce formularul de mapare coloane.
|
|
Nu editeaza import_router.py — apeleaza parse_file si DB direct.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
|
|
data = await file.read()
|
|
filename = file.filename or "fisier"
|
|
|
|
# Parsare fisier
|
|
try:
|
|
parsed = parse_file(data, filename, sheet_name=sheet_name)
|
|
except MultipleSheets as ms:
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
|
except FileTooLarge as e:
|
|
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=str(e), eroare_upload=eroare_upload
|
|
))
|
|
except HeaderError as e:
|
|
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload
|
|
))
|
|
except UnicodeDecodeError as e:
|
|
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload
|
|
))
|
|
except Exception as e:
|
|
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload
|
|
))
|
|
|
|
conn = get_connection()
|
|
try:
|
|
sig = _signature(parsed.columns)
|
|
|
|
# Stagingul in DB (tranzactie explicita)
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
cur = conn.execute(
|
|
"INSERT INTO import_batches (account_id, filename, status, total, purge_after) "
|
|
"VALUES (?, ?, 'staging', ?, datetime('now', '+90 days'))",
|
|
(acct, filename, len(parsed.rows)),
|
|
)
|
|
batch_id = cur.lastrowid
|
|
conn.executemany(
|
|
"INSERT INTO import_rows (batch_id, row_index, raw_json, resolved_status, error) "
|
|
"VALUES (?, ?, ?, 'pending', NULL)",
|
|
[
|
|
(batch_id, i, encrypt_creds(row_dict))
|
|
for i, row_dict in enumerate(parsed.rows)
|
|
],
|
|
)
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
raise
|
|
|
|
# Verifica mapare existenta
|
|
existing = conn.execute(
|
|
"SELECT json_mapare, format_data FROM column_mappings "
|
|
"WHERE account_id=? AND signature_coloane=?",
|
|
(acct, sig),
|
|
).fetchone()
|
|
|
|
batch_id_int: int = cur.lastrowid or 0 # lastrowid este int dupa INSERT reusit
|
|
|
|
if existing:
|
|
# Mapare retinuta → computa preview imediat
|
|
result = _web_compute_preview(conn, batch_id_int, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"error": result,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
return templates.TemplateResponse("_preview_import.html", {
|
|
"request": request,
|
|
"import_id": batch_id_int,
|
|
"message": "Mapare retinuta aplicata automat.",
|
|
"csrf_token": get_csrf_token(request),
|
|
**result,
|
|
})
|
|
|
|
# Mapare noua — sugestii fuzzy si formular de mapare
|
|
fuzzy_suggestions: dict[str, list[dict]] = {}
|
|
for col in parsed.columns:
|
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
|
if sugg:
|
|
fuzzy_suggestions[col] = sugg
|
|
|
|
return templates.TemplateResponse("_mapcoloane.html", {
|
|
"request": request,
|
|
"import_id": batch_id_int,
|
|
"filename": filename,
|
|
"columns": parsed.columns,
|
|
"sample_rows": parsed.rows[:3],
|
|
"fuzzy_suggestions": fuzzy_suggestions,
|
|
"canonical_fields": _CANONICAL_FIELDS,
|
|
"format_data": None,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/mapare-coloane", response_class=HTMLResponse)
|
|
async def web_save_mapare_coloane(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
|
|
account_id = require_login(request)
|
|
acct = account_or_default(account_id)
|
|
|
|
# Detecta body JSON trimis eronat (Content-Type: application/json) → COLOANE_FORMAT_JSON
|
|
content_type = request.headers.get("content-type", "")
|
|
if "application/json" in content_type:
|
|
body = await request.body()
|
|
try:
|
|
json.loads(body)
|
|
# JSON valid dar trimis pe ruta de form — tot e format gresit pentru aceasta ruta
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
# Indiferent daca JSON-ul e valid sau nu, Content-Type application/json e gresit pentru ruta form
|
|
eroare_mapare = _errors.eroare(
|
|
"COLOANE_FORMAT_JSON",
|
|
cauza="Cererea a fost trimisa ca JSON (application/json) in loc de form data.",
|
|
)
|
|
conn = get_connection()
|
|
try:
|
|
first_row = conn.execute(
|
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
|
(import_id,),
|
|
).fetchone()
|
|
columns: list[str] = []
|
|
if first_row:
|
|
try:
|
|
rd = decrypt_creds(first_row["raw_json"]) or {}
|
|
columns = list(rd.keys())
|
|
except Exception:
|
|
pass
|
|
fuzzy: dict[str, list[dict]] = {}
|
|
for col in columns:
|
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
|
if sugg:
|
|
fuzzy[col] = sugg
|
|
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
columns=columns,
|
|
sample_rows=[],
|
|
fuzzy_suggestions=fuzzy,
|
|
canonical_fields=_CANONICAL_FIELDS,
|
|
format_data=None,
|
|
eroare_mapare=eroare_mapare,
|
|
error=True,
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
form = await request.form()
|
|
|
|
# Colectare perechi coloana fisier → camp canonic din form
|
|
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
|
|
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
|
|
format_data_val = str(form.get("format_data") or "").strip() or None
|
|
|
|
# Construieste json_mapare (ignora campurile marcate ca "ignorate")
|
|
json_mapare: dict[str, str] = {}
|
|
for colname, canon in zip(colnames, canons):
|
|
if canon:
|
|
json_mapare[colname] = canon
|
|
|
|
if not json_mapare:
|
|
# Nici un camp mapat → re-arata formularul cu eroare
|
|
conn = get_connection()
|
|
try:
|
|
first_row = conn.execute(
|
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
|
(import_id,),
|
|
).fetchone()
|
|
columns = []
|
|
if first_row:
|
|
try:
|
|
rd = decrypt_creds(first_row["raw_json"]) or {}
|
|
columns = list(rd.keys())
|
|
except Exception:
|
|
pass
|
|
fuzzy: dict[str, list[dict]] = {}
|
|
for col in columns:
|
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
|
if sugg:
|
|
fuzzy[col] = sugg
|
|
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
columns=columns,
|
|
sample_rows=[],
|
|
fuzzy_suggestions=fuzzy,
|
|
canonical_fields=_CANONICAL_FIELDS,
|
|
format_data=format_data_val,
|
|
message="Mapeaza cel putin un camp canonic inainte de a continua.",
|
|
error=True,
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
conn = get_connection()
|
|
try:
|
|
# Verifica ca batch-ul apartine contului
|
|
batch = conn.execute(
|
|
"SELECT id FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error="Batch de import inexistent sau expirat."
|
|
))
|
|
|
|
# Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele
|
|
# ignorate), nu doar campurile mapate. Altfel ignorarea unei coloane schimba
|
|
# semnatura si maparea nu mai e gasita la preview/re-upload (col_names = antet
|
|
# complet peste tot). `colnames` vine din form = toate coloanele randate.
|
|
sig = _signature(colnames or list(json_mapare.keys()))
|
|
|
|
# Salveaza maparea (upsert)
|
|
conn.execute(
|
|
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) "
|
|
"VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(account_id, signature_coloane) DO UPDATE SET "
|
|
"json_mapare=excluded.json_mapare, format_data=excluded.format_data",
|
|
(acct, sig, json.dumps(json_mapare, ensure_ascii=False), format_data_val),
|
|
)
|
|
|
|
# Computa preview
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, **result
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/{import_id}/preview", response_class=HTMLResponse)
|
|
def web_preview_import(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"error": result,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
return templates.TemplateResponse("_preview_import.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"csrf_token": get_csrf_token(request),
|
|
**result,
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# 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 _render_preview_rand(
|
|
request: Request, *, import_id: int, row: dict, editing: bool,
|
|
include_oob: bool, summary: dict, message: str | None = None,
|
|
) -> HTMLResponse:
|
|
return templates.TemplateResponse("_preview_rand.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"row": row,
|
|
"editing": editing,
|
|
"include_oob": include_oob,
|
|
"summary": summary,
|
|
"message": message,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
|
|
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu)."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
|
if row is None or isinstance(result, str):
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=True,
|
|
include_oob=False, summary=result["summary"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse)
|
|
def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
|
if row is None or isinstance(result, str):
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=False,
|
|
include_oob=True, summary=result["summary"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
|
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Persista override (mutatie pura) + re-randeaza DOAR randul.
|
|
|
|
Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare.
|
|
Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
|
|
si mesajul pe campul vinovat."""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
fields: dict[str, str | None] = {
|
|
camp: (str(form.get(camp)) if form.get(camp) is not None else None)
|
|
for camp in EDIT_FIELDS
|
|
}
|
|
conn = get_connection()
|
|
try:
|
|
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
|
|
# pastreaza randul + valorile la 4xx/5xx).
|
|
apply_row_override(
|
|
conn, import_id=import_id, account_id=account_id,
|
|
row_index=row_index, fields=fields,
|
|
)
|
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
|
if row is None or isinstance(result, str):
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
field_errors = [
|
|
e for e in (row.get("errors") or [])
|
|
if isinstance(e, dict) and e.get("field")
|
|
]
|
|
if field_errors:
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=True,
|
|
include_oob=True, summary=result["summary"],
|
|
message="Mai sunt valori invalide — corecteaza campurile marcate.",
|
|
)
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=False,
|
|
include_oob=True, summary=result["summary"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse)
|
|
async def web_mapare_operatie(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Mapeaza o operatie nemapata din preview-ul de import la un cod RAR, in flux.
|
|
|
|
Salveaza maparea (persistenta, operations_mapping) si re-randeaza preview-ul:
|
|
_web_compute_preview recalculeaza cu noua mapare si re-scrie resolved_status in
|
|
import_rows, deci randurile afectate trec din needs_mapping in ok fara re-upload.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
cod_op_service = str(form.get("cod_op_service") or "").strip()
|
|
cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper()
|
|
auto_send = bool(form.get("auto_send"))
|
|
|
|
def _render(message: str | None = None, error: bool = False) -> HTMLResponse:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, message=message, error=error, **result
|
|
))
|
|
|
|
if not cod_op_service or not cod_prestatie:
|
|
return _render("Alege un cod RAR pentru operatie.", error=True)
|
|
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
|
|
).fetchone()
|
|
if not exists:
|
|
return _render(f"Cod RAR necunoscut: {cod_prestatie}", error=True)
|
|
|
|
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send)
|
|
return _render(f"Mapat {cod_op_service} -> {cod_prestatie}.")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/reset", response_class=HTMLResponse)
|
|
def web_import_reset(request: Request) -> HTMLResponse:
|
|
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
|
|
async def web_confirma_import(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Gate HARD confirmare + enqueue randuri ok + log atestare. Intoarce fragment HTML.
|
|
|
|
Replica logica din import_router.commit_import dar cu input din form HTML
|
|
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
|
|
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
|
|
|
|
# Randuri needs_review bifate explicit
|
|
reviewed_rows: set[int] = set()
|
|
for v in form.getlist("reviewed_rows"):
|
|
if isinstance(v, str):
|
|
try:
|
|
reviewed_rows.add(int(v))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
confirmed_by = str(form.get("confirmed_by") or "").strip() or None
|
|
|
|
conn = get_connection()
|
|
try:
|
|
batch = conn.execute(
|
|
"SELECT id, filename, status FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error="Batch de import inexistent sau expirat."
|
|
))
|
|
|
|
if batch["status"] == "committed":
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, message="Acest batch a fost deja comis."
|
|
))
|
|
|
|
# Incarca randurile cu stare ok si needs_review
|
|
ok_rows_db = conn.execute(
|
|
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
|
|
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
|
|
(import_id,),
|
|
).fetchall()
|
|
|
|
def _override_of(r) -> dict:
|
|
return (decrypt_creds(r["override_json"]) if r["override_json"] else None) or {}
|
|
|
|
if not ok_rows_db:
|
|
# Re-arata preview cu eroare
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
message="Niciun rand ok de confirmat in acest batch.",
|
|
error=True,
|
|
**result,
|
|
))
|
|
|
|
# Decripteaza si construieste lista de randuri de trimis
|
|
to_enqueue: list[dict[str, Any]] = []
|
|
review_indices: set[int] = set()
|
|
|
|
for r in ok_rows_db:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
except Exception:
|
|
continue
|
|
if r["resolved_status"] == "ok":
|
|
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
|
|
"override": _override_of(r), "status": "ok"})
|
|
elif r["resolved_status"] == "needs_review":
|
|
review_indices.add(r["row_index"])
|
|
|
|
# Adauga randurile needs_review bifate explicit
|
|
for r in ok_rows_db:
|
|
if r["resolved_status"] == "needs_review" and r["row_index"] in reviewed_rows:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
|
|
"override": _override_of(r), "status": "needs_review"})
|
|
except Exception:
|
|
pass
|
|
|
|
n_total_ok = len(to_enqueue)
|
|
|
|
# Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis
|
|
if n_confirmat != n_total_ok:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
msg = (
|
|
f"Numarul confirmat ({n_confirmat}) difera de randurile gata de trimis ({n_total_ok}). "
|
|
f"Verifica preview-ul si retasteaza numarul corect."
|
|
)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, message=msg, error=True, **result
|
|
))
|
|
|
|
if n_total_ok == 0:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
message="Niciun rand ok de confirmat.",
|
|
error=True,
|
|
**result,
|
|
))
|
|
|
|
# Incarca maparea de coloane pentru payload
|
|
first_row_db = conn.execute(
|
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
|
(import_id,),
|
|
).fetchone()
|
|
col_names: list[str] = []
|
|
if first_row_db:
|
|
try:
|
|
fd = decrypt_creds(first_row_db["raw_json"]) or {}
|
|
col_names = list(fd.keys())
|
|
except Exception:
|
|
pass
|
|
|
|
sig = _signature(col_names) if col_names else ""
|
|
mapping_row = conn.execute(
|
|
"SELECT json_mapare, format_data FROM column_mappings WHERE account_id=? AND signature_coloane=?",
|
|
(acct, sig),
|
|
).fetchone()
|
|
|
|
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) if mapping_row else {}
|
|
fmt = mapping_row["format_data"] if mapping_row else None
|
|
|
|
# Mapare operatii
|
|
mapping_meta = load_mapping_meta(conn, acct)
|
|
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
|
# validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
|
valid_codes = load_nomenclator_codes(conn) or None
|
|
text_rules = load_text_rules(conn, acct)
|
|
|
|
# Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
|
enqueued: list[dict] = []
|
|
toctou: list[int] = []
|
|
rows_for_hash: list[str] = []
|
|
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
for item in to_enqueue:
|
|
row_dict = item["data"]
|
|
row_index = item["row_index"]
|
|
|
|
# Aplica maparea de coloane
|
|
mapped: dict[str, Any] = {}
|
|
for col_f, camp_c in json_mapare.items():
|
|
if col_f in row_dict and camp_c:
|
|
mapped[camp_c] = row_dict[col_f]
|
|
|
|
# Rezolva data
|
|
for col_f, camp_c in json_mapare.items():
|
|
if camp_c == "data_prestatie":
|
|
col_fmt = fmt or "ambiguous"
|
|
raw_date = mapped.get("data_prestatie")
|
|
if raw_date is not None:
|
|
iso_date, _ = parse_date_value(raw_date, col_fmt)
|
|
if iso_date:
|
|
mapped["data_prestatie"] = iso_date
|
|
break
|
|
|
|
# Operatia → prestatii (denumire_op alimenteaza denumirea reala)
|
|
operatie_val = mapped.pop("operatie", None)
|
|
denumire_val = mapped.pop("denumire_op", None)
|
|
if operatie_val and "prestatii" not in mapped:
|
|
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
|
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
|
|
|
# Rezolva prestatii
|
|
prestatii = mapped.get("prestatii") or []
|
|
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules)
|
|
mapped["prestatii"] = resolved_p
|
|
|
|
# Canonicalizare
|
|
canon = canonicalize_row(mapped)
|
|
mapped.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
|
|
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
|
override = item.get("override") or {}
|
|
if override:
|
|
mapped.update(override)
|
|
canon = canonicalize_row(mapped)
|
|
mapped.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
|
|
key = build_key(account_id, canon)
|
|
|
|
rows_for_hash.append(json.dumps({
|
|
"row_index": row_index,
|
|
"vin": mapped.get("vin"),
|
|
"data_prestatie": mapped.get("data_prestatie"),
|
|
"odometru_final": mapped.get("odometru_final"),
|
|
"prestatii": [
|
|
str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
|
|
for p in resolved_p
|
|
],
|
|
}, sort_keys=True, ensure_ascii=False))
|
|
|
|
cur = conn.execute(
|
|
"INSERT OR IGNORE INTO submissions "
|
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
|
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'))",
|
|
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index),
|
|
)
|
|
if cur.rowcount == 0:
|
|
toctou.append(row_index)
|
|
else:
|
|
# telemetrie pentru itemii rezolvati prin regula text.
|
|
_emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p)
|
|
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
|
|
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
raise
|
|
|
|
n_enqueued = len(enqueued)
|
|
|
|
# Log atestare
|
|
rows_hash = hashlib.sha256(
|
|
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
|
).hexdigest() if rows_for_hash else ""
|
|
|
|
conn.execute(
|
|
"INSERT INTO import_attestations (batch_id, account_id, confirmed_by, rows_hash, n_confirmed) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(import_id, acct, confirmed_by, rows_hash, n_enqueued),
|
|
)
|
|
conn.execute(
|
|
"UPDATE import_batches SET status='committed', ok=? WHERE id=?",
|
|
(n_enqueued, import_id),
|
|
)
|
|
|
|
# Succes → bara de upload slim cu mesaj de confirmare + OOB swap al
|
|
# #trimiteri-section (injecteaza _coada.html cu lista proaspata) +
|
|
# header HX-Trigger: trimiteriChanged (declanseza reincarcarea automata).
|
|
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
|
succes_msg = (
|
|
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
|
|
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
|
|
)
|
|
|
|
# Calculeaza contextele (necesita conn deschis) inainte de finally.
|
|
acasa_ctx = _get_acasa_context(request, conn, account_id)
|
|
acasa_ctx["status_filtru"] = ""
|
|
acasa_ctx["oob"] = True # adauga hx-swap-oob="outerHTML" la <section>
|
|
|
|
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
|
|
|
|
# Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus).
|
|
upload_html = templates.get_template("_upload.html").render(
|
|
_ctx(request, are_trimiteri=True, message=succes_msg)
|
|
)
|
|
coada_html = templates.get_template("_coada.html").render(acasa_ctx)
|
|
status_html = templates.get_template("_status.html").render(status_ctx)
|
|
|
|
return HTMLResponse(
|
|
content=upload_html + "\n" + coada_html + "\n" + status_html,
|
|
headers={"HX-Trigger": "trimiteriChanged"},
|
|
)
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# Sectiune "Contul meu": rotire cheie API + creds RAR din UI. #
|
|
# Rute web proprii scoped pe sesiune (nu reutilizeaza /v1/conturi/rar-creds #
|
|
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
|
# =========================================================================== #
|
|
|
|
def _render_cont(
|
|
request: Request,
|
|
*,
|
|
api_key: str | None = None,
|
|
are_creds: bool = False,
|
|
creds_mesaj: str | None = None,
|
|
creds_eroare: str | None = None,
|
|
rot_eroare: str | None = None,
|
|
) -> HTMLResponse:
|
|
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
|
return templates.TemplateResponse(
|
|
"_cont.html",
|
|
_ctx(
|
|
request,
|
|
api_key=api_key,
|
|
are_creds=are_creds,
|
|
creds_mesaj=creds_mesaj,
|
|
creds_eroare=creds_eroare,
|
|
rot_eroare=rot_eroare,
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
|
def fragment_cont(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
|
|
account_id = require_login(request)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
return _render_cont(request, are_creds=are_creds)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/cont/roteste-cheie", response_class=HTMLResponse)
|
|
def cont_roteste_cheie(
|
|
request: Request,
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Revoca toate cheile active si emite una noua. Afisata O SINGURA DATA."""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
new_key = rotate_api_key(conn, acct)
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
return _render_cont(request, api_key=new_key, are_creds=are_creds)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/integrare/test-cheie", response_class=HTMLResponse)
|
|
def integrare_test_cheie(
|
|
request: Request,
|
|
api_key: str = Form(""),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
|
|
|
|
Permite utilizatorului sa confirme ca o cheie copiata din generatorul de exemple
|
|
corespunde contului sau, fara efecte secundare (fara creare/rotire). Cheie goala,
|
|
invalida sau a altui cont -> mesaj de eroare neutru (fara eco al cheii in raspuns).
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
|
|
# Validare cheie goala / doar spatii -> eroare inainte de DB
|
|
cheie = (api_key or "").strip()
|
|
if not cheie:
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False, "mesaj": "Cheia este goala sau lipseste."},
|
|
)
|
|
|
|
conn = get_connection()
|
|
try:
|
|
from ..auth import account_for_key
|
|
cont_cheie = account_for_key(conn, cheie)
|
|
finally:
|
|
conn.close()
|
|
|
|
if cont_cheie is None:
|
|
# Cheie invalida, inexistenta sau revocata
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False,
|
|
"mesaj": "Cheie invalida sau revocata — nu a fost gasita in sistem."},
|
|
)
|
|
|
|
if cont_cheie != acct:
|
|
# Cheie valida dar apartine altui cont — mesaj neutru, fara dezvaluire cont terta
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False,
|
|
"mesaj": "Cheia nu apartine contului tau."},
|
|
)
|
|
|
|
# Succes — cheia e activa si corespunde contului sesiunii
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": True,
|
|
"mesaj": f"Cheie valida — cont {acct}."},
|
|
)
|
|
|
|
|
|
@router.post("/cont/rar-creds", response_class=HTMLResponse)
|
|
def cont_rar_creds(
|
|
request: Request,
|
|
rar_email: str = Form(""),
|
|
rar_parola: str = Form(""),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Seteaza creds RAR per cont din sesiune (ruta web proprie).
|
|
|
|
Camp parola NICIODATA re-pus in value= la re-randare.
|
|
Validare minima: email si parola negoale.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
|
|
email = rar_email.strip()
|
|
parola = rar_parola.strip()
|
|
|
|
if not email or not parola:
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
creds_eroare="Email si parola sunt obligatorii.",
|
|
)
|
|
|
|
enc = encrypt_creds({"email": email, "password": parola})
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
return _render_cont(
|
|
request,
|
|
are_creds=True,
|
|
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
|
)
|
|
finally:
|
|
conn.close()
|