feat(ui): #15 U5 — web upload import (HTMX) drop→mapare→preview→confirma
Implementare completa U5 din plan-treapta2.md (sectiunea 13): - _upload.html: drop zone + buton accesibil (a11y: drag nu e la tastatura), drag-and-drop JS, mesaj 'NU se trimite nimic pana confirmi', selector foi pt multi-sheet xlsx, stari eroare/mesaj - _mapcoloane.html: formular mapare coloane cu .maprow/.mapcol.grow, sugestii fuzzy pre-selectate, etiichete <label> vizibile, sample values, format data configurabil - _preview_import.html: tabel 6 stari, pills rezumat, filtre pe stare, .chk per-rand pe needs_review (D11), banner declarant .banner.warn direct deasupra input-ului N (D12), bara confirmare sticky, text 'dubla cu randul N' pe duplicate_in_file (D10 daltonism), link export CSV randuri esuate - base.html: .s-needs_review (warn), .s-already_sent/.s-duplicate_in_file (muted), .drop-zone, .banner.warn, .sticky-bar, .htmx-indicator - routes.py: rute /_import/upload/mapare-coloane/preview/reset/confirma; helper _web_compute_preview refoloseste _resolve_row_for_preview, _already_sent_lookup, _signature din import_router (fara a-l edita); commit ON CONFLICT DO NOTHING (TOCTOU); log atestare - tests/test_import_ui.py: 15 teste (dashboard, upload, mapare, preview, confirmare N corect/gresit, reset, erori, multi-sheet, a11y D10/D11/D12) 279 teste total, 0 esecuri. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,21 +3,51 @@
|
||||
Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
|
||||
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator +
|
||||
export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review).
|
||||
|
||||
U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
|
||||
Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica.
|
||||
Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..api.v1.import_router import (
|
||||
_already_sent_lookup,
|
||||
_build_idempotency_key,
|
||||
_CANONICAL_SYNONYMS,
|
||||
_fuzzy_suggest_column,
|
||||
_resolve_row_for_preview,
|
||||
_signature,
|
||||
)
|
||||
from ..config import get_settings
|
||||
from ..crypto import decrypt_creds, encrypt_creds
|
||||
from ..db import get_connection, read_heartbeat
|
||||
from ..mapping import load_nomenclator, pending_unmapped, reresolve_account, save_mapping
|
||||
from ..idempotency import build_key, canonicalize_row
|
||||
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
|
||||
from ..mapping import (
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
account_or_default,
|
||||
load_mapping_meta,
|
||||
load_nomenclator,
|
||||
pending_unmapped,
|
||||
reresolve_account,
|
||||
resolve_prestatii,
|
||||
save_mapping,
|
||||
)
|
||||
|
||||
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5)
|
||||
_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"))
|
||||
@@ -169,3 +199,681 @@ def post_mapare(
|
||||
return _render_mapari(request, conn, message=msg)
|
||||
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 _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 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
|
||||
rows: 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)
|
||||
|
||||
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 — Eng#5)
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
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 (Eng#5 — 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")
|
||||
|
||||
return {
|
||||
"rows": preview_rows,
|
||||
"summary": summary,
|
||||
"total": len(preview_rows),
|
||||
"filename": batch["filename"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/_import/upload", response_class=HTMLResponse)
|
||||
async def web_upload_import(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
sheet_name: 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 = DEFAULT_ACCOUNT_ID
|
||||
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", {
|
||||
"request": request,
|
||||
"sheets": ms.sheet_names,
|
||||
})
|
||||
except FileTooLarge as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": str(e),
|
||||
})
|
||||
except HeaderError as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Antet neclar: {e}",
|
||||
})
|
||||
except UnicodeDecodeError as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Encoding nesuportat: {e.reason}",
|
||||
})
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
|
||||
})
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
sig = _signature(parsed.columns)
|
||||
|
||||
# Stagingul in DB (tranzactie explicita — Issue 6)
|
||||
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,
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": batch_id_int,
|
||||
"message": "Mapare retinuta aplicata automat.",
|
||||
**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,
|
||||
})
|
||||
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 = DEFAULT_ACCOUNT_ID
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
form = await request.form()
|
||||
|
||||
# Colectare perechi coloana fisier → camp canonic din form
|
||||
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
|
||||
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", {
|
||||
"request": 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", {
|
||||
"request": request,
|
||||
"error": "Batch de import inexistent sau expirat.",
|
||||
})
|
||||
|
||||
sig = _signature(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", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": 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 = DEFAULT_ACCOUNT_ID
|
||||
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,
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
**result,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_import/reset", response_class=HTMLResponse)
|
||||
def web_import_reset(request: Request) -> HTMLResponse:
|
||||
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
||||
return templates.TemplateResponse("_upload.html", {"request": request})
|
||||
|
||||
|
||||
@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 = DEFAULT_ACCOUNT_ID
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
form = await request.form()
|
||||
|
||||
# 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", {
|
||||
"request": request,
|
||||
"error": "Batch de import inexistent sau expirat.",
|
||||
})
|
||||
|
||||
if batch["status"] == "committed":
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": 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, resolved_status FROM import_rows "
|
||||
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
|
||||
(import_id,),
|
||||
).fetchall()
|
||||
|
||||
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", {"request": request, "error": result})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": 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, "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, "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", {"request": request, "error": msg})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": 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", {"request": request, "error": result})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": 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()}
|
||||
|
||||
# Enqueue in tranzactie explicita (Issue 6) — 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
|
||||
operatie_val = mapped.pop("operatie", None)
|
||||
if operatie_val and "prestatii" not in mapped:
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}]
|
||||
|
||||
# Rezolva prestatii
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops)
|
||||
mapped["prestatii"] = resolved_p
|
||||
|
||||
# Canonicalizare
|
||||
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:
|
||||
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 (Voce#9)
|
||||
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 → drop zone cu mesaj de confirmare
|
||||
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"message": (
|
||||
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
|
||||
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
|
||||
),
|
||||
})
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user