diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index b1f1bd6..42a6f4e 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -266,8 +266,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar return build_key(account_id, canon, rar_env) -# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza -# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic +# Campuri de continut editabile in preview. Prestatiile (chips operatie<->cod RAR) +# se editeaza tot in modal, dar se persista separat (param `prestatii` din +# apply_row_override), nu prin EDIT_FIELDS. obs = text liber, se trateaza ca non-canonic # (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override. EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs") @@ -318,9 +319,13 @@ def apply_row_override( account_id: int | None, row_index: int, fields: dict[str, str | None], + prestatii: list[dict[str, str]] | None = None, ) -> dict[str, Any]: """Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare). + `prestatii`: None = neatins; lista = inlocuieste integral prestatiile randului + (chips editate in modal, deja validate fata de nomenclator de apelant). + NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul prin `_resolve_row_for_preview` (un singur clasificator, fara drift). @@ -358,6 +363,8 @@ def apply_row_override( current = dec new_override = _merge_override(current, fields) + if prestatii is not None: + new_override["prestatii"] = prestatii enc = encrypt_creds(new_override) if new_override else None # Resetam reviewed=0 la orice schimbare de valoare — operatorul # trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override @@ -1167,12 +1174,21 @@ def commit_import( obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) mapped["obs"] = obs_denumire + # Override editat in preview — aplicat INAINTE de resolve_prestatii, + # ca in _resolve_row_for_preview: prestatiile din override (chips + # editate in modal) trec prin ACELASI resolve, altfel raman raw si + # hash-ul/telemetria ar folosi prestatiile pre-editare. + override = ok_row.get("override") or {} + if override: + mapped.update(override) + # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) prestatii = mapped.get("prestatii") or [] resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules) mapped["prestatii"] = resolved - # Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie) + # Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie; + # cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key). canon = canonicalize_row(mapped) mapped.update({ "vin": canon["vin"], @@ -1180,19 +1196,6 @@ def commit_import( "odometru_final": canon["odometru_final"], }) - # Override editat in preview — 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 + env) key = build_key(account_id, canon, env) diff --git a/app/web/routes.py b/app/web/routes.py index b081d71..caa06b5 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -3806,6 +3806,51 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe conn.close() +def _chips_state_from_form(form) -> list[dict[str, str]] | None: + """Reconstruieste starea chips prestatii din inputurile paralele ale formularului. + + Aceeasi conventie ca /form-chips (_chips_prestatii.html): 3 liste paralele + cod_prestatie / chip_op_service / chip_denumire. Codurile alese in pickere dar + ne-aprobate prin '+' (chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit, + ca in post_corectie_trimitere. Dedup pe perechea (op, cod). + + Intoarce None cand formularul nu contine deloc stare de chips (form fara + sectiunea de prestatii) — apelantul lasa prestatiile neatinse. + """ + flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper() + if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker: + return None + cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")] + op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")] + den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")] + n = max(len(cod_list), len(op_list), len(den_list)) + chips: list[dict[str, str]] = [] + for i in range(n): + cod = cod_list[i] if i < len(cod_list) else "" + if not cod: + # picker per-operatie cu cod ales dar ne-aprobat prin '+ Adauga' + cod = str(form.get(f"chips_add_cod_{i}") or "").strip().upper() + chips.append({ + "cod_prestatie": cod, + "cod_op_service": op_list[i] if i < len(op_list) else "", + "denumire": den_list[i] if i < len(den_list) else "", + }) + pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips} + if flat_picker and ("", flat_picker) not in pairs: + chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": ""}) + seen: set = set() + out: list[dict[str, str]] = [] + for c in chips: + if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]): + continue + pair = (c["cod_op_service"], c["cod_prestatie"]) + if pair in seen: + continue + seen.add(pair) + out.append(c) + return out + + @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: """Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal. @@ -3825,13 +3870,50 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> camp: (str(form.get(camp)) if form.get(camp) is not None else None) for camp in EDIT_FIELDS } + # Chips prestatii din modal: None = form fara sectiune de chips (neatins). + chips_state = _chips_state_from_form(form) conn = get_connection() try: + # Invariant ORA-12899: orice cod trimis din form se valideaza fata de + # nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide). + if chips_state is not None: + valid_codes = load_nomenclator_codes(conn) or set() + necunoscute = sorted( + {c["cod_prestatie"] for c in chips_state if c["cod_prestatie"]} - valid_codes + ) + if necunoscute: + _nom = load_nomenclator(conn) + return templates.TemplateResponse("_editare_preview_modal.html", { + "request": request, + "import_id": import_id, + "row_index": row_index, + "csrf_token": get_csrf_token(request), + "vin": str(form.get("vin") or ""), + "stare_css": "", + "stare_eticheta": "", + "form_nr": str(form.get("nr_inmatriculare") or ""), + "form_vin": str(form.get("vin") or ""), + "form_data": str(form.get("data_prestatie") or ""), + "form_odo_final": str(form.get("odometru_final") or ""), + "form_odo_initial": str(form.get("odometru_initial") or ""), + "err_map": {}, + "fix_map": {}, + "vin_context": str(form.get("vin") or ""), + "btn_label": "Salveaza", + "message": f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. " + "Alege un cod valid din lista.", + "prestatii_chips": chips_state, + "has_r_odo": _has_r_odo_chips(chips_state), + "obs_val": str(form.get("obs") or "").strip(), + "nomenclator_rar": _nom, + "form_chips_url": "/form-chips", + }) + # Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error # pastreaza formularul modal cu valorile la 4xx/5xx). apply_row_override( conn, import_id=import_id, account_id=account_id, - row_index=row_index, fields=fields, + row_index=row_index, fields=fields, prestatii=chips_state, ) result, row = _preview_one_row(conn, import_id, account_id, row_index) if row is None or isinstance(result, str): @@ -4377,12 +4459,19 @@ async def web_confirma_import( denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] + # Override editat in preview — aplicat INAINTE de resolve_prestatii, + # ca in _resolve_row_for_preview: prestatiile din override (chips + # editate in modal) trec prin ACELASI resolve. + override = item.get("override") or {} + if override: + mapped.update(override) + # Rezolva prestatii prestatii = mapped.get("prestatii") or [] resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules) mapped["prestatii"] = resolved_p - # Canonicalizare + # Canonicalizare (cheia identica cu preview: canonicalize_row + build_key) canon = canonicalize_row(mapped) mapped.update({ "vin": canon["vin"], @@ -4390,17 +4479,6 @@ async def web_confirma_import( "odometru_final": canon["odometru_final"], }) - # Override editat in preview — 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, env) rows_for_hash.append(json.dumps({ diff --git a/tests/test_web_preview_edit.py b/tests/test_web_preview_edit.py index ae5beb7..cc59d92 100644 --- a/tests/test_web_preview_edit.py +++ b/tests/test_web_preview_edit.py @@ -538,3 +538,155 @@ def test_base_html_deschide_modal_pentru_btn_editeaza(): ".btn-editeaza (sau sa verifice hx-target='#detaliu-modal-body') pentru a " "apela open() si instala focus-trap-ul." ) + + +# --------------------------------------------------------------------------- # +# Persistarea chips-urilor prestatii la salvarea din modal # +# --------------------------------------------------------------------------- # + +_ROWS_ITP = [ + { + "VIN": "WVWZZZ3CZ9E123456", + "Nr": "TM789BC", + "Data": "2026-05-15", + "KM": "82500", + "Operatie": "OP-ITP", + }, +] + + +def _seed_nomenclator_extra() -> None: + """Nomenclator cu mai multe coduri pentru testele de chips (fara mapare OP-ITP).""" + from app.db import get_connection + conn = get_connection() + try: + for cod, nume in (("R-FRANE", "Reparatie frane"), ("OE-2", "Verificare"), ("OE-3", "Reglaj")): + conn.execute( + "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", + (cod, nume), + ) + conn.commit() + finally: + conn.close() + + +def _preview_row0(client: TestClient, iid: int) -> dict: + """Recalculeaza preview-ul si intoarce randul 0 rezolvat (direct pe DB).""" + from app.db import get_connection + from app.web.routes import _preview_one_row + conn = get_connection() + try: + result, row = _preview_one_row(conn, iid, 1, 0) + assert row is not None and not isinstance(result, str) + return row + finally: + conn.close() + + +def test_salvare_chips_persista_si_deblocheaza_randul(client): + """Bug reprodus: cod ales in editor pentru operatie nemapata + coduri extra, + Salveaza -> randul ramanea needs_mapping si chips-urile se pierdeau la redeschidere. + + Dupa fix: chips-urile din form se persista in override -> randul devine ok, + iar redeschiderea editorului arata codurile alese. + """ + _seed_nomenclator_extra() + iid = _upload_and_preview(client, rows=_ROWS_ITP) + + row = _preview_row0(client, iid) + assert row["resolved_status"] == "needs_mapping" + + # Salvare cu chips: OP-ITP -> OE-2 + cod extra OE-3 (fara operatie). + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={ + "cod_prestatie": ["OE-2", "OE-3"], + "chip_op_service": ["OP-ITP", ""], + "chip_denumire": ["Inspectie Tehnica Periodica", ""], + }) + assert r.status_code == 200, r.text + assert "inchideModal" in r.headers.get("HX-Trigger-After-Settle", ""), \ + "Salvarea cu chips valide trebuie sa inchida modalul (succes)" + + row = _preview_row0(client, iid) + assert row["resolved_status"] == "ok", f"Randul trebuie deblocat, e {row['resolved_status']}" + coduri = [p.get("cod_prestatie") for p in row["resolved"]["prestatii"]] + assert coduri == ["OE-2", "OE-3"], coduri + + # Redeschiderea editorului arata chips-urile salvate. + r2 = client.get(f"/_import/{iid}/rand/0/editare-modal") + assert r2.status_code == 200 + assert "OE-2" in r2.text and "OE-3" in r2.text + + +def test_salvare_chips_cod_din_picker_neaprobat_se_aplica(client): + """Cod ales in pickerul per-operatie dar ne-aprobat prin '+ Adauga' se aplica la salvare.""" + _seed_nomenclator_extra() + iid = _upload_and_preview(client, rows=_ROWS_ITP) + + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={ + "cod_prestatie": "", + "chip_op_service": "OP-ITP", + "chip_denumire": "Inspectie Tehnica Periodica", + "chips_add_cod_0": "OE-2", + }) + assert r.status_code == 200, r.text + row = _preview_row0(client, iid) + assert row["resolved_status"] == "ok" + assert row["resolved"]["prestatii"][0]["cod_prestatie"] == "OE-2" + + +def test_salvare_chips_cod_necunoscut_respins(client): + """Cod inexistent in nomenclator -> modal re-randat cu eroare, override neatins.""" + _seed_nomenclator_extra() + iid = _upload_and_preview(client, rows=_ROWS_ITP) + + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={ + "cod_prestatie": "XX-INEXISTENT", + "chip_op_service": "OP-ITP", + "chip_denumire": "Inspectie", + }) + assert r.status_code == 200 + assert "inchideModal" not in r.headers.get("HX-Trigger-After-Settle", "") + assert "necunoscut" in r.text + row = _preview_row0(client, iid) + assert row["resolved_status"] == "needs_mapping", "Codul invalid nu trebuie persistat" + + +def test_salvare_fara_sectiune_chips_lasa_prestatiile_neatinse(client): + """Form fara inputuri de chips (doar campuri de continut) nu atinge prestatiile.""" + _seed_op1() + iid = _upload_and_preview(client) + + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"data_prestatie": "2026-06-15"}) + assert r.status_code == 200 + row = _preview_row0(client, iid) + assert row["resolved"]["prestatii"][0]["cod_prestatie"] == "R-FRANE" + + +def test_commit_foloseste_prestatiile_editate_in_modal(client): + """Confirmarea batch-ului trimite prestatiile din override (chips editate), re-rezolvate.""" + import json as _json + _seed_nomenclator_extra() + iid = _upload_and_preview(client, rows=_ROWS_ITP) + + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={ + "cod_prestatie": "OE-2", + "chip_op_service": "OP-ITP", + "chip_denumire": "Inspectie Tehnica Periodica", + }) + assert r.status_code == 200, r.text + + csrf = _get_csrf(client) + rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf}) + assert rc.status_code == 200, rc.text + + from app.db import get_connection + conn = get_connection() + try: + sub = conn.execute( + "SELECT payload_json FROM submissions WHERE batch_id=?", (iid,) + ).fetchone() + finally: + conn.close() + assert sub is not None, "Randul editat trebuie enqueued la confirmare" + payload = _json.loads(sub["payload_json"]) + assert payload["prestatii"][0]["cod_prestatie"] == "OE-2"