diff --git a/app/web/routes.py b/app/web/routes.py index a490376..b3ea45b 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -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() diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html new file mode 100644 index 0000000..ca41653 --- /dev/null +++ b/app/web/templates/_mapcoloane.html @@ -0,0 +1,96 @@ +
+
+

+ Mapare coloane — + {{ filename or ("import #" ~ import_id) }} +

+ + {% if message %} +
+ {{ message }} +
+ {% endif %} + +

+ Asociaza fiecare coloana din fisier cu campul canonic corespunzator. + Maparea se retine automat pentru fisiere cu acelasi antet. +

+ +
+ +
+ + + + sau YYYY-MM-DD, MM/DD/YYYY etc. + +
+ + {% for col in columns %} + {%- set sugg = fuzzy_suggestions.get(col, []) -%} + {%- set best = sugg[0].camp_canonic if sugg else '' -%} + +
+
+
{{ col }}
+ {% if sugg %} +
+ sugestie: {{ sugg[0].camp_canonic }} + ({{ sugg[0].score | round | int }}%) +
+ {% 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 %} +
+ ex: {{ ns.samples[:2] | join(", ") }} +
+ {% endif %} +
+
+ + +
+
+ {% endfor %} + +
+ + + maparea se retine pentru fisiere cu acelasi antet + +
+
+ +
+ Incarca alt fisier +
+
+
diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html new file mode 100644 index 0000000..4cf8369 --- /dev/null +++ b/app/web/templates/_preview_import.html @@ -0,0 +1,236 @@ +
+
+
+

+ Preview — + {{ filename or ("import #" ~ import_id) }} +

+ {{ total }} randuri +
+ + {% if message %} +
+ {{ message }} +
+ {% endif %} + + +
+ {% 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 %} + {{ cnt }} {{ label }} + {% endif %} + {% endfor %} +
+ + +
+ + {% for status_key, label in status_labels %} + {%- set cnt = summary.get(status_key, 0) -%} + {% if cnt > 0 %} + + {% endif %} + {% endfor %} +
+ + +
+ +
+ + + + + + + + + + + + + + + + {% 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 '' -%} + + + + + + + + + + + + {% endfor %} + +
#VINNr. Inm.DataKM finalOperatieStareNoteVerificat?
{{ row.row_index + 1 }}{{ res.get('vin') or '' | safe }}{{ res.get('nr_inmatriculare') or '' }}{{ res.get('data_prestatie') or '' }}{{ res.get('odometru_final') or '' }}{{ op or '' | safe }} + {{ status }} + + {% 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 %} + + {% if status == 'needs_review' %} + + {% endif %} +
+
+ + + + +
+ +
+ Incarca alt fisier +
+ +
+
+ + diff --git a/app/web/templates/_upload.html b/app/web/templates/_upload.html new file mode 100644 index 0000000..0e209b4 --- /dev/null +++ b/app/web/templates/_upload.html @@ -0,0 +1,106 @@ +
+
+

Import fisier (xlsx / csv)

+ + {% if message %} +
{{ message }}
+ {% endif %} + + {% if error %} + + {% endif %} + + {% if sheets %} +
+ Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou. +
+ {% endif %} + +
+ + {% if sheets %} +
+ + +
+ {% endif %} + +
+ {% if not sheets %} +

Primul fisier? Trage-l aici.

+

xlsx sau csv, max 5000 randuri

+ {% else %} +

+ Incarca fisierul din nou dupa ce ai ales foaia. +

+ {% endif %} + + + +
+ +

+ NU se trimite nimic la RAR pana confirmi explicit. +

+ + + se parseaza fisierul... + +
+
+
+ + diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 176f983..a4d2d7a 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -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; diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index e273d62..0489ef8 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -1,6 +1,9 @@ {% extends "base.html" %} {% block content %} + +{% include '_upload.html' %} +