feat(editor): editor prestatii unificat in modal - un select, un rand de chips (PRD 5.23)

Inlocuieste cele 3 controale de asociere cod RAR cu un singur rand de chips
+ un singur select care adauga instant la change. Stare = un hidden chips_state
JSON versionat; post_form_chips redus la 2 actiuni (add/remove). Optgroup
Sugestii (fuzzy/k-NN) + optiune "Nu se declara la RAR" in select, cu tinta
implicita evidentiata si placeholder care o numeste. exclus persistat prin
payload_json (treapta noua de precedenta in resolve_prestatii, round-trip complet).

Siguranta: itemii exclusi sunt scosi din payload la momentul trimiterii
(worker split_prestatii_excluse inainte de build_rar_payload + filtru defensiv),
ca sa nu ajunga NICIODATA la RAR ca codPrestatie:null.

Suita: 1680 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-08 15:45:34 +00:00
parent 0bc618763e
commit 56ebe1f7cb
20 changed files with 2839 additions and 552 deletions

View File

@@ -120,6 +120,7 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
# Expune parse_erori si eticheta_env in toate template-urile
templates.env.globals["parse_erori"] = parse_erori
templates.env.globals["eticheta_env"] = eticheta_env
templates.env.globals["EXCLUDE_SENTINEL"] = EXCLUDE_SENTINEL
def _mediu_instanta() -> str:
@@ -1336,8 +1337,10 @@ def _payload_form_values(payload_json) -> dict:
def _prestatii_chips_from_payload(payload_json) -> list[dict]:
"""Extrage lista de chips prestatii din payload_json pentru _form_editare.html.
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}.
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire, exclus}.
Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''.
`exclus` propaga adnotarea din payload (chip Nedeclarat la redeschidere);
default 0 pentru payload-uri vechi fara camp.
"""
try:
data = json.loads(payload_json) if payload_json else {}
@@ -1353,6 +1356,7 @@ def _prestatii_chips_from_payload(payload_json) -> list[dict]:
"cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (item.get("cod_op_service") or "").strip(),
"denumire": (item.get("denumire") or "").strip(),
"exclus": 1 if item.get("exclus") else 0,
})
return chips
@@ -1501,6 +1505,15 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
ctx["prestatii_chips"] = prestatii_chips
ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips)
ctx["form_chips_url"] = "/form-chips"
# Tinta implicita + sugestii la randarea INITIALA (inainte de orice /form-chips) —
# helper partajat cu post_form_chips: cat exista operatii nemapate, EXISTA tinta.
# Fara conn nu se pot calcula sugestiile (enrich_suggestions).
ctx["chips_target_index"] = None
ctx["sugestii_tinta"] = []
if conn is not None:
ctx["chips_target_index"], ctx["sugestii_tinta"] = _chips_target_si_sugestii(
conn, prestatii_chips, nomenclator_rar
)
# submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html.
# Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste
# → butonul nu apare (corect: /form-chips nu are scop de submission).
@@ -1656,73 +1669,108 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
if isinstance(obs_val, str):
content["obs"] = obs_val.strip()
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
# form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din
# prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite
# TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index
# chiar cand un item de mijloc ramane nemapat.
# Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899).
codes_raw = form.getlist("cod_prestatie")
# Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia
# "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie).
codes_positional = [
c.strip().upper() if isinstance(c, str) else ""
for c in codes_raw
]
# Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
# Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
_flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if _flat_picker and _flat_picker not in codes_positional:
codes_positional.append(_flat_picker)
for _i in range(len(codes_positional)):
if not codes_positional[_i]:
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
if _op_val:
codes_positional[_i] = _op_val
# Verifica daca cel putin un cod non-gol a fost trimis
codes_nonempty = [c for c in codes_positional if c]
if codes_nonempty:
# Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide)
for cod in codes_nonempty:
exists_nom = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone()
if not exists_nom:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message=f"Cod RAR necunoscut in nomenclator: {cod}. "
"Alege un cod valid din lista.",
),
)
# Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index.
# Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE).
# Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping.
existing = content.get("prestatii") or []
new_prestatii = []
for i, cod in enumerate(codes_positional):
if i >= len(existing) and not cod:
continue # extra pozitii goale fara item corespondent — sarite
item = dict(existing[i]) if i < len(existing) else {}
if cod:
item["cod_prestatie"] = cod
# E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta
new_prestatii.append(item)
# Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul.
# Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza.
seen_pairs: set = set()
deduped: list = []
for item in new_prestatii:
pair = (item.get("cod_op_service"), item.get("cod_prestatie"))
if pair not in seen_pairs:
seen_pairs.add(pair)
deduped.append(item)
content["prestatii"] = deduped
# else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii
# detecteaza operatii nemapate si randul ramane needs_mapping.
# Stare chips: editorul unificat trimite UN SINGUR hidden `chips_state`
# (JSON versionat, cu `exclus` per item — vezi _chips_state_from_form). Cand e
# prezent, INLOCUIESTE integral prestatiile, cu round-trip complet pe exclus.
# Cod necunoscut in nomenclator -> respins (invariant ORA-12899);
# sentinelul __NEDECLARAT__ nu e niciodata in nomenclator, deci pica in aceeasi
# verificare daca ajunge (fabricat) direct ca cod_prestatie in starea hidden.
_chips_state_raw = form.get("chips_state")
_are_chips_state = _chips_state_raw is not None and str(_chips_state_raw).strip() != ""
if _are_chips_state:
_chips_din_stare = _chips_from_state_json(str(_chips_state_raw))
if _chips_din_stare is None:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message="Stare chips invalida — reincarca formularul si reintroduce codurile.",
),
)
valid_codes_now = load_nomenclator_codes(conn) or set()
necunoscute = sorted(
{c["cod_prestatie"] for c in _chips_din_stare if c["cod_prestatie"]} - valid_codes_now
)
if necunoscute:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message=f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. "
"Alege un cod valid din lista.",
),
)
content["prestatii"] = _chips_din_stare
else:
# Compat: formular vechi (peste deploy) cu listele paralele
# cod_prestatie/chip_op_service/chip_denumire + pickere ne-aprobate.
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
# form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din
# prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite
# TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index
# chiar cand un item de mijloc ramane nemapat.
# Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899).
codes_raw = form.getlist("cod_prestatie")
# Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia
# "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie).
codes_positional = [
c.strip().upper() if isinstance(c, str) else ""
for c in codes_raw
]
# Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
# Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
_flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if _flat_picker and _flat_picker not in codes_positional:
codes_positional.append(_flat_picker)
for _i in range(len(codes_positional)):
if not codes_positional[_i]:
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
if _op_val:
codes_positional[_i] = _op_val
# Verifica daca cel putin un cod non-gol a fost trimis
codes_nonempty = [c for c in codes_positional if c]
if codes_nonempty:
# Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide)
for cod in codes_nonempty:
exists_nom = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone()
if not exists_nom:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message=f"Cod RAR necunoscut in nomenclator: {cod}. "
"Alege un cod valid din lista.",
),
)
# Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index.
# Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE).
# Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping.
existing = content.get("prestatii") or []
new_prestatii = []
for i, cod in enumerate(codes_positional):
if i >= len(existing) and not cod:
continue # extra pozitii goale fara item corespondent — sarite
item = dict(existing[i]) if i < len(existing) else {}
if cod:
item["cod_prestatie"] = cod
# E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta
new_prestatii.append(item)
# Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul.
# Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza.
seen_pairs: set = set()
deduped: list = []
for item in new_prestatii:
pair = (item.get("cod_op_service"), item.get("cod_prestatie"))
if pair not in seen_pairs:
seen_pairs.add(pair)
deduped.append(item)
content["prestatii"] = deduped
# else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii
# detecteaza operatii nemapate si randul ramane needs_mapping.
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
@@ -1733,19 +1781,23 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
text_rules = load_text_rules(conn, account_id)
excluded_ops = load_excluded_ops(conn, account_id)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
# Persistam TOATE itemii (inclusiv cei exclusi, adnotati exclus=1) — round-trip
# complet in editor (chip Nedeclarat reapare la redeschidere, x il readuce la
# warning). Excluderea din payload-ul RAR/cheia idempotentei se face DOAR local,
# mai jos, prin canon; content["prestatii"] NU se ingusteaza niciodata.
content["prestatii"] = resolved
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
# Prestatiile excluse de la declarare ies din payload-ul trimis (ca in
# reresolve_account); toate excluse -> needs_data cu motiv explicit mai jos.
# Prestatiile excluse de la declarare NU intra in payload-ul trimis la RAR nici
# in cheia de idempotenta (ca in reresolve_account) — dar RAMAN in payload_json
# persistat mai sus. Toate excluse -> needs_data cu motiv explicit mai jos.
declarabile, excluse = split_prestatii_excluse(resolved)
if not unmapped and excluse and declarabile:
content["prestatii"] = declarabile
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
canon = canonicalize_row(content)
# Foloseste DOAR partea declarabila pentru cheie (exclusii nu schimba hash-ul).
canon = canonicalize_row({**content, "prestatii": declarabile})
content.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
@@ -2392,6 +2444,71 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
conn.close()
def _sugestii_select_pentru_tinta(conn, denumire: str | None, nomenclator: list[dict]) -> list[dict]:
"""Sugestii pentru optgroup "Sugestii" din selectul unic.
Refoloseste EXACT sursele din `_preview_import.html:89-121` (fara drum nou de cod):
`sugestie_principala` (GOLD/SILVER/embedding, `enrich_suggestions`) prima, apoi
top-3 fuzzy (`suggest_codes`), dedupliate, pastrand doar coduri care exista in
nomenclator. Fara memoizare — se recalculeaza la fiecare re-render.
Degradare gratioasa: motor de sugestii rece/dezactivat/exceptie -> lista goala
(selectul cade pe nomenclatorul complet, fara optgroup). `ensure_embeddings_corpus`
ruleaza cu `block=False` implicit — calea de request nu asteapta warmup-ul.
Fara denumire (tinta neidentificata) fuzzy match-ul ar intoarce primele coduri
din nomenclator ca sugestii irelevante — golim explicit.
"""
if not (denumire or "").strip():
return []
try:
ensure_embeddings_corpus(conn, nomenclator)
by_cod = {n["cod_prestatie"]: n.get("nume_prestatie") for n in (nomenclator or [])}
out: list[dict] = []
seen: set[str] = set()
enriched = enrich_suggestions(conn, denumire)
principala = enriched.get("sugestie_principala") if enriched else None
if principala and principala.get("cod_prestatie") in by_cod:
cod = principala["cod_prestatie"]
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
seen.add(cod)
for s in suggest_codes(denumire, nomenclator, limit=3):
cod = s.get("cod_prestatie")
if cod and cod not in seen and cod in by_cod:
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
seen.add(cod)
return out
except Exception:
return [] # degradare gratioasa: selectul ramane cu nomenclatorul complet
def _chips_target_si_sugestii(
conn, chips: list[dict], nomenclator: list[dict],
) -> tuple[int | None, list[dict]]:
"""Tinta implicita (prima operatie nemapata ne-exclusa, identificabila) + sugestii.
Sursa unica pentru `post_form_chips` SI randarea INITIALA a modalului (inainte
de orice /form-chips): cat exista operatii nemapate, EXISTA tinta. Fara acest
helper partajat, cele doua cai ar putea diverge.
Necesita `cod_op_service` truthy: template-ul randeaza chip-ul warning (tinta
vizibila) doar pentru operatii identificabile; un item raw fara cod_op_service
n-are ce sa arate ca tinta si ar ramane invizibil.
"""
target_index = next(
(i for i, c in enumerate(chips)
if not c.get("cod_prestatie") and not c.get("exclus") and c.get("cod_op_service")),
None,
)
sugestii: list[dict] = []
if target_index is not None:
_den_tinta = chips[target_index].get("denumire") or chips[target_index].get("cod_op_service")
sugestii = _sugestii_select_pentru_tinta(conn, _den_tinta, nomenclator)
return target_index, sugestii
# =========================================================================== #
# Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din #
# form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara #
@@ -2403,37 +2520,25 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
async def post_form_chips(request: Request) -> HTMLResponse:
"""Re-randeaza sectiunea chips prestatii (HTMX server-driven).
Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie,
chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza
_chips_prestatii.html actualizat. Fara scriere in DB (stateless mid-edit).
Auth: sesiune activa; CSRF verificat.
Stare unica: hidden `chips_state` (JSON versionat, vezi _chips_state_from_form).
DOUA actiuni: add (leaga codul de operatia tinta prin chips_target_index, sau
adauga cod liber fara tinta; sentinelul __NEDECLARAT__ + tinta marcheaza
exclus=1 pe item) si remove (chips_remove_index, index unic — acopera si fostul
remove_flat). Fara scriere in DB (stateless mid-edit). Auth: sesiune activa;
CSRF verificat.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Reconstruct current chips state from parallel hidden inputs (emise de _chips_prestatii.html).
# Toate cele 3 liste sunt aceeasi lungime (emise index-by-index in template).
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")]
# Aliniaza listele la lungimea maxima (defensive)
n = max(len(cod_list), len(op_list), len(den_list)) if (cod_list or op_list or den_list) else 0
chips: list[dict] = []
for i in range(n):
chips.append({
"cod_prestatie": cod_list[i] if i < len(cod_list) else "",
"cod_op_service": op_list[i] if i < len(op_list) else "",
"denumire": den_list[i] if i < len(den_list) else "",
})
chips, chips_error = _chips_state_from_form(form)
if chips is None:
chips = []
action = str(form.get("chips_action") or "").strip()
# Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau
# confirmare + chip evidentiat (chips_ok / chips_added_cod). Fara no-op silentios.
chips_error = ""
# confirmare + chip evidentiat (chips_ok / chips_added_index). Fara no-op silentios.
chips_ok = ""
chips_added_cod = ""
chips_added_index: int | None = None
conn = get_connection()
@@ -2442,71 +2547,93 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone() is not None
def _target_valid(idx: int) -> bool:
return 0 <= idx < len(chips) and not chips[idx].get("cod_prestatie") and not chips[idx].get("exclus")
try:
if action == "add":
# Adauga cod la operatia specificata prin chips_add_op_index
try:
op_idx = int(str(form.get("chips_add_op_index") or 0))
except (ValueError, TypeError):
op_idx = 0
add_cod = str(form.get(f"chips_add_cod_{op_idx}") or "").strip().upper()
if not add_cod:
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
elif not (0 <= op_idx < len(chips)):
chips_error = "Operatia nu mai exista in formular — reincarca editarea."
elif not _cod_valid(add_cod):
chips_error = f"Cod necunoscut in nomenclator: {add_cod}."
else:
chips[op_idx]["cod_prestatie"] = add_cod
chips_ok = f"{add_cod} adaugat."
chips_added_cod = add_cod
if not chips_error and action == "add":
raw_target = str(form.get("chips_target_index") or "").strip()
target_idx: int | None = None
if raw_target != "":
try:
target_idx = int(raw_target)
except (ValueError, TypeError):
target_idx = -1 # index nenumeric -> forteaza "tinta invalida" mai jos
elif action in ("add_flat", "add_extra"):
# Adauga cod RAR liber (fara op_service): add_flat = mod plat,
# add_extra = mod operatii. Acelasi select (chips_add_cod_flat),
# aceeasi validare si acelasi dedup per-item (E4).
add_cod_flat = str(form.get("chips_add_cod_flat") or "").strip().upper()
if not add_cod_flat:
# Mai multe <select name="chips_pick"> pot coexista pe pagina (un picker
# per operatie nemapata + unul flat); doar cel interactionat are valoare.
pick = next(
(v.strip().upper() for v in form.getlist("chips_pick") if isinstance(v, str) and v.strip()),
"",
)
if not pick:
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
elif not _cod_valid(add_cod_flat):
chips_error = f"Cod necunoscut in nomenclator: {add_cod_flat}."
else:
existing_pairs = {
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
for c in chips
}
if ("", add_cod_flat) in existing_pairs:
chips_error = f"{add_cod_flat} este deja in lista — nu a fost adaugat inca o data."
elif pick == EXCLUDE_SENTINEL:
# "Nu se declara la RAR": cere OBLIGATORIU o tinta valida (operatie
# nemapata, ne-exclusa) — sentinelul nu are sens pe cod liber.
if target_idx is None or not _target_valid(target_idx):
chips_error = "\"Nu se declara la RAR\" cere o operatie tinta valida."
else:
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
chips_ok = f"{add_cod_flat} adaugat."
chips_added_cod = add_cod_flat
chips[target_idx]["exclus"] = 1
chips[target_idx]["cod_prestatie"] = ""
op_nume = chips[target_idx].get("cod_op_service") or "Operatia"
chips_ok = f"{op_nume} nu se va declara la RAR."
chips_added_index = target_idx
elif not _cod_valid(pick):
chips_error = f"Cod necunoscut in nomenclator: {pick}."
elif target_idx is not None and not _target_valid(target_idx):
chips_error = "Operatia tinta nu mai exista sau e deja rezolvata — reincarca editarea."
elif target_idx is not None:
# Leaga codul de operatia tinta (E4 binding).
op_tinta = chips[target_idx].get("cod_op_service")
dedup = any(c.get("cod_op_service") == op_tinta and c.get("cod_prestatie") == pick for c in chips)
if dedup:
chips_error = f"{pick} este deja in lista pentru aceasta operatie."
else:
chips[target_idx]["cod_prestatie"] = pick
chips[target_idx]["exclus"] = 0
chips_ok = f"{pick} adaugat."
chips_added_index = target_idx
else:
# Fara tinta -> chip liber (fara operatie sursa).
dedup = any(not c.get("cod_op_service") and c.get("cod_prestatie") == pick for c in chips)
if dedup:
chips_error = f"{pick} este deja in lista — nu a fost adaugat inca o data."
else:
chips.append({"cod_prestatie": pick, "cod_op_service": "", "denumire": "", "exclus": 0})
chips_ok = f"{pick} adaugat."
chips_added_index = len(chips) - 1
elif action == "remove":
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata)
elif not chips_error and action == "remove":
# Index unic (acopera si fostul remove_flat): chip legat de operatie ->
# codul se sterge (operatia redevine nemapata/warning, inclusiv Nedeclarat
# -> warning); chip liber -> iese complet din lista.
try:
remove_idx = int(str(form.get("chips_remove_index") or 0))
remove_idx = int(str(form.get("chips_remove_index") or ""))
except (ValueError, TypeError):
remove_idx = 0
remove_idx = -1
if 0 <= remove_idx < len(chips):
chips[remove_idx]["cod_prestatie"] = ""
item = chips[remove_idx]
if item.get("cod_op_service"):
item["cod_prestatie"] = ""
item["exclus"] = 0
else:
chips.pop(remove_idx)
elif action == "remove_flat":
# Sterge un chip plat dupa cod (in mod fara op_service)
remove_cod = str(form.get("chips_remove_cod") or "").strip().upper()
chips = [
c for c in chips
if not (not c.get("cod_op_service") and c.get("cod_prestatie") == remove_cod)
]
# Compute has_r_odo dupa actiune
has_r_odo = _has_r_odo_chips(chips)
# Incarca nomenclatorul pentru picker
nomenclator_rar = load_nomenclator(conn)
# Tinta implicita dupa actiune (prima operatie nemapata) + sugestii pentru ea
# (optgroup "Sugestii" din select) — helper partajat cu randarea initiala.
chips_target_index, sugestii_tinta = _chips_target_si_sugestii(conn, chips, nomenclator_rar)
finally:
conn.close()
# Ecou (nu re-derivat): permite butonului "salveaza ca regula" sa ramana
# disponibil dupa re-randari succesive via /form-chips.
chips_submission_id = str(form.get("chips_submission_id") or "").strip()
return templates.TemplateResponse("_chips_prestatii.html", {
"request": request,
"csrf_token": get_csrf_token(request),
@@ -2517,7 +2644,10 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"chips_section_id": "chips-section",
"chips_error": chips_error,
"chips_ok": chips_ok,
"chips_added_cod": chips_added_cod,
"chips_added_index": chips_added_index,
"chips_target_index": chips_target_index,
"chips_submission_id": chips_submission_id,
"sugestii_tinta": sugestii_tinta,
})
@@ -3235,7 +3365,12 @@ def _web_compute_preview(
key: str | None = None
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try:
key = _build_idempotency_key(account_id, info["resolved"], preview_env)
# Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
# hash) — identica cu cea calculata la commit, altfel un rand cu
# prestatii excluse arata alt duplicat decat cel real.
decl, _exc = split_prestatii_excluse(info["resolved"].get("prestatii"))
key_resolved = {**info["resolved"], "prestatii": decl} if _exc else info["resolved"]
key = _build_idempotency_key(account_id, key_resolved, preview_env)
keys_for_lookup.append(key)
key_to_indices.setdefault(key, []).append(i)
except Exception:
@@ -3796,12 +3931,16 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
}
for p in (res.get("prestatii") or [])
if isinstance(p, dict)
]
_preview_has_r_odo = _has_r_odo_chips(_preview_chips)
_preview_nomenclator = load_nomenclator(conn)
_preview_target_idx, _preview_sugestii = _chips_target_si_sugestii(
conn, _preview_chips, _preview_nomenclator
)
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
@@ -3827,6 +3966,8 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
"obs_val": (res.get("obs") or "").strip(),
"nomenclator_rar": _preview_nomenclator,
"form_chips_url": "/form-chips",
"chips_target_index": _preview_target_idx,
"sugestii_tinta": _preview_sugestii,
})
finally:
conn.close()
@@ -3859,25 +4000,77 @@ 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.
CHIPS_STATE_VERSION = 1
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.
def _parse_chip_exclus(raw) -> int:
"""Parsare stricta a flag-ului exclus: DOAR '1' -> 1, orice altceva -> 0."""
return 1 if str(raw).strip() == "1" else 0
def _chips_state_to_json(chips: list[dict]) -> str:
"""Serializeaza chips (lista interna) in JSON versionat pentru hidden `chips_state`."""
return json.dumps({
"v": CHIPS_STATE_VERSION,
"items": [
{
"cod": c.get("cod_prestatie") or "",
"op": c.get("cod_op_service") or "",
"den": c.get("denumire") or "",
"exclus": 1 if c.get("exclus") else 0,
}
for c in chips
],
}, ensure_ascii=False)
# Expune serializarea chips_state in template (randare initiala, fara trecere prin
# post_form_chips) -- sursa unica de adevar pentru hidden `chips_state`.
templates.env.globals["chips_state_json"] = _chips_state_to_json
def _chips_from_state_json(raw: str) -> list[dict] | None:
"""Decodeaza hidden `chips_state` (JSON versionat) in lista interna de chips.
Intoarce None cand JSON-ul e invalid sau structura nu se potriveste
(fara cheie 'items' lista) — apelantul trateaza asta ca eroare vizibila,
NU ca stare goala (altfel o golire accidentala trece neobservata).
"""
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
return None
if not isinstance(parsed, dict) or not isinstance(parsed.get("items"), list):
return None
chips: list[dict] = []
for it in parsed["items"]:
if not isinstance(it, dict):
return None
chips.append({
"cod_prestatie": str(it.get("cod") or "").strip().upper(),
"cod_op_service": str(it.get("op") or "").strip(),
"denumire": str(it.get("den") or "").strip(),
"exclus": _parse_chip_exclus(it.get("exclus")),
})
return chips
def _chips_from_legacy_lists(form) -> list[dict]:
"""Compat tranzitorie: reconstruieste chips din listele paralele vechi.
cod_prestatie / chip_op_service / chip_denumire (+ chip_exclus optional,
parsat strict). Codurile alese in pickere dar ne-aprobate prin '+' (E.g.
chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit — comportament
pastrat identic cu forma veche (form-uri deschise peste deploy). Dedup pe
perechea (op, cod). Se sterge in release-ul urmator.
"""
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")]
excl_list = [str(e) for e in form.getlist("chip_exclus")]
n = max(len(cod_list), len(op_list), len(den_list))
chips: list[dict[str, str]] = []
chips: list[dict] = []
for i in range(n):
cod = cod_list[i] if i < len(cod_list) else ""
if not cod:
@@ -3887,12 +4080,13 @@ def _chips_state_from_form(form) -> list[dict[str, str]] | None:
"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 "",
"exclus": _parse_chip_exclus(excl_list[i]) if i < len(excl_list) else 0,
})
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": ""})
chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": "", "exclus": 0})
seen: set = set()
out: list[dict[str, str]] = []
out: list[dict] = []
for c in chips:
if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]):
continue
@@ -3904,6 +4098,33 @@ def _chips_state_from_form(form) -> list[dict[str, str]] | None:
return out
def _chips_state_from_form(form) -> tuple[list[dict] | None, str]:
"""Reconstruieste starea chips prestatii din formular.
Sursa unica: hidden `chips_state` (JSON versionat {"v":1,"items":[...]});
compat tranzitorie pe listele paralele vechi (cod_prestatie/chip_op_service/
chip_denumire) cand `chips_state` lipseste (form deschis peste deploy).
Intoarce (chips, error):
- error non-gol -> JSON invalid; apelantul arata chips_error si NU aplica
nicio schimbare (NU stare goala silentioasa).
- chips=None + error gol -> formularul nu are deloc sectiune de chips;
prestatiile raman neatinse.
- chips=[] + error gol -> golire intentionata (chips_state prezent, items:[]);
se persista la salvare.
"""
raw_state = form.get("chips_state")
if raw_state is not None and str(raw_state).strip() != "":
chips = _chips_from_state_json(str(raw_state))
if chips is None:
return None, "Stare chips invalida — reincarca formularul si reintroduce codurile."
return chips, ""
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, ""
return _chips_from_legacy_lists(form), ""
@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.
@@ -3924,9 +4145,55 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
for camp in EDIT_FIELDS
}
# Chips prestatii din modal: None = form fara sectiune de chips (neatins).
chips_state = _chips_state_from_form(form)
chips_state, chips_parse_error = _chips_state_from_form(form)
conn = get_connection()
try:
if chips_parse_error:
# JSON chips_state corupt -> eroare vizibila, NU stare goala silentioasa;
# override NEATINS (nicio prestatie nu se schimba).
_nom_err = load_nomenclator(conn)
_cur_result, _cur_row = _preview_one_row(conn, import_id, account_id, row_index)
_cur_chips = (
(_cur_row.get("resolved") or {}).get("prestatii")
if _cur_row and not isinstance(_cur_result, str) else None
) or []
_cur_chips = [
{
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
}
for p in _cur_chips if isinstance(p, dict)
]
_cur_target_idx, _cur_sugestii = _chips_target_si_sugestii(conn, _cur_chips, _nom_err)
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": chips_parse_error,
"chips_error": chips_parse_error,
"prestatii_chips": _cur_chips,
"has_r_odo": _has_r_odo_chips(_cur_chips),
"obs_val": str(form.get("obs") or "").strip(),
"nomenclator_rar": _nom_err,
"form_chips_url": "/form-chips",
"chips_target_index": _cur_target_idx,
"sugestii_tinta": _cur_sugestii,
})
# 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:
@@ -3936,6 +4203,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
)
if necunoscute:
_nom = load_nomenclator(conn)
_nec_target_idx, _nec_sugestii = _chips_target_si_sugestii(conn, chips_state, _nom)
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
@@ -3960,6 +4228,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"obs_val": str(form.get("obs") or "").strip(),
"nomenclator_rar": _nom,
"form_chips_url": "/form-chips",
"chips_target_index": _nec_target_idx,
"sugestii_tinta": _nec_sugestii,
})
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
@@ -3992,11 +4262,13 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
}
for p in (res.get("prestatii") or [])
if isinstance(p, dict)
]
_err_nomenclator = load_nomenclator(conn)
_err_target_idx, _err_sugestii = _chips_target_si_sugestii(conn, _err_chips, _err_nomenclator)
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
@@ -4021,6 +4293,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"obs_val": str(form.get("obs") or res.get("obs") or "").strip(),
"nomenclator_rar": _err_nomenclator,
"form_chips_url": "/form-chips",
"chips_target_index": _err_target_idx,
"sugestii_tinta": _err_sugestii,
})
# Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
@@ -4532,15 +4806,18 @@ async def web_confirma_import(
# Rezolva prestatii
prestatii = mapped.get("prestatii") or []
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops)
# Prestatiile excluse NU pleaca la RAR (ca in preview); rand fara nicio
# prestatie declarabila = 'excluded' in preview -> defensiv skip.
resolved_p, _excluse_p = split_prestatii_excluse(resolved_p)
if not resolved_p:
# Prestatiile excluse NU pleaca la RAR si nu intra in cheia de
# idempotenta, dar RAMAN in payload_json persistat (round-trip in
# editor) — ingustarea la declarabile se face doar local, pentru
# canon/cheie, si la worker, la momentul trimiterii.
declarabile_p, _excluse_p = split_prestatii_excluse(resolved_p)
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
if not declarabile_p:
continue
mapped["prestatii"] = resolved_p
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
canon = canonicalize_row(mapped)
canon = canonicalize_row({**mapped, "prestatii": declarabile_p})
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
@@ -4556,7 +4833,7 @@ async def web_confirma_import(
"odometru_final": mapped.get("odometru_final"),
"prestatii": [
str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
for p in resolved_p
for p in declarabile_p
],
}, sort_keys=True, ensure_ascii=False))