fix(import): persista chips prestatii la salvarea din modalul de editare rand

Editorul de rand din preview afisa chips-urile operatie<->cod RAR dar salvarea
extragea doar EDIT_FIELDS — codurile alese se pierdeau si randul ramanea
needs_mapping. Starea chips se reconstruieste din form (conventia /form-chips),
se valideaza fata de nomenclator si se persista in override_json.

Caile de commit (API + web) aplica acum override-ul INAINTE de resolve_prestatii,
ca in preview — altfel prestatiile editate ajungeau nerezolvate in payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-07 17:20:01 +00:00
parent 5a2c976d46
commit c6e29aa65d
3 changed files with 262 additions and 29 deletions

View File

@@ -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,25 +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)
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"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).
# 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"],

View File

@@ -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,23 +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
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
override = item.get("override") or {}
if override:
mapped.update(override)
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],

View File

@@ -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"