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()
|
||||
|
||||
96
app/web/templates/_mapcoloane.html
Normal file
96
app/web/templates/_mapcoloane.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<div id="import-section">
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||
Mapare coloane —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
|
||||
{% if error %}role="alert"{% endif %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Asociaza fiecare coloana din fisier cu campul canonic corespunzator.
|
||||
Maparea se retine automat pentru fisiere cu acelasi antet.
|
||||
</p>
|
||||
|
||||
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<label for="format-data" style="font-size:13px; color:var(--muted);">
|
||||
Format data
|
||||
</label>
|
||||
<input type="text" id="format-data" name="format_data"
|
||||
value="{{ format_data or 'DD.MM.YYYY' }}"
|
||||
placeholder="ex: DD.MM.YYYY"
|
||||
style="max-width:160px;"
|
||||
aria-describedby="format-data-hint">
|
||||
<span id="format-data-hint" class="muted" style="font-size:12px;">
|
||||
sau YYYY-MM-DD, MM/DD/YYYY etc.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% for col in columns %}
|
||||
{%- set sugg = fuzzy_suggestions.get(col, []) -%}
|
||||
{%- set best = sugg[0].camp_canonic if sugg else '' -%}
|
||||
<input type="hidden" name="colname" value="{{ col }}">
|
||||
<div class="maprow">
|
||||
<div class="mapcol grow">
|
||||
<div><strong>{{ col }}</strong></div>
|
||||
{% if sugg %}
|
||||
<div class="muted" style="font-size:12px; margin-top:2px;">
|
||||
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }}
|
||||
({{ sugg[0].score | round | int }}%)</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- set ns = namespace(samples=[]) -%}
|
||||
{%- for row in sample_rows -%}
|
||||
{%- if row.get(col) is not none and row.get(col) != '' -%}
|
||||
{%- set ns.samples = ns.samples + [row[col] | string] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{% if ns.samples %}
|
||||
<div class="muted" style="font-size:11px; margin-top:2px;">
|
||||
ex: {{ ns.samples[:2] | join(", ") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mapcol" style="min-width:200px;">
|
||||
<label for="canon-{{ loop.index }}"
|
||||
style="display:block; font-size:12px; color:var(--muted); margin-bottom:2px;">
|
||||
Camp canonic
|
||||
</label>
|
||||
<select id="canon-{{ loop.index }}" name="canon">
|
||||
<option value="">— ignorat —</option>
|
||||
{% for field_key, field_label in canonical_fields %}
|
||||
<option value="{{ field_key }}"
|
||||
{% if field_key == best %}selected{% endif %}>
|
||||
{{ field_key }} — {{ field_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<button type="submit"
|
||||
style="min-height:44px; padding:10px 24px; font-size:14px;">
|
||||
Salveaza si continua la preview
|
||||
</button>
|
||||
<span class="muted" style="font-size:12px;">
|
||||
maparea se retine pentru fisiere cu acelasi antet
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:12px;">
|
||||
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
236
app/web/templates/_preview_import.html
Normal file
236
app/web/templates/_preview_import.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<div id="import-section">
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
<h2 style="font-size:15px; margin:0;">
|
||||
Preview —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
<span class="muted" style="margin-left:auto; font-size:13px;">{{ total }} randuri</span>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
|
||||
{% if error %}role="alert"{% endif %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rezumat stari -->
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
{% set status_labels = [
|
||||
('ok', 'gata de trimis'),
|
||||
('needs_review', 'verifica valori'),
|
||||
('needs_mapping', 'fara cod RAR'),
|
||||
('needs_data', 'date lipsa'),
|
||||
('already_sent', 'deja trimis'),
|
||||
('duplicate_in_file','dublicat in fisier'),
|
||||
] %}
|
||||
{% for status_key, label in status_labels %}
|
||||
{%- set cnt = summary.get(status_key, 0) -%}
|
||||
{% if cnt > 0 %}
|
||||
<span class="pill s-{{ status_key }}">{{ cnt }} {{ label }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Butoane filtrare stare -->
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
|
||||
aria-label="Filtrare dupa stare">
|
||||
<button type="button" class="filter-btn" data-filter="all"
|
||||
style="min-height:36px; font-size:13px; padding:4px 12px;">
|
||||
Toate ({{ total }})
|
||||
</button>
|
||||
{% for status_key, label in status_labels %}
|
||||
{%- set cnt = summary.get(status_key, 0) -%}
|
||||
{% if cnt > 0 %}
|
||||
<button type="button" class="filter-btn" data-filter="{{ status_key }}"
|
||||
style="min-height:36px; font-size:13px; padding:4px 12px;
|
||||
background:transparent; border-color:var(--line); color:var(--ink);">
|
||||
{{ status_key }} ({{ cnt }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Tabel preview + bara confirmare (un singur form) -->
|
||||
<form id="confirm-form"
|
||||
hx-post="/_import/{{ import_id }}/confirma"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>VIN</th>
|
||||
<th>Nr. Inm.</th>
|
||||
<th>Data</th>
|
||||
<th>KM final</th>
|
||||
<th>Operatie</th>
|
||||
<th>Stare</th>
|
||||
<th>Note</th>
|
||||
<th>Verificat?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
{%- set res = row.resolved -%}
|
||||
{%- set status = row.resolved_status -%}
|
||||
{%- set prestatii = res.get('prestatii') or [] -%}
|
||||
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
|
||||
<tr data-status="{{ status }}"
|
||||
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
|
||||
<td class="muted">{{ row.row_index + 1 }}</td>
|
||||
<td>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}</td>
|
||||
<td>{{ res.get('nr_inmatriculare') or '' }}</td>
|
||||
<td>{{ res.get('data_prestatie') or '' }}</td>
|
||||
<td>{{ res.get('odometru_final') or '' }}</td>
|
||||
<td>{{ op or '<span class="muted">—</span>' | safe }}</td>
|
||||
<td>
|
||||
<span class="pill s-{{ status }}">{{ status }}</span>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
|
||||
{% if status == 'already_sent' and row.get('already_sent_info') %}
|
||||
{% set ai = row.already_sent_info %}
|
||||
deja trimis {{ (ai.get('created_at') or '')[:10] }}
|
||||
{% if ai.get('id_prezentare') %}(#{{ ai.id_prezentare }}){% endif %}
|
||||
{% elif status == 'duplicate_in_file' and row.get('duplicate_with') %}
|
||||
dubla cu randul
|
||||
{% for idx in row.duplicate_with %}{{ idx + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
{% elif row.flags %}
|
||||
{{ row.flags[0] }}
|
||||
{% elif row.errors %}
|
||||
{%- set e = row.errors[0] -%}
|
||||
{%- if e is mapping -%}
|
||||
{{ e.values() | list | first }}
|
||||
{%- else -%}
|
||||
{{ e }}
|
||||
{%- endif -%}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if status == 'needs_review' %}
|
||||
<label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;"
|
||||
title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere">
|
||||
<input type="checkbox" name="reviewed_rows" value="{{ row.row_index }}"
|
||||
onchange="updateN()"
|
||||
aria-label="Verificat — randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
|
||||
verif.
|
||||
</label>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bara confirmare (sticky jos) -->
|
||||
<div class="sticky-bar">
|
||||
<div style="flex:1; min-width:280px;">
|
||||
<!-- Banner declarant (D12) — direct deasupra input-ului N -->
|
||||
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
|
||||
role="note" aria-live="polite">
|
||||
Confirmand, TU esti declarantul acestor
|
||||
<strong id="n-display">{{ summary.get('ok', 0) }}</strong>
|
||||
prezentari la RAR (ireversibil).
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<div>
|
||||
<label for="n-confirmat"
|
||||
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;">
|
||||
Numar prezentari de confirmat
|
||||
</label>
|
||||
<input type="number" id="n-confirmat" name="n_confirmat"
|
||||
value="{{ summary.get('ok', 0) }}"
|
||||
min="0" required
|
||||
style="max-width:80px;"
|
||||
aria-describedby="n-hint">
|
||||
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;">
|
||||
({{ summary.get('ok', 0) }} ok
|
||||
{% if summary.get('needs_review', 0) %}
|
||||
+ pana la {{ summary.get('needs_review', 0) }} verificate manual
|
||||
{% endif %})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmed-by"
|
||||
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;">
|
||||
Declarant (optional)
|
||||
</label>
|
||||
<input type="text" id="confirmed-by" name="confirmed_by"
|
||||
placeholder="email sau nume"
|
||||
style="max-width:200px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;">
|
||||
<button type="submit"
|
||||
id="confirm-btn"
|
||||
style="min-height:44px; padding:10px 28px; font-size:14px;"
|
||||
{% if not summary.get('ok', 0) %}disabled title="Niciun rand ok de trimis"{% endif %}>
|
||||
Trimite la RAR
|
||||
</button>
|
||||
{% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %}
|
||||
<a href="/v1/import/{{ import_id }}/export-failed" download
|
||||
style="font-size:12px; text-align:center;">
|
||||
descarca randuri cu probleme (CSV)
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div style="padding:8px 0 4px;">
|
||||
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var nOk = {{ summary.get('ok', 0) | int }};
|
||||
|
||||
/* Actualizeaza N si bannerul cand se bifeaza needs_review */
|
||||
function updateN() {
|
||||
var checked = document.querySelectorAll('input[name="reviewed_rows"]:checked').length;
|
||||
var total = nOk + checked;
|
||||
var inp = document.getElementById('n-confirmat');
|
||||
var disp = document.getElementById('n-display');
|
||||
var btn = document.getElementById('confirm-btn');
|
||||
if (inp) inp.value = total;
|
||||
if (disp) disp.textContent = total;
|
||||
if (btn) btn.disabled = (total === 0);
|
||||
}
|
||||
|
||||
/* Filtrare randuri dupa stare */
|
||||
function filterRows(status) {
|
||||
document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) {
|
||||
tr.style.display = (status === 'all' || tr.dataset.status === status) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.filter-btn').forEach(function(b) {
|
||||
var active = b.dataset.filter === status;
|
||||
b.style.background = active ? 'var(--accent)' : '';
|
||||
b.style.borderColor = active ? 'var(--accent)' : '';
|
||||
b.style.color = active ? '#fff' : '';
|
||||
});
|
||||
}
|
||||
|
||||
/* Expune functiile global pentru onclick inline */
|
||||
window.updateN = updateN;
|
||||
window.filterRows = filterRows;
|
||||
|
||||
/* Filtru implicit "Toate" activ la incarcare */
|
||||
filterRows('all');
|
||||
|
||||
/* Focus pe campul N la deschidere (a11y — D12) */
|
||||
var ni = document.getElementById('n-confirmat');
|
||||
if (ni) { ni.focus(); ni.select(); }
|
||||
})();
|
||||
</script>
|
||||
106
app/web/templates/_upload.html
Normal file
106
app/web/templates/_upload.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<div id="import-section">
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Import fisier (xlsx / csv)</h2>
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:12px;"
|
||||
role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sheets %}
|
||||
<div class="flash" style="border-color:var(--warn); background:#201c0f; margin-bottom:12px;">
|
||||
Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="upload-form"
|
||||
hx-post="/_import/upload"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner">
|
||||
|
||||
{% if sheets %}
|
||||
<div style="margin-bottom:12px;">
|
||||
<label for="sheet-select"
|
||||
style="display:block; margin-bottom:4px; font-size:13px; color:var(--muted);">
|
||||
Foaie de lucru
|
||||
</label>
|
||||
<select id="sheet-select" name="sheet_name" style="min-width:200px;">
|
||||
{% for s in sheets %}
|
||||
<option value="{{ s }}">{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="drop-zone" id="drop-zone"
|
||||
role="region" aria-label="Zona de incarcare fisier">
|
||||
{% if not sheets %}
|
||||
<p style="font-size:17px; margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</p>
|
||||
<p class="muted" style="margin:0 0 16px; font-size:13px;">xlsx sau csv, max 5000 randuri</p>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 16px; font-size:14px;">
|
||||
Incarca fisierul din nou dupa ce ai ales foaia.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
|
||||
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
|
||||
<button type="button" id="upload-btn"
|
||||
style="min-height:44px; padding:10px 24px; font-size:14px;">
|
||||
Alege fisier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="margin:8px 0 0; font-size:12px;">
|
||||
NU se trimite nimic la RAR pana confirmi explicit.
|
||||
</p>
|
||||
|
||||
<span id="upload-spinner" class="htmx-indicator muted"
|
||||
style="font-size:13px; margin-top:6px; display:inline;">
|
||||
se parseaza fisierul...
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var fi = document.getElementById('file-input');
|
||||
var dz = document.getElementById('drop-zone');
|
||||
var frm = document.getElementById('upload-form');
|
||||
if (!btn || !fi || !frm) return;
|
||||
|
||||
btn.addEventListener('click', function() { fi.click(); });
|
||||
|
||||
fi.addEventListener('change', function() {
|
||||
if (fi.files.length > 0) frm.requestSubmit();
|
||||
});
|
||||
|
||||
dz.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
dz.classList.add('drag-over');
|
||||
});
|
||||
dz.addEventListener('dragleave', function(e) {
|
||||
if (!dz.contains(e.relatedTarget)) dz.classList.remove('drag-over');
|
||||
});
|
||||
dz.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dz.classList.remove('drag-over');
|
||||
var f = (e.dataTransfer.files || [])[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(f);
|
||||
fi.files = dt.files;
|
||||
} catch (_) {}
|
||||
frm.requestSubmit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -28,8 +28,24 @@
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
||||
.s-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);}
|
||||
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
||||
.s-ok{color:var(--ok);}
|
||||
.s-needs_review{color:var(--warn);}
|
||||
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
|
||||
.muted { color:var(--muted); }
|
||||
a { color:var(--accent); }
|
||||
/* Drop zone upload fisier */
|
||||
.drop-zone { border:2px dashed var(--line); border-radius:8px; padding:32px 20px;
|
||||
text-align:center; transition:border-color .15s,background .15s; }
|
||||
.drop-zone.drag-over { border-color:var(--accent); background:rgba(91,141,239,.05); }
|
||||
/* Banner varianta warn (nu eroare) */
|
||||
.banner.warn { border-left-color:var(--warn); background:#201c0f; }
|
||||
/* Bara confirmare sticky */
|
||||
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line);
|
||||
padding:12px 16px; display:flex; align-items:flex-start; gap:16px;
|
||||
flex-wrap:wrap; z-index:10; }
|
||||
/* Indicator HTMX — ascuns pana la request */
|
||||
.htmx-indicator { display:none; }
|
||||
.htmx-indicator.htmx-request { display:inline; }
|
||||
/* Link-uri de actiune in antetul cardurilor: zona de atins mai mare (>=36px) si
|
||||
feedback la hover; pe ecrane inguste antetul se rupe curat sub titlu. */
|
||||
.cardlink { font-size:13px; padding:7px 10px; border-radius:6px; display:inline-flex;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow -->
|
||||
{% include '_upload.html' %}
|
||||
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
|
||||
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
|
||||
|
||||
450
tests/test_import_ui.py
Normal file
450
tests/test_import_ui.py
Normal file
@@ -0,0 +1,450 @@
|
||||
"""Teste UI web import (U5) — upload → mapare coloane → preview → confirmare.
|
||||
|
||||
Verifica:
|
||||
- Dashboard randeaza sectiunea de upload
|
||||
- Upload xlsx → mapare noua → fragment _mapcoloane returnat
|
||||
- Upload xlsx cu mapare existenta → preview direct
|
||||
- Salvare mapare coloane → preview randat
|
||||
- Preview afiseaza rezumat stari si randul tabelului
|
||||
- Confirmare cu N corect → succes (in coada)
|
||||
- Confirmare cu N gresit → eroare explicita
|
||||
- Reset → drop zone gol
|
||||
- Erori upload (fisier invalid, prea mare, header neclar)
|
||||
- Sheet selector la multi-sheet xlsx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _make_xlsx_bytes(rows: list[dict]) -> bytes:
|
||||
"""Construieste un xlsx minimal cu openpyxl pentru fixture teste."""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
if not rows:
|
||||
return b""
|
||||
headers = list(rows[0].keys())
|
||||
ws.append(headers)
|
||||
for row in rows:
|
||||
ws.append([row.get(h) for h in headers])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
|
||||
"""Construieste un CSV minimal pentru fixture teste."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
buf = io.StringIO()
|
||||
if not rows:
|
||||
return b""
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
_SAMPLE_ROWS = [
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000123",
|
||||
"Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026",
|
||||
"Odometru final": "123456",
|
||||
"Operatie": "Revizie",
|
||||
},
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000456",
|
||||
"Nr inmatriculare": "B002TST",
|
||||
"Data prestatie": "16.06.2026",
|
||||
"Odometru final": "200000",
|
||||
"Operatie": "Revizie",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -> None:
|
||||
"""Seeda o mapare de operatii cod_op → cod_prestatie via API."""
|
||||
client.post("/v1/mapari", json={
|
||||
"cod_op_service": cod_op,
|
||||
"cod_prestatie": cod_prest,
|
||||
"auto_send": True,
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dashboard #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_dashboard_contine_drop_zone(client):
|
||||
"""Dashboard-ul randeaza sectiunea de upload cu drop zone si mesaj warmth."""
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Primul fisier" in r.text
|
||||
assert "drop-zone" in r.text
|
||||
assert "NU se trimite nimic" in r.text
|
||||
assert "import-section" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Upload xlsx — mapare noua #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_xlsx_fara_mapare_arata_formular_mapare(client):
|
||||
"""Upload xlsx fara mapare salvata → fragment mapare coloane."""
|
||||
xlsx = _make_xlsx_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.xlsx", xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Formular de mapare coloane
|
||||
assert "Mapare coloane" in r.text
|
||||
assert "mapare-coloane" in r.text # URL in form action
|
||||
# Coloanele din fisier apar in formular
|
||||
assert "VIN" in r.text
|
||||
assert "Data prestatie" in r.text
|
||||
# Sugestii fuzzy pentru VIN
|
||||
assert "vin" in r.text.lower()
|
||||
|
||||
|
||||
def test_upload_csv_fara_mapare_arata_formular_mapare(client):
|
||||
"""Upload CSV cu separator ; → formular mapare coloane."""
|
||||
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.csv", csv_bytes, "text/csv")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "Mapare coloane" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Salvare mapare coloane → preview #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _upload_and_get_import_id(client, rows=None) -> int:
|
||||
"""Helper: incarca fisier si extrage import_id din raspuns."""
|
||||
xlsx = _make_xlsx_bytes(rows or _SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.xlsx", xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Extrage import_id din URL-ul form action din raspuns
|
||||
text = r.text
|
||||
# Form action contine /_import/{id}/mapare-coloane
|
||||
import re
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", text)
|
||||
assert m, f"Nu s-a gasit import_id in raspuns: {text[:500]}"
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def test_salvare_mapare_coloane_arata_preview(client):
|
||||
"""Dupa salvarea maparii de coloane, raspunsul contine preview-ul."""
|
||||
# Asigura ca nomenclatorul are OE-1 (seeding automat la init_db)
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
|
||||
r = client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Preview trebuie sa contina elementele cheie
|
||||
assert "Preview" in r.text
|
||||
assert "confirm-form" in r.text
|
||||
assert "n-confirmat" in r.text
|
||||
# Rezumat stari
|
||||
assert "gata de trimis" in r.text or "ok" in r.text
|
||||
|
||||
|
||||
def test_preview_arata_randul_vin(client):
|
||||
"""Preview contine VIN-ul din fisier."""
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
r = client.get(f"/_import/{import_id}/preview")
|
||||
assert r.status_code == 200
|
||||
assert "WVWZZZ1KZAW000123" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Upload cu mapare existenta → preview direct #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_cu_mapare_existenta_sare_direct_la_preview(client):
|
||||
"""Al doilea upload cu acelasi antet → preview imediat (mapare retinuta)."""
|
||||
# Primul upload + salvare mapare
|
||||
import_id1 = _upload_and_get_import_id(client)
|
||||
client.post(
|
||||
f"/_import/{import_id1}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
|
||||
# Al doilea upload cu acelasi antet
|
||||
xlsx = _make_xlsx_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test2.xlsx", xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Trebuie sa ajunga direct la preview, nu la mapare
|
||||
assert "Preview" in r.text
|
||||
assert "confirm-form" in r.text
|
||||
assert "Mapare retinuta aplicata automat" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Confirmare (gate HARD) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _setup_preview(client) -> int:
|
||||
"""Upload + mapare + seeda operatii + intoarce import_id gata de confirmare."""
|
||||
_seed_op_mapping(client) # "Revizie" → "OE-1"
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
return import_id
|
||||
|
||||
|
||||
def test_confirmare_n_corect_pune_in_coada(client):
|
||||
"""Confirmare cu N corect → randurile ok ajung in coada."""
|
||||
import_id = _setup_preview(client)
|
||||
|
||||
# Compute preview pentru a afla n_ok
|
||||
r_prev = client.get(f"/_import/{import_id}/preview")
|
||||
assert r_prev.status_code == 200
|
||||
|
||||
# Citeste summary din DB
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
batch = conn.execute(
|
||||
"SELECT ok FROM import_batches WHERE id=?", (import_id,)
|
||||
).fetchone()
|
||||
n_ok = batch["ok"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert n_ok > 0, "Asteptat cel putin un rand ok dupa seeding corect"
|
||||
|
||||
r = client.post(
|
||||
f"/_import/{import_id}/confirma",
|
||||
data={"n_confirmat": str(n_ok), "confirmed_by": "test@test.ro"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Succes → drop zone cu mesaj
|
||||
assert "S-au pus in coada" in r.text or "prezentari" in r.text
|
||||
# Sectiunea se reseteaza la drop zone
|
||||
assert "drop-zone" in r.text
|
||||
|
||||
# Verifica ca submissions au fost create
|
||||
conn2 = get_connection()
|
||||
try:
|
||||
n = conn2.execute(
|
||||
"SELECT COUNT(*) FROM submissions WHERE batch_id=?", (import_id,)
|
||||
).fetchone()[0]
|
||||
assert n == n_ok, f"Asteptat {n_ok} submissions, gasit {n}"
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_confirmare_n_gresit_arata_eroare(client):
|
||||
"""Confirmare cu N gresit → eroare clara, nu enqueue."""
|
||||
import_id = _setup_preview(client)
|
||||
client.get(f"/_import/{import_id}/preview") # calculeaza si stocheaza starea
|
||||
|
||||
r = client.post(
|
||||
f"/_import/{import_id}/confirma",
|
||||
data={"n_confirmat": "99", "confirmed_by": ""},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Trebuie sa arate eroare de confirmare sau preview cu eroare
|
||||
assert (
|
||||
"difera" in r.text
|
||||
or "Numarul confirmat" in r.text
|
||||
or "Niciun rand ok" in r.text
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reset #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_reset_arata_drop_zone_gol(client):
|
||||
"""GET /_import/reset → drop zone gol fara mesaje."""
|
||||
r = client.get("/_import/reset")
|
||||
assert r.status_code == 200
|
||||
assert "drop-zone" in r.text
|
||||
assert "Primul fisier" in r.text
|
||||
assert "import-section" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Erori upload #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_fisier_invalid_arata_eroare(client):
|
||||
"""Upload fisier invalid → mesaj de eroare in drop zone."""
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.xlsx", b"not a real xlsx file", "application/octet-stream")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Trebuie sa arate drop zone cu eroare
|
||||
assert "drop-zone" in r.text or "import-section" in r.text
|
||||
# Eroare vizibila
|
||||
assert "nerecunoscut" in r.text.lower() or "invalid" in r.text.lower() or "eroare" in r.text.lower()
|
||||
|
||||
|
||||
def test_upload_fisier_csv_antet_o_coloana_arata_eroare(client):
|
||||
"""CSV cu o singura coloana reala → header acceptat sau eroare gestionata."""
|
||||
bad = b"date_fara_header\nval1\nval2\n"
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.csv", bad, "text/csv")},
|
||||
)
|
||||
# Fie detecteaza header OK fie arata eroare
|
||||
assert r.status_code == 200
|
||||
assert "import-section" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Multi-sheet xlsx #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_multi_sheet_arata_selector(client):
|
||||
"""xlsx cu mai multe foi → selector de foaie in drop zone."""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wb = openpyxl.Workbook()
|
||||
ws1 = wb.active
|
||||
ws1.title = "Date"
|
||||
ws1.append(["VIN", "Data"])
|
||||
ws1.append(["WVW001", "15.06.2026"])
|
||||
ws2 = wb.create_sheet("Raport")
|
||||
ws2.append(["Total"])
|
||||
ws2.append(["1"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
xlsx = buf.getvalue()
|
||||
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("multi.xlsx", xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Trebuie sa arate selector de foi
|
||||
assert "foi" in r.text.lower() or "sheet" in r.text.lower() or "foaie" in r.text.lower()
|
||||
# Foile trebuie sa apara ca optiuni
|
||||
assert "Date" in r.text or "Raport" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Elemente a11y (D10/D11/D12) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_preview_contine_banner_declarant(client):
|
||||
"""Preview contine bannerul declarant (D12) cu text despre ireversibil."""
|
||||
import_id = _setup_preview(client)
|
||||
r = client.get(f"/_import/{import_id}/preview")
|
||||
assert r.status_code == 200
|
||||
assert "declarantul" in r.text
|
||||
assert "ireversibil" in r.text
|
||||
assert "banner" in r.text
|
||||
|
||||
|
||||
def test_preview_contine_checkboxuri_needs_review(client):
|
||||
"""Randurile needs_review au checkbox 'verificat' (D11)."""
|
||||
# Cream un rand cu VIN numeric → needs_review
|
||||
rows_with_review = [
|
||||
{
|
||||
"VIN": "1234567890", # VIN numeric → coercion flag → needs_review
|
||||
"Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026",
|
||||
"Odometru final": "123456",
|
||||
"Operatie": "OE-1",
|
||||
}
|
||||
]
|
||||
import_id = _upload_and_get_import_id(client, rows=rows_with_review)
|
||||
client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
r = client.get(f"/_import/{import_id}/preview")
|
||||
assert r.status_code == 200
|
||||
# Checkbox reviewed_rows prezent pentru randul needs_review
|
||||
assert "reviewed_rows" in r.text
|
||||
assert "needs_review" in r.text
|
||||
|
||||
|
||||
def test_preview_duplicate_in_file_are_text(client):
|
||||
"""Randurile duplicate_in_file arata text 'dubla cu randul N' (D10 — nu doar culoare)."""
|
||||
_seed_op_mapping(client, "Revizie", "OE-1")
|
||||
# Doua randuri identice → duplicate_in_file
|
||||
dup_rows = [
|
||||
{"VIN": "WVWZZZ1KZAW000123", "Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026", "Odometru final": "123456", "Operatie": "Revizie"},
|
||||
{"VIN": "WVWZZZ1KZAW000123", "Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026", "Odometru final": "123456", "Operatie": "Revizie"},
|
||||
]
|
||||
import_id = _upload_and_get_import_id(client, rows=dup_rows)
|
||||
client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
r = client.get(f"/_import/{import_id}/preview")
|
||||
assert r.status_code == 200
|
||||
# Text explicit pentru duplicate_in_file (nu doar culoare — cerinta daltonism D10)
|
||||
assert "dubla cu randul" in r.text
|
||||
assert "duplicate_in_file" in r.text
|
||||
Reference in New Issue
Block a user