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

View File

@@ -1,233 +1,175 @@
{#
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips).
_chips_prestatii.html — control unificat chips prestatii.
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip.
Inclusa si din _form_editare.html pentru randarea initiala.
UN singur container `.chips`: chips cod (legate de operatie sau libere), chips
warning pentru operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, si
selectul unic `chips_pick` la final. Fara `.op-row`, fara butoane Adauga/+ —
selectul adauga instant la `change` (POST /form-chips, chips_action=add).
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit).
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod).
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO.
Diagrama starii per item (chips_state.items[i]):
cod="" & exclus=0 -> chip warning (warn OP-X . fara cod), click = tinta
cod="" & exclus=1 -> chip Nedeclarat (line-through), x -> readuce warning
cod!="" (op="" sau op!="") -> chip cod (eticheta COD, tooltip = operatia sursa
sau "cod adaugat manual")
Stare persistata intr-UN SINGUR hidden `chips_state` (JSON versionat)
+ hidden `chips_target_index` (tinta curenta; comutata CLIENT-SIDE la click pe
un chip warning, fara round-trip — indexul pleaca la server abia la urmatorul
add) + hidden `chips_submission_id` (ecouat ca sa "salveaza ca regula" ramana
disponibil dupa re-randari via /form-chips).
Context vars (toate cu defaults):
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed)
form_chips_url — URL pentru HTMX; default '/form-chips'
chips_section_id — ID div (default 'chips-section')
csrf_token — CSRF (trecut prin hx-include din form parinte)
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire, exclus}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru select
has_r_odo — True daca vreun chip e R-ODO/I-ODO (server-computed)
chips_added_index — index-ul chip-ului tocmai adaugat (emfaza chip-nou)
chips_target_index — index-ul operatiei tinta curente (sau none)
chips_submission_id — id submission cand chips sunt in modalul de detaliu
(activeaza butonul "salveaza ca regula")
form_chips_url — URL pentru HTMX; default '/form-chips'
chips_section_id — ID div (default 'chips-section')
csrf_token — CSRF (trecut prin hx-include din form parinte)
#}
{% set _chips_url = form_chips_url or '/form-chips' %}
{% set _sec_id = chips_section_id or 'chips-section' %}
{% set _chips = prestatii_chips or [] %}
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %}
{# chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu.
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #}
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
{% set _sub_id = chips_submission_id if (chips_submission_id is defined and chips_submission_id) else '' %}
{% set _target_idx = chips_target_index if (chips_target_index is defined and chips_target_index is not none) else none %}
{% set _target_op = (_chips[_target_idx].cod_op_service or '') if (_target_idx is not none and _target_idx < _chips | length) else '' %}
<div id="{{ _sec_id }}" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
<div id="{{ _sec_id }}" class="chips-editor" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
aria-live="polite" aria-label="Prestatii cod RAR">
{# ===== Input-uri hidden pentru starea curenta a chip-urilor =====
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped).
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i].
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #}
{% for chip in _chips %}
<input type="hidden" name="cod_prestatie" value="{{ chip.cod_prestatie or '' }}">
<input type="hidden" name="chip_op_service" value="{{ chip.cod_op_service or '' }}">
<input type="hidden" name="chip_denumire" value="{{ chip.denumire or '' }}">
{% endfor %}
{# ===== Stare: UN SINGUR hidden JSON versionat + tinta + submission id ===== #}
<input type="hidden" name="chips_state" value='{{ chips_state_json(_chips) }}'>
<input type="hidden" name="chips_target_index" id="chips-target-index"
value="{{ _target_idx if _target_idx is not none else '' }}">
<input type="hidden" name="chips_submission_id" value="{{ _sub_id }}">
<div class="camp-slim" style="margin-bottom:8px;">
<label>Prestatii — cod RAR pe fiecare operatie</label>
<label>Prestatii — cod RAR</label>
{% if _has_ops %}
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
{% for chip in _chips %}
{% if chip.cod_op_service %}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _nemapat = not chip.cod_prestatie %}
{% set _e_nou = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
<span class="op-row-name">
{{ chip.cod_op_service }}
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
{% endif %}
{% if _nemapat %}
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
{% endif %}
</span>
<span style="display:flex;align-items:center;gap:8px;">
{% if chip.cod_prestatie %}
{# ===== Operatie mapata: chip cu × ===== #}
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ loop.index0 }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
&times;
</button>
</span>
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
Reuse EXACT save_mapping + reresolve_account via endpoint dedicat.
hx-include="closest form" propaga csrf_token din form-ul parinte. #}
<span id="save-rule-slot-{{ loop.index0 }}" class="save-rule-slot">
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
<button type="button"
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:4px;line-height:1;"
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula
</button>
{% endif %}
</span>
{% else %}
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
<select name="chips_add_cod_{{ loop.index0 }}"
id="picker-op-{{ loop.index0 }}"
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
style="min-width:160px;font-size:11px;height:26px;">
<option value="">— alege cod RAR —</option>
{% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add","chips_add_op_index":{{ loop.index0 }}}'
aria-label="Adauga cod RAR pentru {{ chip.cod_op_service }}">
+ Adauga
</button>
{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
<div class="chips" role="group" aria-label="Prestatii cod RAR">
{% for chip in _chips %}
{% set _idx = loop.index0 %}
{% set _is_target = _target_idx is not none and _idx == _target_idx %}
{# ===== Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
Containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
un chenar gol nefinisat sub randurile de operatie. #}
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
{% if _extra_chips %}
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
{% for chip in _extra_chips %}
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _e_nou_extra = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %} {% if _e_nou_extra %}chip-nou{% endif %}"
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
{% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %}
{# ===== Chip warning: operatie nemapataclick comuta tinta client-side ===== #}
{% set _label = chip.cod_op_service %}
{% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %}
{% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %}
<button type="button"
class="chip chip-op-warn {% if _is_target %}is-target{% endif %}"
data-chip-idx="{{ _idx }}"
data-op-nume="{{ chip.cod_op_service }}"
title="{{ _title }}"
onclick="window.chipsSetTarget && window.chipsSetTarget(this, {{ _idx }})"
aria-pressed="{{ 'true' if _is_target else 'false' }}"
aria-label="Operatia {{ chip.cod_op_service }} nu are cod RAR{% if _is_target %} (tinta curenta){% endif %}">
&#9888; {{ _label_scurt }} &middot; fara cod
</button>
{% elif chip.exclus %}
{# ===== Chip Nedeclarat: vizibil (line-through), x readuce warning-ul ===== #}
<span class="chip chip-nedeclarat"
title="{{ chip.cod_op_service or 'Cod liber' }} — nu se declara la RAR"
aria-label="{{ chip.cod_op_service or 'Operatia' }} nu se declara la RAR">
Nedeclarat
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">&times;</button>
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)">
&times;
</button>
</span>
{% elif chip.cod_prestatie %}
{# ===== Chip cod: legat de operatie sau liber ===== #}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _e_nou = (chips_added_index is defined) and chips_added_index is not none and _idx == chips_added_index %}
{% set _tooltip = (chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '')) if chip.cod_op_service else 'cod adaugat manual' %}
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
title="{{ _tooltip }}"
aria-label="Prestatie {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
<span class="chip-cod-label">COD</span> {{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
&times;
</button>
</span>
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #}
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
<span id="save-rule-slot-{{ _idx }}" class="save-rule-slot">
<button type="button"
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:-2px;line-height:1;"
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula
</button>
</span>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR suplimentar"
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ Adauga alta operatie / cod RAR</option>
{% for n in nomenclator_rar %}
{# ===== Selectul unic: adauga instant la schimbare, placeholder numeste tinta ===== #}
{% if nomenclator_rar %}
<select name="chips_pick"
id="chips-picker"
class="chips-picker"
hx-trigger="change"
hx-post="{{ _chips_url }}"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-include="closest form"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-vals='{"chips_action":"add"}'
aria-label="{% if _target_op %}Alege cod RAR pentru {{ _target_op }}{% else %}Adauga cod RAR{% endif %}">
<option value="">{% if _target_op %}Cod pentru {{ _target_op }} — alege{% else %}— adauga cod RAR —{% endif %}</option>
{% if _target_idx is not none %}
{% set _sugestii = sugestii_tinta if (sugestii_tinta is defined and sugestii_tinta) else [] %}
{% if _sugestii %}
<optgroup label="Sugestii">
{% for s in _sugestii %}
<option value="{{ s.cod_prestatie }}">{{ s.cod_prestatie }} — {{ s.nume_prestatie }}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="{{ EXCLUDE_SENTINEL }}">Nu se declara la RAR (exclude operatia)</option>
{% endif %}
{% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_extra"}'
aria-label="Adauga cod RAR suplimentar la trimitere">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
</div>
{% endif %}
{% else %}
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
<div class="chips" role="group" aria-label="Coduri RAR selectate">
{% for chip in _chips %}
{% if chip.cod_prestatie %}
{% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _e_nou_flat = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %} {% if _e_nou_flat %}chip-nou{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul {{ chip.cod_prestatie }}">&times;</button>
</span>
{% endif %}
{% endfor %}
{# Picker adaugare cod nou in mod plat #}
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR nou"
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ cod</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_flat"}'
aria-label="Adauga cod RAR selectat in lista">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
{# T-D1/T-E5 (5.16): empty state cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan.
Nomenclator indisponibil — adaugarea de coduri RAR nu e posibila.
</div>
{% endif %}
</div>
{% endif %}
{# Rezultatul ultimei adaugari, in ambele moduri: refuz (rol=alert) sau confirmare.
{# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare.
Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
{% if chips_error is defined and chips_error %}
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
@@ -238,12 +180,40 @@
{{ chips_ok }}
</div>
{% endif %}
{# Hint discret fara chips (debut) #}
{% if not _chips %}
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
</div>
{% endif %}
</div>
</div>
<script>
(function () {
if (window.__chipsUnifiedBound) { return; }
window.__chipsUnifiedBound = true;
// Click pe chip warning -> comuta tinta CLIENT-SIDE (fara round-trip). Click
// pe tinta activa = no-op (nu se poate "deselecta" tinta cat exista warnings).
window.chipsSetTarget = function (btn, idx) {
var hidden = document.getElementById('chips-target-index');
if (!hidden) { return; }
if (String(idx) === hidden.value) { return; }
hidden.value = String(idx);
document.querySelectorAll('.chip-op-warn.is-target').forEach(function (el) {
el.classList.remove('is-target');
el.setAttribute('aria-pressed', 'false');
});
btn.classList.add('is-target');
btn.setAttribute('aria-pressed', 'true');
var picker = document.getElementById('chips-picker');
if (picker && picker.options.length) {
picker.options[0].textContent = 'Cod pentru ' + (btn.dataset.opNume || '') + ' — alege';
}
};
// Focus restaurat pe select dupa fiecare swap outerHTML al sectiunii.
document.body.addEventListener('htmx:afterSettle', function (evt) {
var t = evt.target;
if (t && t.classList && t.classList.contains('chips-editor')) {
var el = document.getElementById('chips-picker');
if (el) { el.focus(); }
}
});
})();
</script>

View File

@@ -828,19 +828,22 @@
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
/* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
.chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
/* .add-code — buton dashed pentru adaugare cod in chipbox */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; }
.add-code:hover, .add-code:focus-visible { border-style:solid; }
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
background:var(--card2); margin-bottom:8px; }
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora
vizuala a randului de mapare op<->cod. */
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); }
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
/* Eticheta mono "COD" pe chip-ul de cod */
.chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; }
/* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel —
singurul tratament "tare" din rand. */
.chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn);
border:1px dashed var(--warn); cursor:pointer; font-weight:600; }
.chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent);
outline-offset:1px; }
.chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; }
/* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta
actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */
.chip-nedeclarat { background:var(--card2); color:var(--muted); border:1px dashed var(--line);
text-decoration:line-through; }
/* Sectiunea chips inerta pe durata unui request /form-chips in zbor:
click pe x/warning mid-flight nu se pierde, doar se ignora. */
.chips-editor.htmx-request { pointer-events:none; opacity:.7; }
/* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data),
cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste
aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */