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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user