diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 39b3b51..91e7f16 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -34,6 +34,7 @@ from ...mapping import ( save_mapping, ) from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult +from ...payload_view import prezentare_din_payload from ...validation import validate_prezentare router = APIRouter(prefix="/v1", tags=["v1"]) @@ -139,19 +140,31 @@ def list_prezentari( conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) + # payload_json e plaintext (vezi submissions.payload_json); il citim doar ca + # sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem. + cols = ( + "id, status, id_prezentare, rar_status_code, retry_count, " + "created_at, updated_at, payload_json" + ) if status: rows = conn.execute( - f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " - f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?", + f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? " + f"ORDER BY id DESC LIMIT ?", scope_params + [status, limit], ).fetchall() else: rows = conn.execute( - f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " - f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", + f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", scope_params + [limit], ).fetchall() - return {"submissions": [dict(r) for r in rows]} + out = [] + for r in rows: + d = dict(r) + # Campuri afisabile derivate din payload (acelasi helper ca dashboardul web); + # payload_json brut nu se intoarce in raspuns. + d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None)) + out.append(d) + return {"submissions": out} finally: conn.close() diff --git a/app/payload_view.py b/app/payload_view.py new file mode 100644 index 0000000..37f293a --- /dev/null +++ b/app/payload_view.py @@ -0,0 +1,112 @@ +"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5). + +Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API +(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie +eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON +plaintext, vezi `submissions.payload_json`) sau un dict deja parsat. + +Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza +la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor: +`nr_inmatriculare` vs `numar`/`numarInmatriculare`; coercion Excel pe odometru/VIN). +""" + +from __future__ import annotations + +import json +from typing import Any + +EMPTY = "—" + + +def _clean_str(value: Any) -> str: + """str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe).""" + if value is None: + return "" + return str(value).strip() + + +def _clean_odometru(value: Any) -> str: + """Odometru afisat curat: strip '.0' din coercion Excel (123456.0 -> 123456).""" + s = _clean_str(value) + if "." in s: + before, after = s.split(".", 1) + if after == "0" and before.lstrip("-").isdigit(): + return before + return s + + +def _vin_scurt(vin: str) -> str: + """Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu). + + VIN are 17 caractere; in tabel aratam ultimele 6 cu prefix elipsa ca sa + incapa fara sa ascundem complet identitatea vehiculului. + """ + if not vin: + return "" + if len(vin) <= 8: + return vin + return "…" + vin[-6:] + + +def _prima_prestatie(prestatii: Any) -> dict[str, Any]: + """Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {}).""" + if isinstance(prestatii, list): + for item in prestatii: + if isinstance(item, dict): + return item + return {} + + +def _payload_dict(payload: str | dict | None) -> dict[str, Any]: + """Normalizeaza intrarea la dict. JSON invalid / tip neasteptat -> {}.""" + if payload is None: + return {} + if isinstance(payload, dict): + return payload + if isinstance(payload, str): + try: + data = json.loads(payload) + except (ValueError, TypeError): + return {} + return data if isinstance(data, dict) else {} + return {} + + +def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: + """Campuri afisabile dintr-un payload de submission. + + Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc): + vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod. + + `operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR + (`cod_prestatie`) sau, in lipsa, codul intern (`cod_op_service`). + """ + data = _payload_dict(payload) + + vin = _clean_str(data.get("vin")) + nr = _clean_str( + data.get("nr_inmatriculare") + or data.get("numar") + or data.get("numarInmatriculare") + ) + odo = _clean_odometru( + data.get("odometru_final") + if data.get("odometru_final") is not None + else data.get("odometru") + ) + data_prest = _clean_str(data.get("data_prestatie") or data.get("dataPrestatie")) + + item = _prima_prestatie(data.get("prestatii")) + cod = _clean_str(item.get("cod_prestatie") or item.get("cod_op_service")) + denumire = _clean_str(item.get("denumire")) + operatie = denumire or cod + + return { + "vehicul_nr": nr or EMPTY, + "vin": vin or EMPTY, + "vin_scurt": _vin_scurt(vin) or EMPTY, + "operatie": operatie or EMPTY, + "data_prestatie": data_prest or EMPTY, + "odometru": odo or EMPTY, + "cod": cod or EMPTY, + } diff --git a/app/web/labels.py b/app/web/labels.py index 2ebfdf3..1172e7a 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -6,6 +6,8 @@ Functii pure: fara DB, fara request. Usor de testat unitar si de importat in tem Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001. """ +import json +from datetime import datetime from typing import Tuple # --------------------------------------------------------------------------- @@ -119,6 +121,80 @@ def eticheta_rar(stare: str) -> Eticheta: ) +# --------------------------------------------------------------------------- +# Format data RAR (US-001, PRD 3.5) +# --------------------------------------------------------------------------- + +def format_data_rar(raw: object) -> str: + """Formateaza un timestamp ISO ca `dd.mm.yyyy hh24:mi:ss` (ora romaneasca). + + - Valoare lipsa (None / "") -> "—". + - ISO valid (cu sau fara timezone / 'Z' / microsecunde) -> data formatata, + fara fractiuni de secunda. + - Format invalid -> fallback grijuliu: intoarce stringul brut (nu arunca), + ca operatorul sa vada totusi ceva, nu o pagina rupta. + """ + if raw is None: + return "—" + s = str(raw).strip() + if not s: + return "—" + iso = s.replace("Z", "+00:00") if s.endswith("Z") else s + try: + dt = datetime.fromisoformat(iso) + except (ValueError, TypeError): + return s + return dt.strftime("%d.%m.%Y %H:%M:%S") + + +# --------------------------------------------------------------------------- +# Motiv uman din rar_error (US-004, PRD 3.5) +# --------------------------------------------------------------------------- + +def motiv_uman(status: str, rar_error: object) -> str: + """Transforma `rar_error` (JSON tehnic) intr-un motiv lizibil pentru coloana Motiv. + + Formele intalnite (vezi router.py / mapping.py): + - validare continut: list[{field, message}] -> mesajele concatenate. + - operatie nemapata: {"unmapped": [{cod_op_service, denumire}]}. + - auto-send oprit: {"auto_send": "..."}. + - eroare RAR: text simplu sau dict generic. + Fara rar_error -> "". Nu arunca niciodata (degradeaza la text brut trunchiat). + """ + if not rar_error: + return "" + raw = rar_error if isinstance(rar_error, str) else str(rar_error) + try: + data = json.loads(raw) + except (ValueError, TypeError): + return raw[:160] + + if isinstance(data, dict): + if "unmapped" in data: + ops = data.get("unmapped") or [] + nume = ", ".join( + (o.get("cod_op_service") or "") for o in ops if isinstance(o, dict) + ).strip(", ") + return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa" + if "auto_send" in data: + return "Necesita confirmare manuala (auto-send oprit pentru cod)" + parti = [f"{k}: {v}" for k, v in data.items()] + return "; ".join(parti)[:200] + + if isinstance(data, list): + msgs: list[str] = [] + for e in data: + if isinstance(e, dict): + msgs.append( + str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values())) + ) + else: + msgs.append(str(e)) + return "; ".join(m for m in msgs if m)[:200] + + return str(data)[:160] + + # --------------------------------------------------------------------------- # Constante auxiliare (microcopy fix, fara logica) # --------------------------------------------------------------------------- diff --git a/app/web/routes.py b/app/web/routes.py index a82a271..8e1f640 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -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. # diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index edd094f..6c47829 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -1,81 +1,56 @@
- Operatii ROAAUTO necunoscute, blocate in needs_mapping. - Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat. -
+ + + +