feat(web): dashboard compact — import pe Acasa, status cu bife, Trimiteri lizibile, Mapari complete (3.5)
Acasa = ecran de import (tab Import scos, ?tab=import->Acasa). Bara status compacta pe 2 randuri cu bife accesibile (glife + text) + data formatata. 'Coada'->'Trimiteri': coloane RO, stare umana, detaliu la click in panou dedicat. Mapari pe 3 sectiuni (de rezolvat / op salvate / formate coloane), Cont doar cheie+creds. Filtrare Trimiteri, corectie inline needs_data cu re-enqueue + detectie coliziune idempotency, badge contoare pe tab-uri. Helper pur partajat payload_view.py (web + GET /v1/prezentari). Backend trimitere (worker/idempotenta/mapping/schema) neatins. 483 teste. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,22 +13,26 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import rotate_api_key
|
||||
from ..payload_view import prezentare_din_payload
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from .labels import (
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
eticheta_rar,
|
||||
eticheta_stare,
|
||||
eticheta_worker,
|
||||
format_data_rar,
|
||||
motiv_uman,
|
||||
)
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
@@ -43,11 +47,14 @@ from ..config import get_settings
|
||||
from ..crypto import decrypt_creds, encrypt_creds
|
||||
from ..db import get_connection, 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 ..mapping import (
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
has_no_auto_send,
|
||||
load_mapping_meta,
|
||||
load_nomenclator,
|
||||
pending_unmapped,
|
||||
@@ -121,7 +128,9 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
||||
return "indisponibil?" if age > 108000 else "ok"
|
||||
|
||||
|
||||
_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"}
|
||||
# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import
|
||||
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
|
||||
_TABS_VALIDE = {"acasa", "coada", "mapari", "cont", "nomenclator"}
|
||||
|
||||
|
||||
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||
@@ -158,13 +167,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||
"are_creds": are_creds,
|
||||
"are_trimiteri": are_trimiteri,
|
||||
"are_cheie_folosita": are_cheie_folosita,
|
||||
# US-002: Acasa include caseta de upload -> are nevoie de csrf_token
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
|
||||
|
||||
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
|
||||
"""Randeaza panoul Acasa ca string HTML."""
|
||||
if conn is None:
|
||||
return templates.get_template("_acasa.html").render({"request": request})
|
||||
return templates.get_template("_acasa.html").render(
|
||||
{"request": request, "csrf_token": get_csrf_token(request)}
|
||||
)
|
||||
ctx = _get_acasa_context(request, conn, account_id)
|
||||
return templates.get_template("_acasa.html").render(ctx)
|
||||
|
||||
@@ -183,10 +196,12 @@ def _render_panel_coada(request: Request) -> str:
|
||||
|
||||
|
||||
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
||||
"""Randeaza panoul Mapari ca string HTML."""
|
||||
"""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),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
@@ -251,12 +266,19 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
||||
conn = get_connection()
|
||||
try:
|
||||
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
|
||||
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari, blocate -> Trimiteri.
|
||||
counts = _status_counts(conn, account_id)
|
||||
badges = {
|
||||
"mapari": counts.get("needs_mapping", 0),
|
||||
"coada": sum(counts.get(s, 0) for s in _BLOCKED),
|
||||
}
|
||||
ctx = {
|
||||
"request": request,
|
||||
"rar_env": get_settings().rar_env,
|
||||
"version": __version__,
|
||||
"active_tab": active_tab,
|
||||
"panel_html": panel_html,
|
||||
"badges": badges,
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
@@ -355,16 +377,22 @@ def fragment_status(request: Request) -> HTMLResponse:
|
||||
# Etichete umane pre-calculate (nu logica in template)
|
||||
worker_lbl = eticheta_worker(worker_alive)
|
||||
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
|
||||
rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state)
|
||||
rar_ok = rar_state == "ok"
|
||||
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
|
||||
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
|
||||
return templates.TemplateResponse("_status.html", {
|
||||
"request": request,
|
||||
"worker_lbl": worker_lbl,
|
||||
"rar_lbl": rar_lbl,
|
||||
# Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare
|
||||
"worker_ok": worker_alive,
|
||||
"rar_ok": rar_ok,
|
||||
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"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),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
@@ -372,23 +400,376 @@ def fragment_status(request: Request) -> HTMLResponse:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _is_iso_date(value: object) -> bool:
|
||||
"""True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect)."""
|
||||
s = str(value or "").strip()
|
||||
if len(s) != 10:
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(s, "%Y-%m-%d")
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _submission_row_view(r) -> dict:
|
||||
"""Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004)."""
|
||||
eticheta = eticheta_stare(r["status"])
|
||||
return {
|
||||
"id": r["id"],
|
||||
"status": 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_uman(r["status"], r["rar_error"]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(request: Request) -> HTMLResponse:
|
||||
def fragment_submissions(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
vehicul: str | None = None,
|
||||
data_de: str | None = None,
|
||||
data_pana: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009).
|
||||
|
||||
Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status);
|
||||
filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie
|
||||
se face dupa parsarea payload_json in Python (plafon perf notat — eng review).
|
||||
"""
|
||||
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)
|
||||
|
||||
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)
|
||||
# Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am
|
||||
# taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care
|
||||
# potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam
|
||||
# afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review).
|
||||
limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200"
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||
"updated_at, payload_json FROM submissions "
|
||||
f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
view = []
|
||||
for r in rows:
|
||||
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:
|
||||
d = prez["data_prestatie"]
|
||||
# Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string
|
||||
# ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru.
|
||||
if not _is_iso_date(d):
|
||||
continue
|
||||
if data_de and d < data_de:
|
||||
continue
|
||||
if data_pana and d > data_pana:
|
||||
continue
|
||||
view.append(v)
|
||||
if len(view) >= 200:
|
||||
break
|
||||
|
||||
return templates.TemplateResponse("_submissions.html", {
|
||||
"request": request,
|
||||
"rows": view,
|
||||
"filtru_activ": filtru_activ,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
|
||||
_CORECTABILE = ("needs_data", "needs_mapping")
|
||||
|
||||
|
||||
def _payload_form_values(payload_json) -> dict:
|
||||
"""Valori brute pentru prefill-ul formularului de corectie (US-010)."""
|
||||
try:
|
||||
data = json.loads(payload_json) if payload_json else {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (ValueError, TypeError):
|
||||
data = {}
|
||||
return {
|
||||
"form_vin": data.get("vin") or "",
|
||||
"form_nr": data.get("nr_inmatriculare") or "",
|
||||
"form_data": data.get("data_prestatie") or "",
|
||||
"form_odo_final": data.get("odometru_final") or "",
|
||||
"form_odo_initial": data.get("odometru_initial") or "",
|
||||
}
|
||||
|
||||
|
||||
def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
||||
error: bool = False, corectie_errors: list | None = None) -> dict:
|
||||
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission."""
|
||||
eticheta = eticheta_stare(row["status"])
|
||||
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"]),
|
||||
"retry_count": row["retry_count"],
|
||||
"created_at": format_data_rar(row["created_at"]),
|
||||
"updated_at": format_data_rar(row["updated_at"]),
|
||||
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
|
||||
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
|
||||
"editabil": row["status"] in _CORECTABILE,
|
||||
"corectie_msg": message,
|
||||
"corectie_error": error,
|
||||
"corectie_errors": corectie_errors or [],
|
||||
}
|
||||
ctx.update(_payload_form_values(row["payload_json"]))
|
||||
return ctx
|
||||
|
||||
|
||||
def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
|
||||
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3)."""
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
||||
[submission_id] + scope_params,
|
||||
).fetchone()
|
||||
|
||||
|
||||
# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e
|
||||
# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload).
|
||||
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
|
||||
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
|
||||
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
|
||||
|
||||
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
|
||||
(acelasi mesaj, nu confirmam existenta — vezi B3/router.py).
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
|
||||
"FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
||||
"ORDER BY id DESC LIMIT 100",
|
||||
(account_id, account_id),
|
||||
).fetchall()
|
||||
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
|
||||
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))
|
||||
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()
|
||||
|
||||
# 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()}
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
|
||||
content["prestatii"] = 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, error=True,
|
||||
message="Lipseste inca un cod RAR — rezolva operatia in tab-ul Mapari."),
|
||||
)
|
||||
|
||||
if has_no_auto_send(resolved, mapping_meta):
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json,
|
||||
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
|
||||
ensure_ascii=False),
|
||||
row["id"]),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, error=True,
|
||||
message="Cod cu auto-send oprit — confirma manual din tab-ul Mapari."),
|
||||
)
|
||||
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
# Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp.
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps(errors, ensure_ascii=False), row["id"]),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, message="Mai sunt campuri invalide — vezi mai jos.",
|
||||
error=True, corectie_errors=errors),
|
||||
)
|
||||
|
||||
# Valid: recalculeaza cheia. Coliziune cu alt rand -> opreste, fara 500/duplicat.
|
||||
new_key = build_key(account_id, canon)
|
||||
if new_key != row["idempotency_key"]:
|
||||
dup = conn.execute(
|
||||
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
||||
(new_key, row["id"]),
|
||||
).fetchone()
|
||||
if dup:
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row2,
|
||||
message=f"Exista deja o trimitere identica (rand #{dup['id']}). Corectia a fost oprita.",
|
||||
error=True,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
||||
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(new_key, payload_json, row["id"]),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
|
||||
# pre-check-ul a trecut dar alt rand a primit cheia intre timp. Fara 500.
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, error=True,
|
||||
message="Exista deja o trimitere identica. Corectia a fost oprita."),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
||||
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
|
||||
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
|
||||
"FROM operations_mapping o "
|
||||
"LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie "
|
||||
"WHERE o.account_id=? ORDER BY o.cod_op_service",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"cod_op_service": r["cod_op_service"],
|
||||
"cod_prestatie": r["cod_prestatie"],
|
||||
"auto_send": bool(r["auto_send"]),
|
||||
"nume_prestatie": r["nume_prestatie"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _load_column_formats(conn, account_id: int) -> list[dict]:
|
||||
"""Formate de coloane salvate (column_mappings) ale contului (US-006).
|
||||
|
||||
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT id, signature_coloane, json_mapare, format_data, created_at "
|
||||
"FROM column_mappings WHERE account_id=? ORDER BY id DESC",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
jm = json.loads(r["json_mapare"]) if r["json_mapare"] else {}
|
||||
except (ValueError, TypeError):
|
||||
jm = {}
|
||||
out.append({
|
||||
"id": r["id"],
|
||||
"signature_coloane": r["signature_coloane"],
|
||||
"mappings": jm,
|
||||
"columns": list(jm.keys()),
|
||||
"format_data": r["format_data"],
|
||||
"created_at": r["created_at"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _render_mapari(
|
||||
request: Request, conn, account_id: int, *, message: str | None = None
|
||||
) -> HTMLResponse:
|
||||
@@ -397,6 +778,8 @@ def _render_mapari(
|
||||
{
|
||||
"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),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": message,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
@@ -447,6 +830,146 @@ def post_mapare(
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere #
|
||||
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
|
||||
# =========================================================================== #
|
||||
|
||||
@router.post("/mapari/salvate", response_class=HTMLResponse)
|
||||
def post_editeaza_mapare_salvata(
|
||||
request: Request,
|
||||
cod_op_service: str = Form(...),
|
||||
cod_prestatie: str = Form(...),
|
||||
csrf_token: str | None = Form(None),
|
||||
auto_send: bool = Form(False),
|
||||
) -> HTMLResponse:
|
||||
"""Editeaza o mapare op->cod salvata (cod RAR / auto-send) + re-rezolva blocatele.
|
||||
|
||||
Scoped pe contul sesiunii (save_mapping foloseste account_or_default(sesiune) —
|
||||
cross-account imposibil). Respinge cod inexistent in nomenclator.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = cod_prestatie.strip().upper()
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
||||
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
msg = (
|
||||
f"Mapare actualizata: {cod_op_service.strip()} -> {cod}. "
|
||||
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
|
||||
f"{stats['still_blocked']} inca nemapate."
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message=msg)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/mapari/salvate/sterge", response_class=HTMLResponse)
|
||||
def post_sterge_mapare_salvata(
|
||||
request: Request,
|
||||
cod_op_service: str = Form(...),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Sterge o mapare op->cod salvata. Scoped pe contul sesiunii."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
|
||||
(acct, cod_op_service.strip()),
|
||||
)
|
||||
return _render_mapari(
|
||||
request, conn, account_id,
|
||||
message=f"Mapare stearsa: {cod_op_service.strip()}.",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# US-006 — Formate de coloane salvate: editare format data + stergere #
|
||||
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
|
||||
# =========================================================================== #
|
||||
|
||||
@router.post("/formate-coloane/editeaza", response_class=HTMLResponse)
|
||||
async def post_editeaza_format_coloane(
|
||||
request: Request,
|
||||
format_id: int = Form(...),
|
||||
format_data: str | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Editeaza un format de coloane salvat (format data). Scoped pe cont prin id+account_id.
|
||||
|
||||
json_mapare optional (string JSON valid) — daca e dat, inlocuieste maparea coloanelor.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
form = await request.form()
|
||||
json_mapare_raw = form.get("json_mapare")
|
||||
conn = get_connection()
|
||||
try:
|
||||
owned = conn.execute(
|
||||
"SELECT 1 FROM column_mappings WHERE id=? AND account_id=?",
|
||||
(format_id, acct),
|
||||
).fetchone()
|
||||
if not owned:
|
||||
return _render_mapari(
|
||||
request, conn, account_id, message="Format inexistent sau inaccesibil."
|
||||
)
|
||||
fmt = (format_data or "").strip() or None
|
||||
if isinstance(json_mapare_raw, str) and json_mapare_raw.strip():
|
||||
try:
|
||||
jm = json.loads(json_mapare_raw)
|
||||
if not isinstance(jm, dict):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return _render_mapari(
|
||||
request, conn, account_id, message="Mapare coloane invalida (JSON)."
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE column_mappings SET json_mapare=?, format_data=? WHERE id=? AND account_id=?",
|
||||
(json.dumps(jm, ensure_ascii=False), fmt, format_id, acct),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE column_mappings SET format_data=? WHERE id=? AND account_id=?",
|
||||
(fmt, format_id, acct),
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message="Format de coloane actualizat.")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/formate-coloane/sterge", response_class=HTMLResponse)
|
||||
def post_sterge_format_coloane(
|
||||
request: Request,
|
||||
format_id: int = Form(...),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Sterge un format de coloane salvat. Scoped pe cont prin id+account_id."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM column_mappings WHERE id=? AND account_id=?",
|
||||
(format_id, acct),
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message="Format de coloane sters.")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# Import UI (U5) — upload → mapare coloane → preview → confirmare #
|
||||
# Consuma helper-e din import_router fara a edita fisierul backend. #
|
||||
|
||||
Reference in New Issue
Block a user