feat(web): editare celule in preview + Acasa unificata (PRD 3.6)

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 <tr> + 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 <tr>+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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-19 10:52:17 +00:00
parent ead63245da
commit 6f6b163867
25 changed files with 2094 additions and 278 deletions

View File

@@ -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) #
# --------------------------------------------------------------------------- #