feat(5.8): reguli mapare pe text (substring/cont) + UX tabel trimiteri (detaliu inline, fara scroll, cod RAR)

Reguli text per cont (operation_text_rules), resolve_prestatii cu param aditiv
text_rules + precedenta stricta, threadat pe toate cele 6 callsite-uri + valid_codes
+ seam classify_prezentare. UI Mapari: sectiune reguli + preview pre-salvare + overlap
+ telemetrie text_rule_hit. UX tabel: cod_rar sub operatie, pill eticheta scurta, fara
scroll orizontal (scopat .tabel-trimiteri + carduri <768px), detaliu inline expandabil
(a11y + pauza poll). code-review: reparat regula auto_send=0 care trimitea automat la RAR
in loc sa tina randul pentru review. 814 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-24 12:47:37 +00:00
parent c80c79462c
commit 51dc504f1d
28 changed files with 3023 additions and 61 deletions

View File

@@ -44,9 +44,12 @@ from ...import_parse import (
parse_file,
)
from ...mapping import (
_emite_text_rule_hits,
account_or_default,
has_no_auto_send,
load_mapping_meta,
load_nomenclator_codes,
load_text_rules,
normalize_for_match,
resolve_prestatii,
)
@@ -126,6 +129,8 @@ def _resolve_row_for_preview(
mapping_meta: dict[str, dict],
formula_columns: list[str],
override: dict[str, Any] | None = None,
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> dict[str, Any]:
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
@@ -201,7 +206,7 @@ def _resolve_row_for_preview(
# Rezolvare prestatii
prestatii = mapped.get("prestatii") or []
resolved, unmapped = resolve_prestatii(prestatii, mapping)
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
mapped["prestatii"] = resolved
# Determinare stare
@@ -745,6 +750,9 @@ def preview_import(
# Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file)
@@ -797,6 +805,8 @@ def preview_import(
mapping_meta=mapping_meta,
formula_columns=formula_columns,
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
)
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review
@@ -1030,6 +1040,9 @@ def commit_import(
# Incarca maparea de operatii
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Construieste payload-urile submissions
enqueued: list[dict] = []
@@ -1076,7 +1089,7 @@ def commit_import(
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or []
resolved, _ = resolve_prestatii(prestatii, mapping)
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
mapped["prestatii"] = resolved
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie)
@@ -1127,6 +1140,8 @@ def commit_import(
toctou_collisions.append(row_index)
else:
sub_id = cur.lastrowid
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
enqueued.append({
"submission_id": sub_id,
"row_index": row_index,

View File

@@ -25,11 +25,13 @@ from ...db import get_connection
from ...errors import eroare as err_eroare
from ...idempotency import build_key, canonicalize_row
from ...mapping import (
_emite_text_rule_hits,
account_or_default,
account_scope_clause,
classify_prezentare,
load_mapping_meta,
load_nomenclator_codes,
load_text_rules,
pending_unmapped,
reresolve_account,
save_mapping,
@@ -65,13 +67,13 @@ def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> boo
return bool(row["on_unmapped_error_default"]) if row else False
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) -> dict:
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None) -> dict:
"""classify_prezentare + aplicarea modului on_unmapped_error.
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere
(blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element.
"""
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules)
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
return cl
@@ -164,6 +166,8 @@ def create_prezentari(
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None
# Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2).
text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for prez in req.prezentari:
content = prez.model_dump()
@@ -187,7 +191,7 @@ def create_prezentari(
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error":
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode)
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if cl["blocked_error"]:
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(_rezultat_respins(existing["id"], cl))
@@ -208,6 +212,8 @@ def create_prezentari(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(encrypt_creds(req.rar_credentials.model_dump()), acct),
)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
# Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea
# cade pe needs_data/needs_mapping, expune motivul (nu doar status).
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
@@ -230,7 +236,7 @@ def create_prezentari(
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare + auto_send gate).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode)
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if cl["blocked_error"]:
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
results.append(_rezultat_respins(None, cl))
@@ -240,8 +246,11 @@ def create_prezentari(
"VALUES (?, ?, ?, ?, ?, ?)",
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
)
sub_id = int(cur.lastrowid)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
# Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv.
results.append(_rezultat_enqueue(int(cur.lastrowid), cl))
results.append(_rezultat_enqueue(sub_id, cl))
# US-004: audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL).
@@ -284,10 +293,12 @@ def valideaza_prezentari(
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for i, prez in enumerate(req.prezentari):
content = prez.model_dump()
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode)
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if res["blocked_error"]:
res = {**res, "status": "error"}
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT

View File

@@ -87,27 +87,127 @@ def suggest_codes(
]
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010).
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
def _rezolva_din_reguli_text(
item: dict,
text_rules: list[dict] | None,
valid_codes: set[str] | None,
) -> tuple[str | None, str | None, bool | None]:
"""Cauta prima regula text (in ordinea data) al carei pattern e substring al
textului operatiei. Intoarce (cod uppercase, pattern original, auto_send) daca e
valid, altfel (None, None, None).
Textul operatiei = `denumire` daca exista, altfel `cod_op_service`. Ambele parti
(text si pattern) se normalizeaza cu `normalize_for_match` (fara diacritice,
uppercase, spatii colapsate) -> match insensibil la caz/diacritice.
`text_rules` e deja ordonata (priority ASC, id ASC) de `load_text_rules`, deci
prima regula care da match castiga. Daca regula castigatoare are un cod absent din
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare
umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
"""
if not text_rules:
return None, None, None
text = normalize_for_match(item.get("denumire") or item.get("cod_op_service"))
if not text:
return None, None, None
for rule in text_rules:
pat = normalize_for_match(rule.get("pattern"))
if not pat or pat not in text:
continue
# Prima regula care da match castiga.
cod = (rule.get("cod_prestatie") or "").strip().upper()
if not cod:
return None, None, None
if valid_codes is not None and cod not in valid_codes:
return None, None, None # cod invalid in nomenclator -> nu il punem; ramane nemapat
return cod, rule.get("pattern"), bool(rule.get("auto_send"))
return None, None, None
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010).
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
folosesc ca sa emita `log_event("text_rule_hit", ...)`.
"""
hits: list[dict] = []
for item in resolved or []:
sursa = item.get("cod_sursa")
if isinstance(sursa, str) and sursa.startswith(COD_SURSA_TEXT_RULE_PREFIX):
hits.append({
"pattern": sursa[len(COD_SURSA_TEXT_RULE_PREFIX):],
"cod_prestatie": item.get("cod_prestatie"),
})
return hits
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
"""Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant).
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
Un pattern IDENTIC dupa normalizare NU e overlap: e un upsert (update al codului),
nu o suprapunere care merita avertisment. Intoarce dict-urile originale din
`existing_rules` care se suprapun (in ordinea data).
"""
pat = normalize_for_match(pattern)
if not pat:
return []
hits: list[dict] = []
for rule in existing_rules or []:
other = normalize_for_match(rule.get("pattern"))
if not other or other == pat:
continue # gol sau identic -> nu e overlap
if pat in other or other in pat:
hits.append(rule)
return hits
def resolve_prestatii(
prestatii: list[dict] | None,
mapping: dict[str, str],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> tuple[list[dict], list[dict]]:
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
Reguli (hibrid):
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod si fara mapare -> ramane nemapat.
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
(substring) -> umplem cod_prestatie din prima regula care potriveste.
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
cod valid SI op nu e in `mapping`.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
neatins"); rutele API il paseaza intotdeauna.
`text_rules` = lista de dict-uri ca cea intoarsa de `load_text_rules`
([{pattern, cod_prestatie, auto_send, priority}], ordonata priority ASC, id ASC).
Default None = comportament actual neschimbat (fara reguli text).
Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
contextul; payload-ul RAR citeste doar cod_prestatie. `nemapate` =
@@ -117,6 +217,12 @@ def resolve_prestatii(
unmapped: list[dict] = []
for item in prestatii or []:
it = dict(item)
# Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de
# hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None)
cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip()
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
@@ -134,8 +240,22 @@ def resolve_prestatii(
if op and op in mapping:
it["cod_prestatie"] = mapping[op]
elif op:
it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# Mapare exacta absenta -> incearca regulile text (substring).
cod_regula, pattern_regula, auto_send_regula = _rezolva_din_reguli_text(
it, text_rules, valid_codes
)
if cod_regula is not None:
it["cod_prestatie"] = cod_regula
# Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu
# pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar
# TINE randul pentru verificare umana (has_no_auto_send -> True).
if not auto_send_regula:
it["regula_fara_autosend"] = True
else:
it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# item fara cod si fara op: il lasam asa; validarea de continut prinde
# "prestatii goale"/cod lipsa.
resolved.append(it)
@@ -254,6 +374,7 @@ def classify_prezentare(
mapping: dict[str, str],
mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
@@ -273,7 +394,7 @@ def classify_prezentare(
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes)
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
c["prestatii"] = resolved
if unmapped:
@@ -311,12 +432,17 @@ def classify_prezentare(
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
"""Verifica daca vreun item rezolvat via mapping are auto_send=0.
"""Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text).
T6/OV-1: un cod nou-mapat cu auto_send=0 nu trebuie trimis automat.
Items cu cod_prestatie direct (nu via cod_op_service) nu sunt afectate.
T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu
auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri
randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada".
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
"""
for item in resolved:
if item.get("regula_fara_autosend"):
return True
op = (item.get("cod_op_service") or "").strip()
if op and op in mapping_meta and not mapping_meta[op]["auto_send"]:
return True
@@ -395,6 +521,85 @@ def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestati
)
def load_text_rules(conn, account_id: int | None) -> list[dict]:
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
Fiecare element: {pattern, cod_prestatie, auto_send, priority}.
Aplica account_or_default (None == 1).
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT pattern, cod_prestatie, auto_send, priority "
"FROM operation_text_rules "
"WHERE account_id=? "
"ORDER BY priority ASC, id ASC",
(acct,),
).fetchall()
return [dict(r) for r in rows]
def save_text_rule(
conn,
account_id: int | None,
pattern: str,
cod_prestatie: str,
auto_send: bool,
) -> None:
"""Upsert o regula text pe (account_id, pattern).
auto_send boolean -> 0/1. Daca regula exista deja (acelasi cont + pattern),
actualizeaza cod_prestatie si auto_send.
"""
acct = account_or_default(account_id)
pat = (pattern or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not pat or not cod:
raise ValueError("pattern si cod_prestatie sunt obligatorii")
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(account_id, pattern) DO UPDATE SET "
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
(acct, pat, cod, 1 if auto_send else 0),
)
def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
"""Sterge regula cu (account_id, pattern) daca exista."""
acct = account_or_default(account_id)
pat = (pattern or "").strip()
conn.execute(
"DELETE FROM operation_text_rules WHERE account_id=? AND pattern=?",
(acct, pat),
)
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event
inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} —
fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
"""
hits = text_rule_hits(resolved)
if not hits:
return
from .observ import log_event # import local: best-effort, fara ciclu la import-time
for hit in hits:
log_event(
"text_rule_hit",
account_id=account_id,
cod=hit.get("cod_prestatie"),
conn=conn,
context={
"submission_id": submission_id,
"account_id": account_id,
"pattern": hit.get("pattern"),
"cod_prestatie": hit.get("cod_prestatie"),
},
)
def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) -> dict[str, int]:
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
@@ -413,6 +618,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
# T2: incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct)
if batch_id is not None:
# T7: scope la batch-ul specificat (import commit explicit).
@@ -438,10 +645,13 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content = json.loads(r["payload_json"])
except (ValueError, TypeError):
continue
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, r["id"], resolved)
if unmapped:
conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",

View File

@@ -35,6 +35,17 @@ def _clean_odometru(value: Any) -> str:
return s
def _clean_cod_rar(value: Any) -> str:
"""Cod RAR afisat curat: uppercase + strip '.0' defensiv (coercion Excel 'OE-2.0' -> 'OE-2').
Codurile RAR nu au zecimale, dar fii defensiv ca la odometru.
"""
s = _clean_str(value)
if s.endswith(".0"):
s = s[:-2]
return s.upper() if s else ""
def _vin_scurt(vin: str) -> str:
"""Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu).
@@ -101,6 +112,9 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
denumire = _clean_str(item.get("denumire"))
operatie = denumire or cod
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
return {
"vehicul_nr": nr or EMPTY,
"vin": vin or EMPTY,
@@ -109,4 +123,5 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"data_prestatie": data_prest or EMPTY,
"odometru": odo or EMPTY,
"cod": cod or EMPTY,
"cod_rar": cod_rar or EMPTY,
}

View File

@@ -182,6 +182,22 @@ CREATE INDEX IF NOT EXISTS idx_app_events_ts ON app_events(ts);
CREATE INDEX IF NOT EXISTS idx_app_events_account ON app_events(account_id, ts);
CREATE INDEX IF NOT EXISTS idx_app_events_tip ON app_events(tip);
-- Reguli automate de mapare pe text (substring, per cont). PRD 5.8 US-001.
-- auto_send DEFAULT 0 (decizie CEO 2026-06-24): substring are blast-radius mai mare
-- decat maparea exacta; o regula noua rezolva codul dar tine randul pentru verificare
-- umana pana cand operatorul activeaza explicit "In coada".
CREATE TABLE IF NOT EXISTS operation_text_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
cod_prestatie TEXT NOT NULL,
auto_send INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (account_id, pattern)
);
CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id);
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1),

View File

@@ -58,6 +58,38 @@ STARI_SUBMISSION: dict[str, Eticheta] = {
}
# ---------------------------------------------------------------------------
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri (US-006)
# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa.
# ---------------------------------------------------------------------------
ETICHETE_SCURTE: dict[str, str] = {
"queued": "In coada",
"sending": "Se trimite",
"sent": "Finalizat",
"needs_mapping": "De mapat",
"needs_data": "Date lipsa",
"error": "Eroare",
}
def eticheta_scurta(status: str) -> str:
"""
Returneaza eticheta compacta (pill) pentru o stare de submission.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
try:
return ETICHETE_SCURTE[status]
except KeyError:
raise KeyError(
f"Starea de submission {status!r} nu are eticheta scurta in labels.py. "
"Adauga-o in ETICHETE_SCURTE."
)
def eticheta_stare(status: str) -> Eticheta:
"""
Returneaza (text, subtext, css_class) pentru o stare de submission.

View File

@@ -30,6 +30,7 @@ from ..web.csrf import get_csrf_token, verify_csrf
from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
eticheta_rar,
eticheta_scurta,
eticheta_stare,
eticheta_worker,
format_data_rar,
@@ -62,16 +63,23 @@ from ..submissions_admin import (
)
from ..mapping import (
DEFAULT_ACCOUNT_ID,
_emite_text_rule_hits,
account_or_default,
account_scope_clause,
delete_text_rule,
has_no_auto_send,
load_mapping_meta,
load_nomenclator,
load_nomenclator_codes,
load_text_rules,
normalize_for_match,
pending_unmapped,
reresolve_account,
resolve_prestatii,
save_mapping,
save_text_rule,
suggest_codes,
text_rules_overlap,
)
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5)
@@ -228,6 +236,7 @@ def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
"pending": pending_unmapped(conn, account_id),
"saved_mappings": _load_saved_op_mappings(conn, account_id),
"column_formats": _load_column_formats(conn, account_id),
"text_rules": load_text_rules(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": None,
"csrf_token": get_csrf_token(request),
@@ -646,6 +655,8 @@ def _submission_row_view(r) -> dict:
return {
"id": r["id"],
"status": r["status"],
# PRD 5.8 US-007/US-006: pill = eticheta scurta; textul lung ramane ca tooltip (title=).
"stare_scurt": eticheta_scurta(r["status"]),
"stare_text": eticheta[0],
"stare_css": eticheta[2],
"prez": prezentare_din_payload(r["payload_json"]),
@@ -981,9 +992,14 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, account_id)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved
# US-010: telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
canon = canonicalize_row(content)
content.update({
@@ -1228,6 +1244,7 @@ def _render_mapari(
"pending": pending_unmapped(conn, account_id),
"saved_mappings": _load_saved_op_mappings(conn, account_id),
"column_formats": _load_column_formats(conn, account_id),
"text_rules": load_text_rules(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": message,
"csrf_token": get_csrf_token(request),
@@ -1342,6 +1359,138 @@ def post_sterge_mapare_salvata(
conn.close()
# =========================================================================== #
# US-004 (5.8) — Reguli automate (text): substring -> cod RAR #
# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.#
# =========================================================================== #
@router.post("/mapari/reguli-text", response_class=HTMLResponse)
def post_salveaza_regula_text(
request: Request,
pattern: str = Form(...),
cod_prestatie: str = Form(...),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Salveaza o regula text (substring -> cod RAR) + re-rezolva submission-urile blocate.
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N"
+ trigger trimiteriChanged (refresh lista), ca maparea inline (5.7).
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
pat = (pattern or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not pat or not cod:
return _render_mapari(
request, conn, account_id,
message="Completeaza textul cautat si codul RAR.",
)
valid_codes = load_nomenclator_codes(conn)
if valid_codes and cod not in valid_codes:
return _render_mapari(
request, conn, account_id,
message=f"Cod RAR necunoscut in nomenclator: {cod}.",
)
# US-011: avertisment neblocant daca regula noua se suprapune (substring,
# oricare directie) cu una existenta. Calculam INAINTE de save, fata de
# regulile curente, ca pattern-ul nou sa nu se compare cu sine.
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
save_text_rule(conn, account_id, pat, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = f"Regula salvata. Deblocate: {stats['requeued']}."
if overlap:
parti = "; ".join(
f"«{r.get('pattern')}» -> {r.get('cod_prestatie')}" for r in overlap
)
msg += (
f" Se suprapune cu regula {parti}; "
"ordinea (priority, id) decide care se aplica prima."
)
resp = _render_mapari(request, conn, account_id, message=msg)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/mapari/reguli-text/sterge", response_class=HTMLResponse)
def post_sterge_regula_text(
request: Request,
pattern: str = Form(...),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Sterge o regula text. Scoped pe contul sesiunii."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
pat = (pattern or "").strip()
delete_text_rule(conn, account_id, pat)
return _render_mapari(
request, conn, account_id,
message=f"Regula stearsa: «{pat}».",
)
finally:
conn.close()
@router.post("/mapari/reguli-text/preview", response_class=HTMLResponse)
def post_preview_regula_text(
request: Request,
pattern: str = Form(""),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Preview pre-salvare (US-009): cate operatii nemapate ar potrivi regula.
NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
(needs_mapping, reuse pending_unmapped) al caror text (denumire sau
cod_op_service, normalizat) CONTINE pattern-ul + intoarce pana la 3 exemple.
Pattern gol -> fragment gol (nu numara „tot"). Scoped pe contul sesiunii.
"""
from markupsafe import escape
account_id = require_login(request)
verify_csrf(request, csrf_token)
pat = normalize_for_match(pattern)
if not pat:
return HTMLResponse(content="")
conn = get_connection()
try:
pending = pending_unmapped(conn, account_id)
finally:
conn.close()
matches = []
for entry in pending:
text = normalize_for_match(entry.get("denumire") or entry.get("cod_op_service"))
if pat in text:
matches.append(entry)
if not matches:
return HTMLResponse(
content='<span class="muted" style="font-size:12px;">Nicio potrivire acum.</span>'
)
exemple = ", ".join(
f"«{escape((e.get('denumire') or e.get('cod_op_service') or '').strip())}»"
for e in matches[:3]
)
n = len(matches)
plural = "operatie nemapata" if n == 1 else "operatii nemapate"
return HTMLResponse(
content=(
f'<span class="muted" style="font-size:12px;">'
f'Potriveste {n} {plural}: {exemple}.</span>'
)
)
# =========================================================================== #
# US-006 — Formate de coloane salvate: editare format data + stergere #
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
@@ -1510,6 +1659,11 @@ def _web_compute_preview(
# Mapare operatii (o singura incarcare — Eng#5)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# US-010/US-003 (paritate): preview-ul web trebuie sa aplice ACELEASI reguli text +
# validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin
# regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Detectie coercion flags din valorile stocate (VIN numeric)
coercion_flags_map: dict[int, list[str]] = {}
@@ -1553,6 +1707,8 @@ def _web_compute_preview(
mapping_meta=mapping_meta,
formula_columns=formula_columns,
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
)
key: str | None = None
@@ -2237,6 +2393,9 @@ async def web_confirma_import(
# Mapare operatii
mapping_meta = load_mapping_meta(conn, acct)
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU)
enqueued: list[dict] = []
@@ -2275,7 +2434,7 @@ async def web_confirma_import(
# Rezolva prestatii
prestatii = mapped.get("prestatii") or []
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops)
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules)
mapped["prestatii"] = resolved_p
# Canonicalizare
@@ -2319,6 +2478,8 @@ async def web_confirma_import(
if cur.rowcount == 0:
toctou.append(row_index)
else:
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p)
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
conn.execute("COMMIT")

View File

@@ -67,7 +67,9 @@
</div>
</div>
<!-- Panou dedicat pentru detaliul trimiterii (NU inline in tabel: poll-ul din tabel
ar sterge un expand inline). Gol pana la click pe un rand. -->
<div id="trimitere-detaliu"></div>
<!-- US-008: detaliul traieste acum INLINE, ca rand-sibling expandabil sub randul
selectat (#detaliu-{id} in _submissions.html); poll-ul de 15s se pune pe pauza
cat un rand e deschis (base.html). Acest div global e golit de rol (nu mai e
tinta de swap), pastrat doar ca ancora inerta. -->
<div id="trimitere-detaliu" hidden></div>
</section>

View File

@@ -254,4 +254,100 @@
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 4: Reguli automate pe text (operation_text_rules) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
<p class="muted" style="margin:0 0 12px; font-size:13px; max-width:680px;">
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring)
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice.
<strong>In coada</strong>: implicit oprit — regula rezolva codul dar tine randul pentru
verificare umana pana activezi „In coada".
</p>
{% if not text_rules %}
<div class="empty" style="margin-bottom:12px;">
Inca nu ai reguli. Ex: operatia contine «verificare» &rarr; OE-2.
Mapeaza automat operatii similare fara cod intern. Adauga prima regula mai jos.
</div>
{% endif %}
<div class="tablewrap">
<table>
<thead><tr>
<th>Daca operatia contine</th>
<th>Cod RAR</th>
<th>In coada</th>
<th>Actiuni</th>
</tr></thead>
<tbody>
{% for r in text_rules %}
<tr>
<td>
<form id="rt-del-{{ loop.index }}" hx-post="/mapari/reguli-text/sterge"
hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi regula «{{ r.pattern }}»?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="pattern" value="{{ r.pattern }}">
</form>
<div>contine <strong>«{{ r.pattern }}»</strong></div>
</td>
<td class="muted" style="font-size:12px;">
{{ r.cod_prestatie }}
</td>
<td class="muted" style="font-size:12px;">
{% if r.auto_send %}Auto (in coada){% else %}Manual (verificare){% endif %}
</td>
<td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-del-{{ loop.index }}"
style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</td>
</tr>
{% endfor %}
{# Rand de adaugare (mereu prezent ca placeholder, inclusiv in empty state). #}
<tr>
<td>
<form id="rt-add" hx-post="/mapari/reguli-text" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="text" name="pattern" required
placeholder="ex. verificare"
aria-label="Text continut in operatie"
style="width:100%; max-width:240px;"
hx-post="/mapari/reguli-text/preview"
hx-trigger="keyup delay:400ms"
hx-target="#rt-preview"
hx-swap="innerHTML"
hx-include="#rt-add">
</form>
</td>
<td>
<select name="cod_prestatie" form="rt-add" required aria-label="Cod RAR pentru regula text">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
</td>
<td>
{{ ui.autosend_toggle(form_id="rt-add", checked=False) }}
</td>
<td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-add">Adauga</button>
</td>
</tr>
{# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #}
<tr>
<td colspan="4" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -15,46 +15,71 @@
Sterge selectate
</button>
</div>
<div class="tablewrap">
<div class="tablewrap tabel-trimiteri">
<table>
<thead><tr>
<th style="width:28px;"><span class="muted" title="Selecteaza randuri blocate">&#10003;</span></th>
<th>#</th>
<th>Stare</th>
<th>Vehicul</th>
<th>Operatie</th>
<th>Data prestatie</th>
<th>Nr. prezentare RAR</th>
<th>Actualizat</th>
<th>Motiv</th>
<th class="col-chk"><span class="muted" title="Selecteaza randuri blocate">&#10003;</span></th>
<th class="col-id">#</th>
<th class="col-stare">Stare</th>
<th class="col-vehicul">Vehicul</th>
<th class="col-operatie">Operatie</th>
<th class="col-data">Data prestatie</th>
<th class="col-rar">Nr. prezentare RAR</th>
<th class="col-actualizat">Actualizat</th>
</tr></thead>
<tbody>
{% for r in rows %}
{# US-008: detaliul apare ca rand-sibling expandabil SUB acest rand (#detaliu-{id}),
nu in panoul global de la baza. Randul e clickabil/focusabil (toggle prin JS in
base.html: single-open + pauza poll). #}
<tr id="trimitere-row-{{ r.id }}"
class="trimitere-row"
data-detaliu-id="{{ r.id }}"
hx-get="/_fragments/trimitere/{{ r.id }}"
hx-target="#trimitere-detaliu"
hx-target="#detaliu-{{ r.id }}"
hx-swap="innerHTML"
hx-indicator="#ind-{{ r.id }}"
role="button" tabindex="0" aria-expanded="false"
aria-controls="detaliu-{{ r.id }}"
style="cursor:pointer;"
title="Click pentru detaliul complet">
<td onclick="event.stopPropagation();">
<td class="col-chk" onclick="event.stopPropagation();">
{% if r.gestionabil %}
<input type="checkbox" name="submission_id" value="{{ r.id }}"
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %}
</td>
<td class="muted">{{ r.id }}</td>
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
<td>
<td class="col-id muted" data-eticheta="#">
<span class="chevron" aria-hidden="true">&#9656;</span>{{ r.id }}</td>
<td class="col-stare" data-eticheta="Stare">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
</td>
<td class="col-vehicul" data-eticheta="Vehicul">
{{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
{% endif %}
</td>
<td>{{ r.prez.operatie }}</td>
<td>{{ r.prez.data_prestatie }}</td>
<td>{{ r.id_prezentare or '—' }}</td>
<td class="muted">{{ r.updated_at }}</td>
<td class="muted" style="white-space:normal; max-width:280px;">{{ r.motiv }}</td>
<td class="col-operatie" data-eticheta="Operatie">
<div>{{ r.prez.operatie }}</div>
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<div class="muted cod-rar-sub">cod RAR: {{ r.prez.cod_rar }}</div>
{% else %}
<div class="muted cod-rar-sub">nemapat</div>
{% endif %}
</td>
<td class="col-data" data-eticheta="Data prestatie">{{ r.prez.data_prestatie }}</td>
<td class="col-rar" data-eticheta="Nr. prezentare RAR">{{ r.id_prezentare or '—' }}</td>
<td class="col-actualizat muted" data-eticheta="Actualizat">{{ r.updated_at }}</td>
</tr>
{# US-008: rand-sibling de detaliu, ascuns pana la deschidere. Placeholder „Se
incarca…" prin hx-indicator cat raspunde HTMX. #}
<tr class="detaliu-rand" hidden>
<td colspan="8">
<span id="ind-{{ r.id }}" class="htmx-indicator muted"
style="padding:8px 4px;">Se incarca&hellip;</span>
<div id="detaliu-{{ r.id }}"></div>
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -1,11 +1,13 @@
{% from "_eroare.html" import card_erori %}
{% import '_macros.html' as ui %}
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
{# US-008: conectorul detaliului = fundal subtil + border-top pe randul-sibling
(.detaliu-rand, base.html), NU border-left accent (evita AI-slop). #}
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--line);">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
<button type="button" style="margin-left:auto; background:var(--card); color:var(--muted); border-color:var(--line);"
onclick="document.getElementById('trimitere-detaliu').innerHTML='';">
onclick="window.inchideDetaliu && window.inchideDetaliu('{{ id }}');">
Inchide
</button>
</div>
@@ -51,12 +53,12 @@
{% if gestionabil %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;">
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#trimitere-detaliu" hx-swap="innerHTML" style="margin:0;">
hx-target="#detaliu-{{ id }}" hx-swap="innerHTML" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Re-pune in coada</button>
</form>
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#trimitere-detaliu" hx-swap="innerHTML"
hx-target="#detaliu-{{ id }}" hx-swap="innerHTML"
hx-confirm="Stergi definitiv aceasta trimitere din coada?" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
@@ -77,7 +79,7 @@
{% for op in nemapate_inline %}
{% set top = op.suggestions[0] if op.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form hx-post="/trimitere/{{ id }}/mapeaza" hx-target="#trimitere-detaliu" hx-swap="innerHTML"
<form hx-post="/trimitere/{{ id }}/mapeaza" hx-target="#detaliu-{{ id }}" hx-swap="innerHTML"
style="margin:0 0 12px; padding:10px; border:1px solid var(--line); border-radius:8px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="cod_op_service" value="{{ op.cod_op_service }}">
@@ -126,7 +128,7 @@
{% endif %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#trimitere-detaliu" hx-swap="innerHTML">
hx-target="#detaliu-{{ id }}" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
@@ -158,13 +160,17 @@
<script>
(function() {
/* Vizibilitate (design review): scroll la panou + evidentiaza randul selectat. */
var panou = document.getElementById('trimitere-detaliu');
if (panou) panou.scrollIntoView({behavior: 'smooth', block: 'nearest'});
document.querySelectorAll('tr[id^="trimitere-row-"]').forEach(function(tr) {
tr.style.outline = '';
});
/* US-008: detaliul traieste acum in randul-sibling #detaliu-{id}. Asiguram ca randul
de detaliu e vizibil (la re-swap dupa corectie/mapare HTMX poate readuce continut
intr-un container ascuns) si ca randul declansator e marcat ca deschis. Single-open
+ pauza poll sunt gestionate global in base.html. */
var cont = document.getElementById('detaliu-{{ id }}');
if (cont) {
var detRow = cont.closest('tr.detaliu-rand');
if (detRow) detRow.hidden = false;
cont.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
var rand = document.getElementById('trimitere-row-{{ id }}');
if (rand) rand.style.outline = '2px solid var(--accent)';
if (rand && window.marcheazaDetaliuDeschis) window.marcheazaDetaliuDeschis(rand);
})();
</script>

View File

@@ -162,6 +162,52 @@
.dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
padding:5px 12px; min-height:32px; }
.dt-pager button:disabled { opacity:.45; cursor:default; }
/* === Tabel trimiteri (PRD 5.8 US-007): fara scroll orizontal. SCOPAT prin
.tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
.tabel-trimiteri table { table-layout:fixed; }
.tabel-trimiteri th, .tabel-trimiteri td { white-space:normal; word-break:break-word; vertical-align:top; }
.tabel-trimiteri .col-chk { width:30px; }
.tabel-trimiteri .col-id { width:48px; }
.tabel-trimiteri .col-stare { width:104px; }
.tabel-trimiteri .col-data { width:104px; }
.tabel-trimiteri .col-rar { width:96px; }
.tabel-trimiteri .col-actualizat { width:128px; }
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* === Detaliu inline (PRD 5.8 US-008): rand-sibling expandabil sub randul selectat. === */
/* Chevron de stare (▸ inchis / ▾ deschis), rotit prin schimbarea glifei in JS. */
.tabel-trimiteri .chevron { display:inline-block; color:var(--muted); font-size:11px;
width:1.1em; text-align:center; margin-right:2px; }
/* Randul deschis: fundal evidentiat (nu doar culoare de text -> a11y). */
.tabel-trimiteri tr.rand-deschis > td { background:#1d212b; }
[data-theme="light"] .tabel-trimiteri tr.rand-deschis > td { background:#eef1f6; }
/* Conectorul detaliului = fundal subtil + border-top (NU border-left accent / slop). */
.tabel-trimiteri tr.detaliu-rand > td { padding:0; border-top:2px solid var(--accent);
background:color-mix(in srgb, var(--accent) 6%, var(--card)); }
.tabel-trimiteri tr.detaliu-rand .card { margin:10px; }
/* `hidden` trebuie sa invinga `display:block` din banda <768 (specificitate). */
.tabel-trimiteri tr.detaliu-rand[hidden] { display:none; }
/* 768-1024px: ascunde Actualizat (e in detaliu) -> 7 coloane, fara scroll */
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; }
}
/* Tinta de atins >=44px pe touch (chevron-ul e ancora de toggle). */
@media (pointer:coarse) {
.tabel-trimiteri .chevron { min-width:44px; min-height:44px; line-height:44px; }
}
/* <768px: card per rand (eticheta:valoare stivuit), nu tabel -> fara scroll orizontal */
@media (max-width:767px) {
.tabel-trimiteri table { table-layout:auto; }
.tabel-trimiteri thead { display:none; }
.tabel-trimiteri table, .tabel-trimiteri tbody, .tabel-trimiteri tr, .tabel-trimiteri td { display:block; width:auto; }
.tabel-trimiteri tr { border:1px solid var(--line); border-radius:8px; padding:8px 12px; margin-bottom:10px; }
.tabel-trimiteri td { border-bottom:none; padding:4px 0; display:flex; gap:10px; align-items:baseline; }
.tabel-trimiteri td::before { content:attr(data-eticheta); color:var(--muted); font-size:12px;
flex:0 0 auto; min-width:120px; }
.tabel-trimiteri td.col-chk { display:none; }
}
</style>
</head>
<body>
@@ -371,5 +417,94 @@
document.body.addEventListener('htmx:afterSettle', function(e) { enhance(e.target); });
})();
</script>
<script>
// Detaliu trimitere INLINE (PRD 5.8 US-008): randul de detaliu (#detaliu-{id}) e
// un <tr class="detaliu-rand"> sibling, ascuns pana la deschidere. La click pe rand:
// - se inchid celelalte detalii (un singur rand deschis o data);
// - se arata randul-sibling (placeholder „Se incarca…" prin hx-indicator);
// - chevron ▸/▾ + fundal evidentiat + aria-expanded sincronizate.
// Re-click pe acelasi rand inchide fara re-fetch. Cat un detaliu e deschis, poll-ul
// de 15s (#submissions-wrap) e pus pe pauza (D-eng-2) ca lista sa nu se miste sub
// operator. Delegare pe document.body -> supravietuieste swap-urilor HTMX ale listei.
(function() {
function chevron(row, on) {
var c = row.querySelector('.chevron');
if (c) c.innerHTML = on ? '&#9662;' : '&#9656;'; // ▾ / ▸
}
function setExpanded(row, on) {
row.setAttribute('aria-expanded', on ? 'true' : 'false');
if (on) row.classList.add('rand-deschis'); else row.classList.remove('rand-deschis');
chevron(row, on);
}
function detRowFor(id) {
var cont = document.getElementById('detaliu-' + id);
return cont ? cont.closest('tr.detaliu-rand') : null;
}
function closeOne(row) {
var id = row.getAttribute('data-detaliu-id');
var cont = document.getElementById('detaliu-' + id);
if (cont) cont.innerHTML = '';
var dr = detRowFor(id);
if (dr) dr.hidden = true;
setExpanded(row, false);
}
function closeAllDetalii(except) {
document.querySelectorAll('tr.trimitere-row[aria-expanded="true"]').forEach(function(r) {
if (r !== except) closeOne(r);
});
}
// Expus pentru butonul „Inchide" din _trimitere_detaliu.html: goleste containerul
// randului CURENT si readuce focusul pe randul declansator.
window.inchideDetaliu = function(id) {
var row = document.getElementById('trimitere-row-' + id);
if (row) { closeOne(row); row.focus(); }
else {
var cont = document.getElementById('detaliu-' + id);
if (cont) cont.innerHTML = '';
var dr = detRowFor(id);
if (dr) dr.hidden = true;
}
};
// Expus pentru scriptul fragmentului: marcheaza randul ca deschis dupa un re-swap
// (corectie/mapare inline), inchizand orice alt detaliu ramas deschis.
window.marcheazaDetaliuDeschis = function(row) {
closeAllDetalii(row);
setExpanded(row, true);
};
// htmx:beforeRequest — single point: pauza poll + toggle deschidere/inchidere.
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail && evt.detail.elt;
if (!elt) return;
// Pauza poll periodic cat un detaliu e deschis (cererea vine chiar de pe wrap).
if (elt.id === 'submissions-wrap' &&
document.querySelector('tr.detaliu-rand:not([hidden])')) {
evt.preventDefault();
return;
}
if (!(elt.classList && elt.classList.contains('trimitere-row'))) return;
var id = elt.getAttribute('data-detaliu-id');
if (elt.getAttribute('aria-expanded') === 'true') {
// Re-click pe randul deschis -> inchide, fara re-fetch.
evt.preventDefault();
window.inchideDetaliu(id);
return;
}
// Deschidere: inchide celelalte, arata randul-sibling (placeholder loading).
closeAllDetalii(elt);
var dr = detRowFor(id);
if (dr) dr.hidden = false;
setExpanded(elt, true);
});
// Tastatura (role=button): Enter/Space deschid/inchid randul focusat.
document.body.addEventListener('keydown', function(evt) {
var t = evt.target;
if (!(t && t.classList && t.classList.contains('trimitere-row'))) return;
if (evt.key === 'Enter' || evt.key === ' ' || evt.key === 'Spacebar') {
evt.preventDefault();
t.click();
}
});
})();
</script>
</body>
</html>