From 6f6b163867a7f61a9c74bb3edfa589ff378ba281 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Fri, 19 Jun 2026 10:52:17 +0000 Subject: [PATCH] feat(web): editare celule in preview + Acasa unificata (PRD 3.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementeaza PRD 3.6 (US-001..007), pe canalul de import + stratul web; worker / masina stari / idempotenta / mapare raman neatinse. - US-003/004: tab-ul "Trimiteri" eliminat; Trimiterile devin sectiune permanenta sub upload pe Acasa ("Trimiterile tale"); upload comprimat la bara slim (hero pastrat la first-run); ?tab=coada si /_fragments/coada servesc Acasa (fara fragment orfan); poll gated pe visibilityState. - US-001: coloana noua import_rows.override_json (nullable, Fernet, Approach B) + _migrate defensiv; ruta v1 + alias web .../rand/{i}/editeaza aplica patch canonic ULTIMUL in _resolve_row_for_preview si commit_import (mutatie pura, status rederivat, fara drift). Scoping JOIN -> 404, guard committed -> 409, semantica empty=clear, decrypt fail -> no-op. - US-002: buton "Editeaza" pe rand; swap pe + OOB contoare (nu pe sectiune); form propriu (confirm dezactivat la editare); refoloseste grila responsiva + error-map din _trimitere_detaliu.html; mutual-exclusion intre randuri. - US-005/006: "De rezolvat", "Operatii salvate" si "Formate de coloane" ca tabele (.tablewrap); H4: comutatorul reflecta auto_send STOCAT. - US-007: bifa "auto-send" devine comutator etichetat pe COADA ("Pune automat in coada" / "Tine pentru verificare"), scoped pe operatie; name="auto_send" pastrat (semantica de prezenta -> bool corect cu ambele parsere, zero backend). Fix-uri gasite la verificarea E2E in browser (htmx 1.9.12, JS — invizibile la TestClient): useTemplateFragments=true (raspuns +OOB era parsat in context de tabel -> swapError + contoare pierdute); re-activarea confirm-btn dupa salvare deferita pe tick (evita editing=true tranzitoriu); n-hint actualizat de updateN. Teste: 523 passed. E2E browser: Acasa unificata, upload slim, editare rand (needs_data -> ok, swap pe rand, contoare OOB), Mapari tabelar + comutator. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/import_router.py | 163 ++++++++++++- app/db.py | 11 + app/schema.sql | 1 + app/web/routes.py | 187 +++++++++++++-- app/web/templates/_acasa.html | 14 +- app/web/templates/_coada.html | 26 +- app/web/templates/_macros.html | 26 ++ app/web/templates/_mapari.html | 266 ++++++++++++--------- app/web/templates/_preview_import.html | 161 +++++-------- app/web/templates/_preview_rand.html | 168 +++++++++++++ app/web/templates/_upload.html | 39 ++- app/web/templates/base.html | 8 + app/web/templates/dashboard.html | 3 +- tests/fixtures/import_antet_necanonic.csv | 3 + tests/fixtures/import_lipsa_coloana.csv | 2 + tests/test_acasa_trimiteri.py | 131 +++++++++++ tests/test_autosend_toggle.py | 214 +++++++++++++++++ tests/test_formate_tabel.py | 136 +++++++++++ tests/test_import_edit_row.py | 275 ++++++++++++++++++++++ tests/test_mapari_tabel.py | 232 ++++++++++++++++++ tests/test_preview_edit_ui.py | 137 +++++++++++ tests/test_upload_slim.py | 100 ++++++++ tests/test_web_badge.py | 13 +- tests/test_web_submissions.py | 8 +- tests/test_web_tabs.py | 48 ++-- 25 files changed, 2094 insertions(+), 278 deletions(-) create mode 100644 app/web/templates/_macros.html create mode 100644 app/web/templates/_preview_rand.html create mode 100644 tests/fixtures/import_antet_necanonic.csv create mode 100644 tests/fixtures/import_lipsa_coloana.csv create mode 100644 tests/test_acasa_trimiteri.py create mode 100644 tests/test_autosend_toggle.py create mode 100644 tests/test_formate_tabel.py create mode 100644 tests/test_import_edit_row.py create mode 100644 tests/test_mapari_tabel.py create mode 100644 tests/test_preview_edit_ui.py create mode 100644 tests/test_upload_slim.py diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index e4182da..3ce1d68 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -124,6 +124,7 @@ def _resolve_row_for_preview( mapping: dict[str, str], mapping_meta: dict[str, dict], formula_columns: list[str], + override: dict[str, Any] | None = None, ) -> dict[str, Any]: """Rezolva un rand din import pentru preview: aplica mapare coloane + validare. @@ -132,6 +133,11 @@ def _resolve_row_for_preview( resolved: valorile finale rezolvate (VIN, data, km, prestatii) errors: lista erori validare flags: motive needs_review + + `override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL + peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea + unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara + sa atinga `raw_json`/idempotency. """ # Aplica maparea de coloane mapped: dict[str, Any] = {} @@ -182,6 +188,11 @@ def _resolve_row_for_preview( "odometru_final": canon["odometru_final"], }) + # Override editat in preview (3.6) — aplicat ULTIMUL, peste valorile mapate + + # canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override). + if override: + mapped.update(override) + # Flags needs_review acumulate all_flags = list(coercion_flags) + formula_flag if is_ambiguous_date: @@ -244,6 +255,86 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) -> return build_key(account_id, canon) +# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza +# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6. +EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final") + + +def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> dict[str, Any]: + """Aplica campurile editate peste override-ul curent (mutatie pura). + + Semantica: + - valoare None -> camp ne-trimis in cerere -> neschimbat. + - valoare "" -> STERGE cheia din override (revine la valoarea din fisier). + - valoare negoala -> set valoare CANONICA (vin/nr upper, odometru_final fara ".0"). + `odometru_initial`/`data_prestatie` se pastreaza stripped (canonicalize_row normeaza + doar `_final`; validarea le verifica direct). + """ + out = dict(current) + raw: dict[str, str] = {} + for camp in EDIT_FIELDS: + val = fields.get(camp) + if val is None: + continue + s = str(val).strip() + if s == "": + out.pop(camp, None) # empty = clear + else: + raw[camp] = s + if raw: + canon = canonicalize_row(raw) + for camp in raw: + if camp in ("vin", "nr_inmatriculare", "odometru_final"): + out[camp] = canon[camp] + else: + out[camp] = raw[camp] + return out + + +def apply_row_override( + conn, + *, + import_id: int, + account_id: int | None, + row_index: int, + fields: dict[str, str | None], +) -> dict[str, Any]: + """Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare). + + NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul + prin `_resolve_row_for_preview` (un singur clasificator, fara drift). + + Ridica HTTPException: 404 (rand/batch inexistent sau alt cont — scoping JOIN), + 409 (batch deja comis), 422 (override curent corupt -> no-op defensiv, fara scriere goala). + Intoarce noul dict de override (gol = override sters). + """ + acct = account_or_default(account_id) + # Scoping intr-o singura interogare JOIN -> 404 pe gol (alt cont / batch / row_index). + row = conn.execute( + "SELECT r.id AS rid, r.override_json AS oj, b.status AS bstatus " + "FROM import_rows r JOIN import_batches b ON b.id = r.batch_id " + "WHERE b.id=? AND b.account_id=? AND r.row_index=?", + (import_id, acct, row_index), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="rand de import inexistent") + if row["bstatus"] == "committed": + raise HTTPException(status_code=409, detail="batch deja comis; editarea nu mai are efect") + + current: dict[str, Any] = {} + if row["oj"]: + dec = decrypt_creds(row["oj"]) + if dec is None: + # Decrypt fail (cheie schimbata / token corupt): no-op defensiv, NICIODATA scriere goala. + raise HTTPException(status_code=422, detail="override curent ilizibil; editare anulata") + current = dec + + new_override = _merge_override(current, fields) + enc = encrypt_creds(new_override) if new_override else None + conn.execute("UPDATE import_rows SET override_json=? WHERE id=?", (enc, row["rid"])) + return new_override + + def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]: """Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5). @@ -589,21 +680,24 @@ def preview_import( # Incarca toate randurile raw_rows_db = conn.execute( - "SELECT row_index, raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index", + "SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index", (import_id,), ).fetchall() if not raw_rows_db: return {"rows": [], "summary": {}} - # Decripteaza si reconstruieste randurile + # Decripteaza si reconstruieste randurile + override-urile editate (3.6) rows: list[dict] = [] + overrides: list[dict] = [] for r in raw_rows_db: try: row_data = decrypt_creds(r["raw_json"]) rows.append(row_data or {}) except Exception: rows.append({}) + ov = decrypt_creds(r["override_json"]) if r["override_json"] else None + overrides.append(ov or {}) # Obtine coloanele col_names = list(rows[0].keys()) if rows else [] @@ -681,6 +775,7 @@ def preview_import( mapping=mapping, mapping_meta=mapping_meta, formula_columns=formula_columns, + override=overrides[i] or None, ) # Calculeaza cheia de idempotenta pentru randurile ok/needs_review @@ -824,7 +919,7 @@ def commit_import( # Incarca randurile cu stare ok sau needs_review ok_rows_db = conn.execute( - "SELECT row_index, raw_json, resolved_status FROM import_rows " + "SELECT row_index, raw_json, override_json, resolved_status FROM import_rows " "WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index", (import_id,), ).fetchall() @@ -832,6 +927,9 @@ def commit_import( if not ok_rows_db: raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat in acest batch.") + def _override_of(r) -> dict: + return (decrypt_creds(r["override_json"]) if r["override_json"] else None) or {} + # Decripteaza randurile ok ok_rows: list[dict] = [] ok_indices: list[int] = [] @@ -846,7 +944,8 @@ def commit_import( continue if r["resolved_status"] == "ok": - ok_rows.append({"row_index": r["row_index"], "data": row_data, "status": "ok"}) + ok_rows.append({"row_index": r["row_index"], "data": row_data, + "override": _override_of(r), "status": "ok"}) ok_indices.append(r["row_index"]) elif r["resolved_status"] == "needs_review": review_indices.add(r["row_index"]) @@ -860,7 +959,8 @@ def commit_import( try: row_data = decrypt_creds(r["raw_json"]) if row_data: - ok_rows.append({"row_index": idx, "data": row_data, "status": "needs_review"}) + ok_rows.append({"row_index": idx, "data": row_data, + "override": _override_of(r), "status": "needs_review"}) ok_indices.append(idx) except Exception: pass @@ -964,6 +1064,19 @@ def commit_import( "odometru_final": canon["odometru_final"], }) + # Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver. + override = ok_row.get("override") or {} + if override: + mapped.update(override) + # Re-canonicalizeaza pentru a obtine cheia IDENTICA cu cea din preview + # (_build_idempotency_key = canonicalize_row + build_key peste mapped). + canon = canonicalize_row(mapped) + mapped.update({ + "vin": canon["vin"], + "nr_inmatriculare": canon["nr_inmatriculare"], + "odometru_final": canon["odometru_final"], + }) + # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine) key = build_key(account_id, canon) @@ -1033,6 +1146,46 @@ def commit_import( conn.close() +# --------------------------------------------------------------------------- # +# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview (3.6) # +# --------------------------------------------------------------------------- # + +class RandEditIn(BaseModel): + """Campuri de continut editabile in preview. None = ne-trimis (neschimbat); + "" = sterge override-ul (revine la valoarea din fisier).""" + vin: str | None = None + nr_inmatriculare: str | None = None + data_prestatie: str | None = None + odometru_initial: str | None = None + odometru_final: str | None = None + + +@router.post("/{import_id}/rand/{row_index}/editeaza") +def editeaza_rand( + import_id: int, + row_index: int, + req: RandEditIn, + account_id: int = Depends(resolve_account_id), +) -> dict: + """Persista editarea unui rand de preview (mutatie pura — Approach B, 3.6). + + NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul + prin `_resolve_row_for_preview` cu override aplicat ultimul. + """ + conn = get_connection() + try: + override = apply_row_override( + conn, + import_id=import_id, + account_id=account_id, + row_index=row_index, + fields=req.model_dump(), + ) + return {"ok": True, "import_id": import_id, "row_index": row_index, "override": override} + finally: + conn.close() + + # --------------------------------------------------------------------------- # # GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) # # --------------------------------------------------------------------------- # diff --git a/app/db.py b/app/db.py index 08a76f3..1bce0f8 100644 --- a/app/db.py +++ b/app/db.py @@ -79,6 +79,17 @@ def _migrate(conn: sqlite3.Connection) -> None: if "email_verified" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0") + # Coloana import_rows.override_json (3.6, Approach B): patch canonic editat in + # preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create + # inainte de 3.6 nu au coloana. + irows_tbl = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'" + ).fetchone() + if irows_tbl: + irows_cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()} + if "override_json" not in irows_cols: + conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT") + # Index batch_id pe submissions (poate lipsi pe DB veche) existing_idx = {r["name"] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'" diff --git a/app/schema.sql b/app/schema.sql index 5868b74..1fb5e60 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS import_rows ( batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, row_index INTEGER NOT NULL, raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions) + override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet (3.6, Approach B); NULL = fara editare resolved_status TEXT NOT NULL DEFAULT 'pending' CHECK (resolved_status IN ( 'pending','ok','needs_mapping','needs_data', diff --git a/app/web/routes.py b/app/web/routes.py index 8e1f640..92e4151 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -42,6 +42,8 @@ from ..api.v1.import_router import ( _fuzzy_suggest_column, _resolve_row_for_preview, _signature, + apply_row_override, + EDIT_FIELDS, ) from ..config import get_settings from ..crypto import decrypt_creds, encrypt_creds @@ -130,7 +132,9 @@ def _rar_state(hb, worker_alive: bool) -> str: # US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import # cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404. -_TABS_VALIDE = {"acasa", "coada", "mapari", "cont", "nomenclator"} +# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa. +# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan. +_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator"} def _get_acasa_context(request: Request, conn, account_id: int) -> dict: @@ -162,11 +166,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: ).fetchone() are_cheie_folosita = row_key is not None + # US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul + # sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut. + counts = _status_counts(conn, account_id) + blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) + return { "request": request, "are_creds": are_creds, "are_trimiteri": are_trimiteri, "are_cheie_folosita": are_cheie_folosita, + "blocate_total": blocate_total, # US-002: Acasa include caseta de upload -> are nevoie de csrf_token "csrf_token": get_csrf_token(request), } @@ -190,9 +200,10 @@ def _render_panel_import(request: Request) -> str: }) -def _render_panel_coada(request: Request) -> str: - """Randeaza panoul Coada ca string HTML.""" - return templates.get_template("_coada.html").render({"request": request}) +def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str: + """US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa + (Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi.""" + return _render_panel_acasa(request, conn, account_id) def _render_panel_mapari(request: Request, conn, account_id: int) -> str: @@ -243,7 +254,7 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> if tab == "import": return _render_panel_import(request) if tab == "coada": - return _render_panel_coada(request) + return _render_panel_coada(request, conn, account_id) if tab == "mapari": return _render_panel_mapari(request, conn, account_id) if tab == "cont": @@ -266,11 +277,11 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse: conn = get_connection() try: panel_html = _render_panel_for_tab(request, conn, account_id, active_tab) - # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari, blocate -> Trimiteri. + # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele + # (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003). counts = _status_counts(conn, account_id) badges = { "mapari": counts.get("needs_mapping", 0), - "coada": sum(counts.get(s, 0) for s in _BLOCKED), } ctx = { "request": request, @@ -308,9 +319,16 @@ def fragment_import(request: Request) -> HTMLResponse: @router.get("/_fragments/coada", response_class=HTMLResponse) def fragment_coada(request: Request) -> HTMLResponse: - """Fragment HTMX pentru tab-ul Coada — include coada submissions (US-003).""" - require_login(request) - return templates.TemplateResponse("_coada.html", {"request": request}) + """US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa + (Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html` + orfan din bookmark-uri/HTMX vechi. Nu da 404.""" + account_id = require_login(request) + conn = get_connection() + try: + ctx = _get_acasa_context(request, conn, account_id) + return templates.TemplateResponse("_acasa.html", ctx) + finally: + conn.close() @router.get("/_fragments/nomenclator", response_class=HTMLResponse) @@ -1025,20 +1043,23 @@ def _web_compute_preview( 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", + "SELECT row_index, raw_json, override_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 + # Decripteaza randurile + override-urile editate (3.6) rows: list[dict[str, Any]] = [] + overrides: 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) + ov = decrypt_creds(r["override_json"]) if r["override_json"] else None + overrides.append(ov or {}) col_names = list(rows[0].keys()) if rows else [] sig = _signature(col_names) @@ -1098,6 +1119,7 @@ def _web_compute_preview( mapping=mapping, mapping_meta=mapping_meta, formula_columns=formula_columns, + override=overrides[i] or None, ) key: str | None = None @@ -1409,6 +1431,118 @@ def web_preview_import( conn.close() +# =========================================================================== # +# US-002 (3.6) — Editare celule in preview: mod editare pe rand. # +# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). # +# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). # +# =========================================================================== # + +def _preview_one_row(conn, import_id: int, account_id: int, row_index: int): + """Recalculeaza preview-ul si extrage un singur rand. + + Statusul e rederivat prin `_resolve_row_for_preview` (H2, fara clasificator duplicat), + iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel + confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None).""" + result = _web_compute_preview(conn, import_id, account_id) + if isinstance(result, str): + return result, None + row = next((r for r in result["rows"] if r["row_index"] == row_index), None) + return result, row + + +def _render_preview_rand( + request: Request, *, import_id: int, row: dict, editing: bool, + include_oob: bool, summary: dict, message: str | None = None, +) -> HTMLResponse: + return templates.TemplateResponse("_preview_rand.html", { + "request": request, + "import_id": import_id, + "row": row, + "editing": editing, + "include_oob": include_oob, + "summary": summary, + "message": message, + "csrf_token": get_csrf_token(request), + }) + + +@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse) +def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse: + """Intra in mod editare pe un rand de preview (randul devine FORM propriu, D-3.3).""" + account_id = require_login(request) + conn = get_connection() + try: + result, row = _preview_one_row(conn, import_id, account_id, row_index) + if row is None or isinstance(result, str): + raise HTTPException(status_code=404, detail="rand de import inexistent") + return _render_preview_rand( + request, import_id=import_id, row=row, editing=True, + include_oob=False, summary=result["summary"], + ) + finally: + conn.close() + + +@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse) +def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse: + """Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare.""" + account_id = require_login(request) + conn = get_connection() + try: + result, row = _preview_one_row(conn, import_id, account_id, row_index) + if row is None or isinstance(result, str): + raise HTTPException(status_code=404, detail="rand de import inexistent") + return _render_preview_rand( + request, import_id=import_id, row=row, editing=False, + include_oob=True, summary=result["summary"], + ) + finally: + conn.close() + + +@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse) +async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: + """Alias web (US-001/US-002): persista override (mutatie pura) + re-randeaza DOAR randul. + + Statusul e rederivat prin `_resolve_row_for_preview` (H2). Swap pe rand + OOB + contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare + cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2).""" + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + fields: dict[str, str | None] = { + camp: (str(form.get(camp)) if form.get(camp) is not None else None) + for camp in EDIT_FIELDS + } + conn = get_connection() + try: + # Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error + # pastreaza randul + valorile la 4xx/5xx). + apply_row_override( + conn, import_id=import_id, account_id=account_id, + row_index=row_index, fields=fields, + ) + result, row = _preview_one_row(conn, import_id, account_id, row_index) + if row is None or isinstance(result, str): + raise HTTPException(status_code=404, detail="rand de import inexistent") + field_errors = [ + e for e in (row.get("errors") or []) + if isinstance(e, dict) and e.get("field") + ] + if field_errors: + return _render_preview_rand( + request, import_id=import_id, row=row, editing=True, + include_oob=True, summary=result["summary"], + message="Mai sunt valori invalide — corecteaza campurile marcate.", + ) + return _render_preview_rand( + request, import_id=import_id, row=row, editing=False, + include_oob=True, summary=result["summary"], + ) + finally: + conn.close() + + @router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse) async def web_mapare_operatie( request: Request, @@ -1514,11 +1648,14 @@ async def web_confirma_import( # Incarca randurile cu stare ok si needs_review ok_rows_db = conn.execute( - "SELECT row_index, raw_json, resolved_status FROM import_rows " + "SELECT row_index, raw_json, override_json, resolved_status FROM import_rows " "WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index", (import_id,), ).fetchall() + def _override_of(r) -> dict: + return (decrypt_creds(r["override_json"]) if r["override_json"] else None) or {} + if not ok_rows_db: # Re-arata preview cu eroare result = _web_compute_preview(conn, import_id, account_id) @@ -1542,7 +1679,8 @@ async def web_confirma_import( except Exception: continue if r["resolved_status"] == "ok": - to_enqueue.append({"row_index": r["row_index"], "data": row_data, "status": "ok"}) + to_enqueue.append({"row_index": r["row_index"], "data": row_data, + "override": _override_of(r), "status": "ok"}) elif r["resolved_status"] == "needs_review": review_indices.add(r["row_index"]) @@ -1551,7 +1689,8 @@ async def web_confirma_import( 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"}) + to_enqueue.append({"row_index": r["row_index"], "data": row_data, + "override": _override_of(r), "status": "needs_review"}) except Exception: pass @@ -1656,6 +1795,17 @@ async def web_confirma_import( "odometru_final": canon["odometru_final"], }) + # Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver. + override = item.get("override") or {} + if override: + mapped.update(override) + 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({ @@ -1702,13 +1852,16 @@ async def web_confirma_import( (n_enqueued, import_id), ) - # Succes → drop zone cu mesaj de confirmare + # Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True: + # contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie + # sectiunea "Trimiterile tale" de pe Acasa (US-003/US-004). toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" return templates.TemplateResponse("_upload.html", _ctx( request, + are_trimiteri=True, message=( f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. " - f"Procesarea incepe in cateva secunde — urmareste coada de mai jos." + f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale." ), )) diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index 6c47829..2c9b700 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -1,6 +1,6 @@
- {# === Centru de greutate: caseta de upload (importul e operatia principala) === #} + {# === Centru de greutate: bara de upload (importul e operatia principala) === #} {% include '_upload.html' %} {# === Subordonat: primii pasi pe un singur rand compact === #} @@ -44,13 +44,21 @@
{% endif %} - {# === Subordonat: ajutor rapid pe un rand discret === #} + {# === Subordonat: ajutor rapid pe un rand discret === + US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos + pe aceeasi pagina). Wayfinding "Mapari"/"Coduri RAR" pastrat pentru operatori. #}
Ajutor: - Trimiteri Mapari Coduri RAR
+ {# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003). + Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul, + iar empty-state-ul tabelului ar fi redundant (US-004 / D-5.1). === #} + {% if are_trimiteri %} + {% include '_coada.html' %} + {% endif %} + diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index ad7a93f..8c3627f 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -1,7 +1,20 @@ -
+{# + _coada.html — repurposat in 3.6 (US-003). + Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa, + sub zona de upload. Pastreaza filtrele (US-009), tabelul (_submissions.html) si + panoul de detaliu (#trimitere-detaliu). Poll aliniat la 15s (anti dublu-poll, M5). +#} +
-

Trimiteri catre RAR

+

+ Trimiterile tale + {% if blocate_total %} + {{ blocate_total }} + {% endif %} +

export CSV: trimise toate @@ -42,14 +55,15 @@ +
se incarca…
- +
-
+
diff --git a/app/web/templates/_macros.html b/app/web/templates/_macros.html new file mode 100644 index 0000000..dca404f --- /dev/null +++ b/app/web/templates/_macros.html @@ -0,0 +1,26 @@ +{# Macro-uri partajate intre template-urile de import si mapari. #} + +{# US-007: comutator pe COADA in loc de bifa "auto-send". + Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele + poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu + semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca + bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get()) + la /_import/.../mapare-operatie). Zero atingere backend. + - form_id: leaga input-ul de un
extern (necesar in celulele de tabel). + - checked: starea initiala (H4 - reflecta valoarea STOCATA per mapare). #} +{% macro autosend_toggle(form_id='', checked=True) -%} +
+ La fisierele viitoare cu aceasta operatie: + + + Nebifat = "Tine pentru verificare". Doar pentru aceasta operatie; + nimic nu pleaca la RAR pana confirmi. + +
+{%- endmacro %} diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html index 8e59f5f..8a8ac6e 100644 --- a/app/web/templates/_mapari.html +++ b/app/web/templates/_mapari.html @@ -1,3 +1,4 @@ +{% import '_macros.html' as ui %}
{% if message %} @@ -21,47 +22,58 @@ Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.

- {% for e in pending %} - {% set top = e.suggestions[0] if e.suggestions else None %} - {% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %} - - - - -
-
{{ e.cod_op_service }} - {{ e.blocked }} blocate
-
{{ e.denumire or '(fara denumire)' }}
- {% if e.suggestions %} -
- sugestii: - {% for s in e.suggestions[:3] %} - {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} - {% endfor %} -
- {% endif %} -
- -
- -
- -
- -
- -
- -
- - {% endfor %} +
+ + + + + + + + + + {% for e in pending %} + {% set top = e.suggestions[0] if e.suggestions else None %} + {% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %} + + + + + + + + {% endfor %} + +
OperatieSugestiiCod RARPunere in coada
+
+ + +
+
{{ e.cod_op_service }} + {{ e.blocked }} blocate
+
{{ e.denumire or '(fara denumire)' }}
+
+ {% if e.suggestions %} + {% for s in e.suggestions[:3] %} + {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} + {% endfor %} + {% else %}—{% endif %} + + + + {{ ui.autosend_toggle(form_id="map-rez-" ~ loop.index, checked=True) }} + + +
+
{% endif %}
@@ -77,50 +89,61 @@
{% else %}

- Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau auto-send si salveaza; + Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau punerea in coada si salveaza; la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.

- {% for m in saved_mappings %} -
- - - -
-
{{ m.cod_op_service }}
-
- acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %} -
-
- -
- -
- -
- -
- -
- -
-
- -
-
- {% endfor %} +
+ + + + + + + + + {% for m in saved_mappings %} + + + + + + + {% endfor %} + +
OperatieCod RARPunere in coadaActiuni
+
+ + +
+
+ + +
+
{{ m.cod_op_service }}
+
+ acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %} +
+
+ + + {{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }} + + + +
+
{% endif %} @@ -140,42 +163,51 @@ Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie).

- {% for f in column_formats %} -
-
-
- {{ f.columns | length }} coloane recunoscute - {% if f.format_data %} - data: {{ f.format_data }} - {% endif %} -
-
- {% for col, camp in f.mappings.items() %} - {{ col }} → {{ camp }}{% if not loop.last %}; {% endif %} - {% endfor %} -
-
- -
- - - - -
- -
- - - -
+
+ + + + + + + + + {% for f in column_formats %} + + + + + + + {% endfor %} + +
ColoaneMapari (coloana → camp)Format data
+ {{ f.columns | length }} coloane + + {% for col, camp in f.mappings.items() %} + {{ col }} → {{ camp }}{% if not loop.last %}; {% endif %} + {% endfor %} + +
+ + + + +
+
+
+ + + +
+
- {% endfor %} {% endif %}
diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index 788bd9e..a98b498 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -1,3 +1,4 @@ +{% import '_macros.html' as ui %}
{% set pas = 3 %}{% include '_stepper.html' %}
@@ -16,16 +17,16 @@
{% 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'), - ] %} + + {% 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 %} @@ -96,7 +97,7 @@
- + {{ ui.autosend_toggle(checked=True) }}
@@ -106,85 +107,39 @@
{% endif %} - + +
+ + + + + + + + + + + + + + + + + {% for row in rows %} + {% include '_preview_rand.html' %} + {% endfor %} + +
#VINNr. Inm.DataKM finalOperatieStareNoteVerificat?Actiuni
+
+ +
- -
- - - - - - - - - - - - - - - - {% 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 %} - {# US-008: arata MOTIVUL (mesajul de validare), nu numele campului #} - {%- for e in row.errors -%} - {%- if e is mapping -%} - {{ e.get('message') or e.get('msg') or (e.values() | list | first) }} - {%- else -%} - {{ e }} - {%- endif -%} - {%- if not loop.last %}; {% endif -%} - {%- endfor -%} - {% endif %} - - {% if status == 'needs_review' %} - - {% endif %} -
-
- - -
+ + +
Incarca alt fisier @@ -254,18 +212,32 @@ diff --git a/app/web/templates/_preview_rand.html b/app/web/templates/_preview_rand.html new file mode 100644 index 0000000..00e1e7f --- /dev/null +++ b/app/web/templates/_preview_rand.html @@ -0,0 +1,168 @@ +{# + _preview_rand.html — un singur rand de preview import (US-002, 3.6). + Doua moduri: + - display (editing falsy): normal + buton "Editeaza" pe coloana de actiuni. + - edit (editing truthy): cu un singur ce contine un FORM PROPRIU + (NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html. + Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section (D-3.1). + La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob). +#} +{%- 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 '' -%} +{% if editing %} +{%- set err_map = {} -%} +{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- endif -%}{%- endfor -%} + + +
+ + +
+ Editare rand {{ row.row_index + 1 }} + {{ status }} +
+ + {% if message %} + + {% endif %} + + + {% macro camp(nume, eticheta, valoare, tip='text') %} +
+ + + {% if err_map.get(nume) %} +
{{ err_map.get(nume) }}
+ {% endif %} +
+ {% endmacro %} + +
+ {{ camp('nr_inmatriculare', 'Numar inmatriculare', res.get('nr_inmatriculare')) }} + {{ camp('vin', 'VIN (serie sasiu)', res.get('vin')) }} + {{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', res.get('data_prestatie')) }} + {{ camp('odometru_final', 'Odometru final', res.get('odometru_final')) }} + {{ camp('odometru_initial', 'Odometru initial (daca e cerut)', res.get('odometru_initial')) }} +
+ +
+ + + + se salveaza… + +
+
+ + + +{% else %} + + {{ 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 %} + {%- for e in row.errors -%} + {%- if e is mapping -%} + {{ e.get('message') or e.get('msg') or (e.values() | list | first) }} + {%- else -%} + {{ e }} + {%- endif -%} + {%- if not loop.last %}; {% endif -%} + {%- endfor -%} + {% endif %} + + + {% if status == 'needs_review' %} + + {% endif %} + + + {% if status not in ('already_sent', 'duplicate_in_file') %} + + {% endif %} + + +{% if include_oob %} +{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea (D-3.1). #} +{% 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 %} +
+ + +{% endif %} +{% endif %} diff --git a/app/web/templates/_upload.html b/app/web/templates/_upload.html index 6d13801..182e69b 100644 --- a/app/web/templates/_upload.html +++ b/app/web/templates/_upload.html @@ -1,7 +1,8 @@
{% set pas = 1 %}{% include '_stepper.html' %} -
-

Import fisier (xlsx / csv)

+ {# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul + de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #} +
{% if message %}
{{ message }}
@@ -40,6 +41,26 @@
{% endif %} + {% if are_trimiteri and not sheets %} + {# === Bara slim (returning user): eticheta + buton + zona de trage, pe un rand === #} +
+ Importa: + + + sau trage aici + + NU se trimite nimic la RAR pana confirmi. + +
+ {% else %} + {# === Hero first-run (sau re-upload multi-foaie): pastreaza copy-ul de bun venit === #}
{% if not sheets %} @@ -55,13 +76,14 @@ style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">

NU se trimite nimic la RAR pana confirmi explicit.

+ {% endif %} @@ -77,6 +99,17 @@ var fi = document.getElementById('file-input'); var dz = document.getElementById('drop-zone'); var frm = document.getElementById('upload-form'); + + /* US-003 (3.6): un singur sticky bar pe ecran — cand re-apare zona de upload + (reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */ + var trim = document.getElementById('trimiteri-section'); + if (trim) trim.style.display = ''; + + /* Dupa un commit reusit (mesaj de succes), du utilizatorul la Trimiteri. */ + {% if message and not error %} + if (trim) trim.scrollIntoView({behavior: 'smooth', block: 'start'}); + {% endif %} + if (!btn || !fi || !frm) return; btn.addEventListener('click', function() { fi.click(); }); diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 3ae9c33..8ad7870 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -5,6 +5,14 @@ {% block title %}Gateway RAR AUTOPASS{% endblock %} +