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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
226
app/mapping.py
226
app/mapping.py
@@ -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=?",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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» → 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>
|
||||
|
||||
@@ -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">✓</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">✓</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">▸</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…</span>
|
||||
<div id="detaliu-{{ r.id }}"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? '▾' : '▸'; // ▾ / ▸
|
||||
}
|
||||
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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
544
docs/prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md
Normal file
544
docs/prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# PRD 5.8 — UX tabel trimiteri (detaliu inline, fara scroll, cod RAR) + reguli mapare pe text
|
||||
|
||||
**Stare**: verify-pass
|
||||
|
||||
> Proces: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis`.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Patru imbunatatiri UX, fara schimbarea fluxului de trimitere:
|
||||
1. Panoul de detaliu al unei trimiteri apare **sub randul selectat** (rand expandabil), nu la
|
||||
baza tabelului (azi `#trimitere-detaliu` traieste dupa `<table>` in `_coada.html:72`).
|
||||
2. Tabelul de trimiteri **nu mai are scroll orizontal** (confirmat live: `.tablewrap`
|
||||
`scrollWidth=1189 > clientWidth=1010` la 1280px). Solutie agreata: mut **Motiv** in detaliu,
|
||||
stiveasc **cod RAR sub codul operatiei** in coloana Operatie, **VIN sub nr. inmatriculare**
|
||||
(deja stivuit) si **scurtez etichetele de Stare**.
|
||||
3. In coloana Operatie apare si **codul de operatie RAR rezolvat** (`cod_prestatie`), nu doar
|
||||
codul/denumirea venita din API/import.
|
||||
4. Pagina **Mapari** capata **reguli automate pe text** (substring): ex. operatie care *contine*
|
||||
„verificare" → cod RAR `OE-2`. Se aplica la ingestie si la re-rezolvarea blocajelor.
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- Fara `match_type` multiplu (starts_with / regex). Doar **contains** (substring), insensibil la
|
||||
diacritice si majuscule. Daca apare nevoia, e alt PRD.
|
||||
- Fara modificari la masina de stari, la worker, la contractul RAR sau la idempotenta.
|
||||
- Fara schimbarea logicii de mapare exacta `cod_op_service → cod_prestatie` (ramane, are precedenta).
|
||||
- Fara redesign general al dashboard-ului; doar tabelul de trimiteri + sectiunea noua din Mapari.
|
||||
- Fara reguli text per-import sau globale: regulile sunt **per cont** (ca `operations_mapping`).
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
> Backend + UI pentru acelasi comportament = stories separate. Regulile pe text (US-001..004) si
|
||||
> UX-ul tabelului (US-005..008) sunt **independente** — se pot livra in paralel.
|
||||
|
||||
---
|
||||
|
||||
### US-001: Schema + persistenta reguli text de mapare
|
||||
**Ca** dezvoltator **vreau** o tabela `operation_text_rules` **pentru ca** regulile pe substring
|
||||
sa fie durabile per cont, langa `operations_mapping`.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/schema.sql`, `app/mapping.py`, `tests/test_mapping_text_rules.py`
|
||||
- **Test intai (RED)**: `tests/test_mapping_text_rules.py` —
|
||||
`test_save_text_rule_persista`, `test_load_text_rules_per_cont`,
|
||||
`test_delete_text_rule`, `test_unic_per_cont_pattern`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Tabela noua: `id, account_id (NOT NULL, FK accounts 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`, cu `UNIQUE(account_id, pattern)`.
|
||||
- [x] **`auto_send` DEFAULT 0** (decizie CEO 2026-06-24, siguranta): o regula pe substring are blast
|
||||
radius mai mare decat o mapare exacta (potriveste si operatii viitoare nevazute), iar
|
||||
`FINALIZATA` e ireversibil la RAR. O regula noua rezolva codul dar TINE randul pentru verificare
|
||||
umana (`needs_mapping`) pana cand operatorul activeaza explicit „In coada". UI-ul (US-004) reflecta
|
||||
toggle-ul pe 0 implicit.
|
||||
- [x] `load_text_rules(conn, account_id)` intoarce lista ordonata `[{pattern, cod_prestatie,
|
||||
auto_send, priority}]` (ordine: `priority ASC, id ASC`), aplicand `account_or_default`.
|
||||
- [x] `save_text_rule(conn, account_id, pattern, cod_prestatie, auto_send)` face upsert pe
|
||||
`(account_id, pattern)`; `delete_text_rule(...)` sterge.
|
||||
- [x] Schema e idempotenta (`CREATE TABLE IF NOT EXISTS`) si nu strica DB-uri existente.
|
||||
- [x] `python3 -m pytest tests/test_mapping_text_rules.py -q` trece.
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-002: `resolve_prestatii` aplica reguli text (substring, dupa maparea exacta)
|
||||
**Ca** integrator **vreau** ca o operatie nemapata sa primeasca cod RAR din prima regula text care
|
||||
da match **pentru ca** sa nu mai intre in editor degeaba.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/mapping.py`, `tests/test_mapping.py`
|
||||
- **Test intai (RED)**: `tests/test_mapping.py` —
|
||||
`test_regula_text_contains_rezolva`, `test_mapare_exacta_bate_regula_text`,
|
||||
`test_regula_text_insensibila_diacritice_caz`, `test_regula_text_cod_invalid_in_nomenclator_ramane_nemapat`,
|
||||
`test_prima_regula_dupa_priority_castiga`
|
||||
- **Acceptance criteria**:
|
||||
- [x] `resolve_prestatii(prestatii, mapping, valid_codes=None, text_rules=None)` — semnatura
|
||||
**aditiva** (param nou optional, default `None` = comportament actual neschimbat).
|
||||
- [x] **Precedenta** stricta: `cod_prestatie` direct valid > mapare exacta `cod_op_service` >
|
||||
**reguli text** > nemapat. Regula text se incearca DOAR cand nu exista cod valid si op nu e
|
||||
in `mapping`.
|
||||
- [x] Match = substring pe textul operatiei (`denumire` daca exista, altfel `cod_op_service`),
|
||||
normalizat cu `normalize_for_match` (fara diacritice, uppercase, spatii colapsate) — pe
|
||||
ambele parti (pattern si text).
|
||||
- [x] Daca regula da match dar `cod_prestatie`-ul ei **nu** e in `valid_codes` → operatia ramane
|
||||
**nemapata** (nu trimitem cod invalid; coerent cu regula „RAR accepta doar coduri din
|
||||
nomenclator", `mapping.py:101-105`).
|
||||
- [x] La match multiplu castiga prima dupa ordinea `load_text_rules` (priority, id).
|
||||
- [x] `python3 -m pytest tests/test_mapping.py -q` trece (inclusiv testele vechi).
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-003: Reguli text active la ingestie + la re-rezolvare
|
||||
**Ca** service **vreau** ca regulile text sa actioneze pe prezentari noi (API + import) **si** sa
|
||||
deblocheze randurile `needs_mapping` existente cand salvez o regula noua.
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `app/mapping.py` (`classify_prezentare` + `reresolve_account`), `app/api/v1/router.py`,
|
||||
`app/api/v1/import_router.py`, `app/web/routes.py`, `tests/test_reresolve_text_rules.py`
|
||||
- **Test intai (RED)**: `tests/test_reresolve_text_rules.py` —
|
||||
`test_ingestie_api_aplica_regula_text`, `test_ingestie_import_aplica_regula_text`,
|
||||
`test_corectie_web_aplica_regula_text`, `test_salvare_regula_rerezolva_blocate`
|
||||
- **Acceptance criteria**:
|
||||
- [x] **TOATE cele 6 apeluri `resolve_prestatii` primesc `text_rules` SI `valid_codes`** (decizie
|
||||
eng D-eng-1): `mapping.py:276` (in `classify_prezentare`), `mapping.py:441` (reresolve_account),
|
||||
`import_router.py:204`, `import_router.py:1079`, `routes.py:984` (corectie web `needs_data`),
|
||||
`routes.py:2278` (import web). Cele 4 care azi paseaza `valid_codes=None` incep sa paseze
|
||||
`valid_codes=load_nomenclator_codes(conn) or None` — altfel AC-ul de validare (US-002) nu se
|
||||
onoreaza pe acele cai.
|
||||
- [x] **`classify_prezentare` capata param `text_rules`** (A3): seam-ul partajat API + `/valideaza`
|
||||
(invariant 5.2) il primeste si ambii apelanti (`create_prezentari` + ruta `/valideaza`) fac
|
||||
`load_text_rules(...)` si il paseaza — altfel dry-run-ul diverge de trimiterea reala.
|
||||
- [x] **`text_rules`/`valid_codes` se incarca o data per cerere/batch, NU per rand** (T2):
|
||||
`routes.py:2278` (loop import) si `reresolve_account` (loop randuri) le incarca inainte de bucla.
|
||||
- [x] O prezentare API/import a carei operatie da match pe o regula intra `queued` (sau respectand
|
||||
`auto_send`), NU `needs_mapping`.
|
||||
- [x] La salvarea unei reguli noi, `reresolve_account` re-evalueaza blocajele si deblocheaza
|
||||
randurile care acum dau match (acelasi mecanism ca la `save_mapping`).
|
||||
- [x] `cod_prestatie`-ul rezolvat din regula respecta validarea fata de nomenclator (US-002).
|
||||
- [x] `python3 -m pytest -q` trece integral.
|
||||
- **Verificare E2E**: `POST /v1/prezentari` cu operatie „Verificare X" (fara mapare exacta), regula
|
||||
`contine "verificare" → OE-2` salvata in prealabil → raspuns cu `status=queued`, fara `nemapate`.
|
||||
|
||||
### US-004: UI Mapari — sectiune „Reguli automate (text)"
|
||||
**Ca** operator **vreau** sa adaug/sterg reguli text din pagina Mapari **pentru ca** sa automatizez
|
||||
maparea fara cod intern per operatie.
|
||||
|
||||
- **Depinde de**: US-001 (UI poate merge in paralel cu US-002/003; rezolvarea efectiva cere US-003)
|
||||
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_mapari.html`,
|
||||
`tests/test_web_mapari_text_rules.py`
|
||||
- **Test intai (RED)**: `tests/test_web_mapari_text_rules.py` —
|
||||
`test_post_regula_text_salveaza_si_rerezolva`, `test_post_sterge_regula`,
|
||||
`test_regula_text_scoped_pe_cont_sesiune`, `test_csrf_necesar`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Sectiune noua in `_mapari.html` (a 4-a, langa „De rezolvat" / „Mapari salvate" / „Formate"):
|
||||
tabel cu coloanele **Daca operatia contine** (text) | **Cod RAR** (select din nomenclator) |
|
||||
**In coada** (toggle `auto_send`, **implicit OFF** — vezi US-001 decizia CEO) | **Actiuni**
|
||||
(sterge), plus un rand de adaugare.
|
||||
- [x] Formularul foloseste `auto_send`-toggle si `select` din nomenclator existente (reuse macro),
|
||||
cu `csrf_token`; rutele `POST /mapari/reguli-text` si `POST /mapari/reguli-text/sterge`
|
||||
sunt **account-scoped pe sesiune** (`require_login`), ca rutele Mapari actuale.
|
||||
- [x] La salvare se cheama `save_text_rule` + `reresolve_account`, cu mesaj „Regula salvata.
|
||||
Deblocate: N" si trigger `trimiteriChanged` (refresh lista), exact ca maparea inline (5.7).
|
||||
- [x] Textul afisat e clar ca match-ul e „contine" (substring), nu egalitate.
|
||||
- [x] **Empty state** (nicio regula): o linie explicativa cu exemplu — „Inca nu ai reguli.
|
||||
Ex: operatia contine «verificare» → OE-2. Mapeaza automat operatii similare fara cod
|
||||
intern." — urmata de randul de adaugare gata afisat ca placeholder. (Pass 2)
|
||||
- [x] **Stari**: succes = mesaj „Regula salvata. Deblocate: N"; eroare cod invalid in
|
||||
nomenclator = mesaj inline „Cod RAR necunoscut in nomenclator", fara salvare. (Pass 2)
|
||||
- [x] `python3 -m pytest tests/test_web_mapari_text_rules.py -q` trece.
|
||||
- **Verificare E2E**: gstack browser pe `/?tab=mapari` — adaug regula `verificare → OE-2`,
|
||||
confirm ca apare in lista si ca un rand `De rezolvat` cu „verificare" dispare dupa salvare.
|
||||
|
||||
---
|
||||
|
||||
### US-005: `cod_rar` distinct in datele de afisare ale randului
|
||||
**Ca** dezvoltator **vreau** un camp separat pentru codul RAR rezolvat **pentru ca** sa-l pot afisa
|
||||
sub codul operatiei fara sa-l confund cu `cod_op_service`.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/payload_view.py`, `tests/test_payload_view.py`
|
||||
- **Test intai (RED)**: `tests/test_payload_view.py` —
|
||||
`test_cod_rar_prezent_cand_mapat`, `test_cod_rar_gol_cand_nemapat`,
|
||||
`test_operatie_ramane_denumire_sau_op`
|
||||
- **Acceptance criteria**:
|
||||
- [x] `prezentare_din_payload` intoarce in plus `cod_rar` = `cod_prestatie` (uppercase, strip
|
||||
„.0") sau `EMPTY` cand lipseste. `operatie` si `cod` raman neschimbate (compat).
|
||||
- [x] Cand operatia e nemapata (`cod_prestatie` None), `cod_rar` == `EMPTY` (nu cade pe
|
||||
`cod_op_service`).
|
||||
- [x] `python3 -m pytest tests/test_payload_view.py -q` trece.
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-006: Etichete de Stare compacte (pill)
|
||||
**Ca** operator **vreau** etichete scurte in coloana Stare **pentru ca** randul sa fie ingust si
|
||||
lizibil.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/labels.py`, `tests/test_labels.py`
|
||||
- **Test intai (RED)**: `tests/test_labels.py` —
|
||||
`test_eticheta_scurta_pentru_fiecare_stare`, `test_eticheta_lunga_ramane_pentru_subtext`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Eticheta scurta expusa ca **functie noua separata `eticheta_scurta(status) -> str`** (dict
|
||||
propriu + garda `KeyError`), NU ca al 4-lea element in tuple-ul `Eticheta` (3 elemente azi,
|
||||
despachetat in template-uri — l-ar rupe). `eticheta_stare` ramane neatins (text lung pentru
|
||||
subtext/tooltip in detaliu). Set propus:
|
||||
`queued→"In coada"`, `sending→"Se trimite"`, `sent→"Finalizat"`,
|
||||
`needs_mapping→"De mapat"`, `needs_data→"Date lipsa"`, `error→"Eroare"`.
|
||||
- [x] Pill-ul din tabel foloseste eticheta scurta; clasele CSS `s-*` (culorile) raman.
|
||||
- [x] Stare neacoperita ridica `KeyError` (ca azi, ca sa prinda stari noi).
|
||||
- [x] `python3 -m pytest tests/test_labels.py -q` trece.
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-007: Tabel trimiteri fara scroll orizontal (coloane re-aranjate)
|
||||
**Ca** operator **vreau** sa vad tot randul fara scroll lateral **pentru ca** sa citesc starea si
|
||||
operatia dintr-o privire.
|
||||
|
||||
- **Depinde de**: US-005, US-006
|
||||
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/routes.py` (builder rand),
|
||||
`app/web/templates/base.html` (CSS), `tests/test_web_submissions.py`
|
||||
- **Test intai (RED)**: `tests/test_web_submissions.py` —
|
||||
`test_tabel_nu_are_coloana_motiv`, `test_operatie_contine_cod_rar`,
|
||||
`test_pill_eticheta_scurta`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Coloana **Motiv** eliminata din `<thead>`/`<tbody>` (continutul ei se vede in detaliu —
|
||||
`_trimitere_detaliu.html:36-39` deja afiseaza Motiv).
|
||||
- [x] Coloana **Operatie**: linia 1 = `prez.operatie` (denumire/cod API), linia 2 = `prez.cod_rar`
|
||||
muted (`cod RAR: XXX`); cand nemapat afiseaza „nemapat" muted.
|
||||
- [x] Coloana **Vehicul**: nr. inmatriculare + VIN scurt stivuit (deja exista, pastrat).
|
||||
- [x] Pill Stare = eticheta scurta (US-006).
|
||||
- [x] CSS: la 1280px `.tablewrap` **nu** mai depaseste (`scrollWidth <= clientWidth`); se permite
|
||||
`white-space: normal` controlat pe coloanele text si latimi rezonabile, fara a rupe alte
|
||||
tabele care folosesc `.tablewrap` (scopare prin clasa, ex. `.tabel-trimiteri`).
|
||||
- [x] **Pill compact pastreaza info lunga**: eticheta scurta (US-006) e in pill; textul lung
|
||||
(subtext din `eticheta_stare`) ramane disponibil ca `title=` (tooltip) pe pill si in
|
||||
panoul de detaliu — nu se pierde informatie. (Pass 5)
|
||||
- [x] **Responsive** (Pass 6): `>=1024px` toate cele 8 coloane; `768-1024px` ascund coloana
|
||||
**Actualizat** (e in detaliu) → 7 coloane fara scroll; `<768px` randurile devin **carduri**
|
||||
(eticheta:valoare stivuit, pill + chevron sus), nu tabel — fara scroll orizontal.
|
||||
- [x] Secundarul „cod RAR" / „nemapat" e >=12px cu contrast >=4.5:1 (coerent cu `vin_scurt`
|
||||
existent); nu se transmite stare DOAR prin culoare (textul „nemapat" o spune). (Pass 6)
|
||||
- [x] `python3 -m pytest tests/test_web_submissions.py -q` trece.
|
||||
- **Verificare E2E**: gstack browser pe `/` la 1280px, 1024px, 900px si 375px — `scrollWidth <=
|
||||
clientWidth` la fiecare (sau carduri sub 768px); coloana Operatie arata ambele coduri.
|
||||
|
||||
### US-008: Detaliu trimitere ca rand expandabil sub randul selectat
|
||||
**Ca** operator **vreau** ca detaliul sa apara imediat sub randul pe care l-am dat click **pentru
|
||||
ca** sa nu mai caut la baza tabelului.
|
||||
|
||||
- **Depinde de**: US-007
|
||||
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/_coada.html`,
|
||||
`app/web/templates/_trimitere_detaliu.html`, `app/web/routes.py`,
|
||||
`tests/test_web_detaliu_inline.py`
|
||||
- **Test intai (RED)**: `tests/test_web_detaliu_inline.py` —
|
||||
`test_fragment_detaliu_se_randeaza_in_container_pe_rand`,
|
||||
`test_un_singur_detaliu_deschis`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Fiecare rand are un rand-sibling de detaliu `<tr class="detaliu-rand">` cu
|
||||
`<td colspan=N><div id="detaliu-{{r.id}}"></div></td>`, ascuns implicit.
|
||||
- [x] Click pe rand face `hx-get="/_fragments/trimitere/{id}"` cu `hx-target="#detaliu-{id}"`,
|
||||
`hx-swap="innerHTML"`; la deschidere se inchid celelalte detalii deschise (un singur rand
|
||||
expandat o data).
|
||||
- [x] **Indicator vizual** (Pass 1/4): chevron `▸`/`▾` la inceputul randului (rotit cand e
|
||||
deschis) + randul deschis primeste **fundal evidentiat** (`#1d212b` dark / echivalent light).
|
||||
Detaliul se leaga prin **fundal subtil + `border-top`** (stilul existent al sectiunii de
|
||||
mapare inline `_trimitere_detaliu.html`), NU `border-left` accent (evita pattern AI-slop).
|
||||
- [x] **Stare loading** (Pass 2): la click, pana raspunde HTMX, containerul randului arata un
|
||||
placeholder discret („Se incarca…") prin `hx-indicator`, ca operatorul sa vada ca s-a
|
||||
inregistrat clicul.
|
||||
- [x] **Accesibilitate** (Pass 6): randul clickabil e focusabil la tastatura (`tabindex="0"`,
|
||||
`role="button"`, `aria-expanded` sincronizat); Enter/Space deschid/inchid; tinta de atins
|
||||
(chevron/checkbox) >=44px pe touch.
|
||||
- [x] Butonul „Inchide" din `_trimitere_detaliu.html` goleste containerul randului curent (nu mai
|
||||
tinteste `#trimitere-detaliu` global) si readuce focusul pe randul declansator.
|
||||
- [x] `#trimitere-detaliu` global din `_coada.html:72` este eliminat sau golit de rol.
|
||||
- [x] **Poll-ul de refresh (15s, `_coada.html`) se pune pe pauza cat timp un rand e expandat** si
|
||||
se reia la inchidere (decizie eng D-eng-2). Fara flicker, fara pierdere de scroll/focus in
|
||||
timpul citirii; lista nu se misca sub operator. (NU re-fetch dupa swap, NU excludere de rand.)
|
||||
- [x] `python3 -m pytest tests/test_web_detaliu_inline.py -q` trece.
|
||||
- **Verificare E2E**: gstack browser pe `/` — click pe randul #N: detaliul apare imediat sub el
|
||||
(`detaliuTop` intre `rowTop` si randul urmator), nu la baza; click pe alt rand muta detaliul.
|
||||
|
||||
---
|
||||
|
||||
> **Stories de expansiune (CEO review 2026-06-24, SELECTIVE EXPANSION).** US-009..011 acceptate in scope.
|
||||
> Cresc increderea si observabilitatea regulilor text; nu schimba fluxul de trimitere. Livrabile in Val 4.
|
||||
|
||||
### US-009: Preview pre-salvare regula text (cate operatii potriveste)
|
||||
**Ca** operator **vreau** sa vad cate operatii potriveste o regula INAINTE sa o salvez **pentru ca**
|
||||
sa nu salvez un pattern prea lacom (perechea naturala a `auto_send=0`).
|
||||
|
||||
- **Depinde de**: US-001 (reuse agregarea `pending_unmapped`); independent de US-002/003
|
||||
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_mapari.html`, `tests/test_web_mapari_preview_regula.py`
|
||||
- **Test intai (RED)**: `tests/test_web_mapari_preview_regula.py` —
|
||||
`test_preview_numara_potriviri`, `test_preview_intoarce_exemple`, `test_preview_pattern_gol`,
|
||||
`test_preview_scoped_pe_cont`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Ruta `POST /mapari/reguli-text/preview` (account-scoped pe sesiune, CSRF) primeste `pattern`,
|
||||
normalizeaza cu `normalize_for_match`, numara operatiile distincte nemapate ale contului
|
||||
(`needs_mapping`, reuse `pending_unmapped`) al caror text **contine** pattern-ul + intoarce
|
||||
pana la 3 exemple `{cod_op_service, denumire}`. **NU salveaza nimic**.
|
||||
- [x] Fragment HTMX afisat sub randul de adaugare la schimbarea pattern-ului (`hx-trigger="keyup
|
||||
delay:400ms"`): „Potriveste N operatii nemapate: «...», «...»" sau „Nicio potrivire acum".
|
||||
- [x] Pattern gol → fara apel/fragment gol (nu numara „tot").
|
||||
- [x] `python3 -m pytest tests/test_web_mapari_preview_regula.py -q` trece.
|
||||
- **Verificare E2E**: gstack browser pe `/?tab=mapari` — tastez „verificare" in pattern → vad „Potriveste
|
||||
N operatii: ..." inainte de Salveaza.
|
||||
|
||||
### US-010: Telemetrie hit regula text in `app_events`
|
||||
**Ca** operator/admin **vreau** sa stiu ce regula a rezolvat ce submission **pentru ca** sa pot
|
||||
raspunde la „de ce a primit randul asta codul OE-2?".
|
||||
|
||||
- **Depinde de**: US-002 (rezolvare) + US-003 (active la ingestie)
|
||||
- **Fisiere**: `app/mapping.py` (adnotare item rezolvat-prin-regula), `app/observ.py` (reuse `log_event`),
|
||||
apelantii cu `conn` (`create_prezentari`, `reresolve_account`, import), `tests/test_text_rule_telemetry.py`
|
||||
- **Test intai (RED)**: `tests/test_text_rule_telemetry.py` —
|
||||
`test_hit_regula_emite_app_event`, `test_mapare_exacta_nu_emite_text_rule_hit`,
|
||||
`test_event_redactat_si_scoped_pe_cont`
|
||||
- **Acceptance criteria**:
|
||||
- [x] `resolve_prestatii` adnoteaza itemul rezolvat-prin-regula cu pattern-ul sursa (camp aditiv pe dict,
|
||||
ex. `cod_sursa="text_rule:<pattern>"`; payload-harmless — RAR citeste doar `cod_prestatie`).
|
||||
- [x] Apelantii cu `conn` emit `log_event("text_rule_hit", {...})` in `app_events` cu
|
||||
`{submission_id, account_id, pattern, cod_prestatie}`, redactat prin `app/security` (fara PII).
|
||||
Maparea exacta NU emite `text_rule_hit`.
|
||||
- [x] Vizibil in tab-ul „Jurnal" (5.6), scoped pe cont pentru non-admin (mecanismul existent).
|
||||
- [x] `python3 -m pytest tests/test_text_rule_telemetry.py -q` trece.
|
||||
- **Verificare E2E**: `POST /v1/prezentari` cu operatie ce da match pe regula → eveniment `text_rule_hit`
|
||||
vizibil in Jurnal cu pattern-ul si codul.
|
||||
|
||||
### US-011: Avertizare overlap intre reguli text (neblocant)
|
||||
**Ca** operator **vreau** un avertisment cand o regula noua se suprapune cu una existenta **pentru ca**
|
||||
sa inteleg de ce o regula „castiga" inaintea alteia.
|
||||
|
||||
- **Depinde de**: US-001 (load) + US-004 (UI salvare)
|
||||
- **Fisiere**: `app/mapping.py` (helper pur `text_rules_overlap`), `app/web/routes.py`,
|
||||
`app/web/templates/_mapari.html`, `tests/test_mapping_overlap.py`, `tests/test_web_mapari_overlap.py`
|
||||
- **Test intai (RED)**: `tests/test_mapping_overlap.py` — `test_overlap_substring_ambele_directii`,
|
||||
`test_fara_overlap`, `test_overlap_normalizat_diacritice`; `tests/test_web_mapari_overlap.py` —
|
||||
`test_salvare_cu_overlap_arata_avertisment_dar_salveaza`
|
||||
- **Acceptance criteria**:
|
||||
- [x] Helper pur `text_rules_overlap(pattern, existing_rules) -> list[dict]`: overlap = un pattern
|
||||
normalizat e substring al celuilalt (oricare directie). Determinist, fara DB.
|
||||
- [x] La salvare (ruta US-004), daca exista overlap, mesaj inline **neblocant**: „Se suprapune cu
|
||||
regula «X» → COD; ordinea (priority, id) decide care se aplica prima." **Salvarea continua**
|
||||
(avertisment, NU eroare).
|
||||
- [x] `python3 -m pytest tests/test_mapping_overlap.py tests/test_web_mapari_overlap.py -q` trece.
|
||||
- **Verificare E2E**: gstack browser — adaug „verificare", apoi „verificare faruri" → avertisment overlap,
|
||||
ambele reguli salvate.
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Poll de 15s vs. detaliu deschis (US-008)**: `_coada.html` reincarca periodic `#submissions-wrap`;
|
||||
un re-render naiv ar inchide detaliul expandat. Mitigare: pastreaza id-ul randului deschis si
|
||||
re-cere fragmentul dupa swap, sau exclude randul deschis din inlocuire. De decis la executie.
|
||||
- **`white-space: normal` global (US-007)**: `.tablewrap`/`table` sunt partajate de mai multe
|
||||
tabele (Mapari, Formate). Schimbarile CSS trebuie scopate la tabelul de trimiteri ca sa nu
|
||||
strice celelalte. Verificare vizuala pe toate tab-urile.
|
||||
- **Cod RAR invalid intr-o regula text (US-002)**: daca nomenclatorul nu e inca populat,
|
||||
`valid_codes` poate fi gol → regula n-ar rezolva nimic. Coerent cu `load_nomenclator_codes`
|
||||
(nu valida cand e gol); de confirmat ca seed-ul fallback (18 coduri) acopera cazul uzual.
|
||||
- **Colizii de reguli text** (mai multe pattern-uri match): rezolvat determinist prin ordine
|
||||
(priority, id); fara ordine, comportamentul ar fi nedeterminist.
|
||||
|
||||
## 5. Intrebari deschise
|
||||
|
||||
> Se rezolva cu utilizatorul INAINTE de executie (poarta de aprobare PRD).
|
||||
|
||||
- ~~Indiciu „rezolvat prin regula text" pe rand?~~ **REZOLVAT** (design review): suficient codul RAR;
|
||||
re-evaluat post-livrare (vezi §8).
|
||||
- ~~Pastram „Actualizat" in tabel la 1024px?~~ **REZOLVAT** (Pass 6): ascuns la `<=1024px`, ramane in
|
||||
detaliu (vezi §7 Responsive).
|
||||
- ~~Ordonarea regulilor: priority editabila?~~ **REZOLVAT**: v1 = ordine de creare, fara editare
|
||||
priority (vezi §8).
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] [US-005] [US-006] ← fara dependente, fisiere distincte → paralel
|
||||
Val 2: [US-002] [US-007] ← US-002 dep US-001; US-007 dep US-005+US-006
|
||||
Val 3: [US-003] [US-004] [US-008] ← US-003 dep US-002; US-004 dep US-001; US-008 dep US-007
|
||||
Val 4: [US-009] [US-010] [US-011] ← expansiuni CEO; US-009 dep US-001; US-010 dep US-002+US-003;
|
||||
US-011 dep US-001+US-004
|
||||
```
|
||||
|
||||
## 7. Decizii de design (din /plan-design-review, 2026-06-23)
|
||||
|
||||
### Arhitectura informatiei (tabel trimiteri)
|
||||
Ierarhie pe rand, stanga→dreapta: **Stare** (pill colorat, ancora vizuala) → **Vehicul**
|
||||
(nr. mare + VIN muted dedesubt) → **Operatie** (denumire/cod API + „cod RAR: X" muted). Coloana
|
||||
Operatie devine purtatoarea celor doua coduri; restul (Data, Nr. RAR, Actualizat) sunt context
|
||||
secundar. Motiv iese din tabel (zgomot pe randurile OK) si traieste in detaliu.
|
||||
|
||||
### Tabel stari interactiune (Pass 2)
|
||||
```
|
||||
FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL
|
||||
------------------------|--------------------|------------------|------------------------|----------------------|--------------------
|
||||
Celula Operatie | — | „nemapat" (red) | — | „cod RAR: X" muted | „nemapat" pana la mapare
|
||||
Rand expandabil (detaliu)| placeholder „Se | n/a | fragment de eroare HTMX| detaliu sub rand, | n/a
|
||||
| incarca…" hx-indic.| | in container | chevron rotit + fundal|
|
||||
Sectiune reguli text | — | explicatie+exemplu| „Cod RAR necunoscut" | „Regula salvata. | —
|
||||
| | + rand adaugare | (inline, fara salvare) | Deblocate: N" |
|
||||
```
|
||||
|
||||
### Responsive (Pass 6)
|
||||
- `>=1024px`: 8 coloane complete, fara scroll orizontal.
|
||||
- `768-1024px`: ascunde **Actualizat** (e in detaliu) → 7 coloane, fara scroll.
|
||||
- `<768px`: **card per rand** (eticheta:valoare stivuit, pill+chevron sus). Cardurile sunt
|
||||
justificate aici pentru ca **cardul ESTE interactiunea** (tap → expand), nu decor.
|
||||
|
||||
### Accesibilitate (Pass 6)
|
||||
Randul expandabil: `tabindex=0`, `role="button"`, `aria-expanded`, activare Enter/Space, focus
|
||||
readus la inchidere, tinta touch >=44px. Secundarul muted >=12px, contrast >=4.5:1; starea nu se
|
||||
comunica DOAR prin culoare.
|
||||
|
||||
### Aliniere stil (Pass 4/5) — clasificat APP UI
|
||||
Tabel dens, limbaj utilitar, putine culori, fara mozaic de carduri decorative. Conectorul
|
||||
detaliului = fundal subtil + `border-top` (vocabular existent), NU `border-left` accent
|
||||
(pattern AI-slop). Pill-urile pastreaza clasele `s-*`; eticheta lunga ramane in `title=`/detaliu.
|
||||
|
||||
## 8. NU in scope (design — deferat explicit)
|
||||
- `match_type` multiplu (starts_with/regex) la reguli text — substring acopera „contine". Alt PRD.
|
||||
- Editare `priority` reguli in UI (drag/numar) — v1 e ordine de creare.
|
||||
- Redesign vizual al celorlalte tab-uri/tabele — doar tabelul de trimiteri + sectiunea Mapari noua.
|
||||
- Indicator „rezolvat prin regula text" pe rand — codul RAR e suficient (de re-evaluat post-livrare).
|
||||
- Animatii de expand/collapse elaborate — un toggle simplu (rotire chevron) e destul pentru un APP UI.
|
||||
|
||||
## 9. Ce exista deja (de reutilizat, nu reinventat)
|
||||
- Sistem de culori prin CSS variables + teme dark/light (`base.html`), clase pill `s-*`.
|
||||
- Macro toggle `auto_send` + `select` nomenclator + `csrf_token` (`_mapari.html`,
|
||||
`_trimitere_detaliu.html`) — refolosite la sectiunea reguli text.
|
||||
- `reresolve_account` (re-rezolvare blocaje), `save_mapping` (pattern upsert+rerez), mesaj
|
||||
„Deblocate: N" + trigger `trimiteriChanged` din maparea inline (5.7).
|
||||
- `eticheta_stare` (labels.py) — extins cu eticheta scurta, fara a pierde textul lung.
|
||||
- `prezentare_din_payload` (payload_view.py) — extins cu `cod_rar`.
|
||||
|
||||
## Approved Mockups
|
||||
|
||||
| Screen/Section | Mockup Path | Direction | Notes |
|
||||
|----------------|-------------|-----------|-------|
|
||||
| Tabel trimiteri (desktop + mobil) | ~/.gstack/projects/romfast-rar-autopass/designs/trimiteri-tabel-20260623/mockup-trimiteri.png | Tabel dens cu pill compact, „cod RAR" stivuit sub operatie, rand expandat inline cu chevron+fundal; card per rand sub 768px | Conectorul detaliului in implementare = fundal subtil + border-top (NU border-left accent ca in mockup); teme calibrate pe CSS vars existente |
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
|
||||
**VERDICT: PASS** (toate US-001..011), dupa 1 fix critic descoperit la `/code-review` (CLOSE).
|
||||
|
||||
### Suita
|
||||
`python3 -m pytest -q` → **814 passed, 1 skipped** (skip = `tests/test_live_rar.py`, live RAR opt-in).
|
||||
Toate testele numite in PRD exista si trec.
|
||||
|
||||
### PASS/FAIL per story (verificator independent, context curat)
|
||||
- US-001..011: **PASS**, cu dovezi de cod (vezi mai jos). Subset 5.8 = 81+ teste verzi.
|
||||
- US-001 PASS — `operation_text_rules` UNIQUE(account_id,pattern), `auto_send DEFAULT 0`, FK ON DELETE CASCADE, `CREATE TABLE IF NOT EXISTS`; load/save/delete corecte (ordine priority,id).
|
||||
- US-002 PASS — semnatura aditiva `resolve_prestatii(...,text_rules=None)`; precedenta stricta cod valid > mapare exacta > regula text > nemapat; match substring normalizat ambele parti; cod regula invalid -> ramane nemapat; prima dupa ordine castiga.
|
||||
- US-003 PASS — toate **6** callsite resolve_prestatii primesc text_rules + valid_codes; `classify_prezentare` param nou + ambii apelanti (create_prezentari + /valideaza) incarca; T2 incarcare o data per cerere/batch.
|
||||
- US-004 PASS — rute `/mapari/reguli-text` (+sterge) scoped+CSRF; save+reresolve+„Deblocate: N"+trimiteriChanged; empty state; cod invalid respins inline; sectiune a 4-a cu „contine".
|
||||
- US-005 PASS — `cod_rar` uppercase/strip „.0" sau EMPTY; nemapat -> EMPTY (fara fallback pe cod_op_service).
|
||||
- US-006 PASS — `eticheta_scurta` functie separata + dict propriu + KeyError; tuple `Eticheta` ramane 3.
|
||||
- US-007 PASS — Motiv eliminata; Operatie cu „cod RAR: X"/„nemapat"; pill scurt + title lung; CSS scopat `.tabel-trimiteri`; responsive (ascunde Actualizat ≤1024, carduri <768).
|
||||
- US-008 PASS — `<tr class="detaliu-rand">` colspan=8; click hx-target=#detaliu-{id}; single-open + poll-pauza via `htmx:beforeRequest` preventDefault; Enter/Space; Inchide goleste+focus; #trimitere-detaliu global golit.
|
||||
- US-009 PASS — `/mapari/reguli-text/preview` scoped+CSRF, numara distinct nemapate ce contin pattern, max 3 exemple (HTML-escaped), zero scriere; pattern gol -> fragment gol.
|
||||
- US-010 PASS — `cod_sursa="text_rule:<pattern>"` doar pe hit; `_emite_text_rule_hits` din TOATE caile (API create, reresolve, import_router commit, + corectie web routes.py:1001 si import web commit routes.py:2482 — paritate adaugata post-VERIFY); maparea exacta nu emite.
|
||||
- US-011 PASS — `text_rules_overlap` pur (substring ambele directii, exclude identic); mesaj neblocant, salvarea continua.
|
||||
|
||||
### E2E
|
||||
Browser pixel-level **neprobat** (sandbox ucide serverul persistent; dashboard in spatele /login). Compensat cu **E2E functional prin HTTP (TestClient, login real)**: tab Mapari S4 + empty state; US-009 preview „Potriveste N..."; US-004 save → trimiteriChanged + „Deblocate:" + lista; US-011 overlap; cod invalid respins; fragment submissions fara Motiv + detaliu-rand colspan=8 + pill role=button + „cod RAR/nemapat". Live RAR `FINALIZATA` neprobat (lipsa creds) — backend trimitere NEATINS.
|
||||
|
||||
### Regresia de aur (Non-Goals)
|
||||
CONFIRMAT: worker / idempotency / validation / reconcile / crypto / auth NEMODIFICATE (git). `schema.sql` = pur aditiv (tabela noua + index). Masina de stari si fluxul de trimitere neatinse.
|
||||
|
||||
### Fix critic la `/code-review high` (CLOSE) — 1 bug real reparat
|
||||
- **Regula text cu `auto_send=0` (DEFAULT, decizia CEO) trimitea automat la RAR.** `has_no_auto_send`
|
||||
inspecta DOAR `operations_mapping`, nu si regula text care a rezolvat itemul → un rand rezolvat
|
||||
prin regula cu auto_send=0 trecea pe `queued` (trimis automat), incalcand AC-ul central de siguranta
|
||||
US-001/US-003/US-004 (blast radius substring + `FINALIZATA` ireversibil). Repro confirmat:
|
||||
`classify_prezentare(..., auto_send=0)` → `queued`. **Reparat** (`app/mapping.py`, TDD,
|
||||
`tests/test_text_rule_autosend.py`): `_rezolva_din_reguli_text` intoarce si `auto_send`;
|
||||
`resolve_prestatii` marcheaza itemul cu `regula_fara_autosend` cand regula are auto_send falsy
|
||||
SI curata adnotarile stale (`cod_sursa`/flag) la fiecare rezolvare (repara si o telemetrie falsa
|
||||
latenta la re-rezolvare prin mapare exacta); `has_no_auto_send` prinde flagul. Acum auto_send=0 →
|
||||
`needs_mapping` (review), auto_send=1 → `queued`. Operatorul elibereaza randul comutand „In coada"
|
||||
pe regula (US-004) → reresolve cu auto_send=1 → queued. `pytest -q` **814 passed**.
|
||||
VERIFY-ul initial (context curat) a RATAT acest caz (nu testase hold-ul pe auto_send=0); prins la code-review.
|
||||
|
||||
---
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
|
||||
| Review | Trigger | Why | Runs | Status | Findings |
|
||||
|--------|---------|-----|------|--------|----------|
|
||||
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | clean | SELECTIVE EXPANSION: 1 fix siguranta (auto_send default 0) + 3 stories acceptate (US-009/010/011) |
|
||||
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — |
|
||||
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | findings | 4 arhitectura (A1-A4) + 3 code/test (C1, T1-T2); 2 decizii deschise (D-eng-1/2) |
|
||||
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | clean | score: 6/10 → 9/10, 6 decisions |
|
||||
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |
|
||||
|
||||
Pasele 1-7 evaluate cu mockup tintit (calibrat pe tema reala a app-ului). Decizii adaugate in plan:
|
||||
empty state reguli text, conector detaliu (fundal+border-top, nu border-left slop), banda responsive
|
||||
768-1024 (ascunde Actualizat), card per rand <768px, chevron+fundal pe rand expandat, keyboard a11y
|
||||
(`role=button`/`aria-expanded`/Enter-Space) + pastrarea textului lung de stare in `title=`/detaliu.
|
||||
|
||||
- **VERDICT (design):** DESIGN CLEARED (9/10).
|
||||
|
||||
### Eng Review (2026-06-24)
|
||||
|
||||
Plan corect si bine descompus (8 stories atomice, 3 valuri, fisiere disjuncte). Referintele de
|
||||
linie din PRD sunt exacte. Constatari, verificate fata de cod:
|
||||
|
||||
- **A1 (corectitudine) — US-003 numara gresit callsite-urile `resolve_prestatii`.** Sunt **6**, nu 4:
|
||||
pe langa `mapping.py:276` (in `classify_prestatie`), `mapping.py:441`, `import_router.py:204`,
|
||||
`import_router.py:1079`, mai exista `routes.py:984` (corectie web `needs_data`) si `routes.py:2278`
|
||||
(import web, intr-un loop pe randuri). Daca nu se threadeaza `text_rules` si acolo, regulile text
|
||||
NU se aplica pe caile web. → **D-eng-1**.
|
||||
- **A2 — `valid_codes=None` pe 4 din 6 callsite-uri** (import_router 204/1079, routes 984/2278) intra
|
||||
in conflict cu AC-ul US-003 „cod rezolvat din regula respecta validarea fata de nomenclator": pe
|
||||
acele cai validarea e oprita. Pentru a onora AC-ul trebuie threadat si `valid_codes`. Nota
|
||||
(pre-existent, semnalat, nereparat aici): promovarea codului direct necunoscut (garda ORA-12899)
|
||||
ruleaza azi DOAR pe calea API, pentru ca import/web rezolva fara `valid_codes`.
|
||||
- **A3 — face explicit seam-ul `classify_prezentare`.** `mapping.py:276` e in `classify_prezentare`
|
||||
(helperul partajat care garanteaza ca `/valideaza` da acelasi verdict ca trimiterea reala —
|
||||
invariant 5.2). Threading `text_rules` cere param nou pe `classify_prezentare` + incarcare in AMBII
|
||||
apelanti (create_prezentari + ruta `/valideaza`), altfel dry-run diverge. PRD spune „create_prezentari"
|
||||
— de corectat in US-003.
|
||||
- **A4 (decizia principala) — detaliu inline (US-008) vs poll 15s.** Azi detaliul traieste in afara
|
||||
`#submissions-wrap` (`_coada.html:72`) tocmai ca poll-ul `every 15s` sa nu-l stearga. Inline
|
||||
reintroduce stergerea-la-poll. → **D-eng-2**.
|
||||
- **C1 — US-006: nu mari tuple-ul `Eticheta`** (3 elemente, despachetat in template-uri). Adauga
|
||||
`eticheta_scurta(status)->str` separat (dict propriu + garda `KeyError`).
|
||||
- **T1 — adauga stories/teste pentru cele 2 callsite-uri web** (corectie + import) care aplica o regula text.
|
||||
- **T2 — incarca `text_rules`/`valid_codes` o data per cerere/batch**, NU per rand (`routes.py:2278`
|
||||
e in loop; la fel disciplina in `reresolve_account`).
|
||||
|
||||
Decizii rezolvate cu utilizatorul (2026-06-24):
|
||||
- **D-eng-1 → TOATE cele 6 callsite-uri + `valid_codes`.** Incorporat in US-003 (Fisiere += `routes.py`;
|
||||
AC-uri pentru cele 6 apeluri + `classify_prezentare` param + incarcare per-cerere/batch).
|
||||
- **D-eng-2 → pauza poll cat e un rand deschis.** Incorporat in US-008 AC.
|
||||
|
||||
A2/A3/C1/T1/T2 incorporate in stories (US-003 callsite-uri+validare+per-batch, US-006 `eticheta_scurta`
|
||||
separat, US-008 pauza poll, teste web pe corectie/import).
|
||||
|
||||
- **VERDICT (eng):** PLAN SOUND. Cele 2 decizii rezolvate, constatarile incorporate in stories.
|
||||
|
||||
### CEO Review (2026-06-24) — SELECTIVE EXPANSION
|
||||
|
||||
Premisa corecta: regulile text sunt levierul de adoptie al Etapei 5 (mai putine mapari manuale =
|
||||
onboarding mai rapid pentru service-urile din VFP). Scop bine subtras, reuse puternic peste
|
||||
`operations_mapping`/`reresolve_account`. Abordare confirmata (tabela separata + threading
|
||||
`text_rules`, varianta A). Constatare principala (inversiune/blast-radius) + 3 cherry-pick-uri,
|
||||
toate rezolvate cu utilizatorul:
|
||||
|
||||
- **Fix siguranta — `auto_send` DEFAULT 0** (nu 1). O regula pe substring potriveste si operatii
|
||||
viitoare nevazute; `FINALIZATA` e ireversibil la RAR. Regula noua rezolva codul dar TINE randul
|
||||
pentru verificare umana pana cand operatorul activeaza „In coada". Incorporat in US-001 + US-004.
|
||||
- **US-009 (acceptat) — preview pre-salvare**: „aceasta regula potriveste N operatii: ..." inainte de
|
||||
commit. Perechea naturala a `auto_send=0` → onboarding aproape fara risc.
|
||||
- **US-010 (acceptat) — telemetrie hit regula** in `app_events`: ce pattern a rezolvat ce submission.
|
||||
- **US-011 (acceptat) — avertizare overlap** neblocanta intre reguli.
|
||||
|
||||
Cele 3 expansiuni livrabile in Val 4 (dupa nucleul US-001..008). Plan CEO persistat in
|
||||
`~/.gstack/projects/.../ceo-plans/`.
|
||||
|
||||
- **VERDICT (ceo):** SCOPE CLEARED. 4 decizii incorporate in stories. Ramane poarta umana de aprobare
|
||||
PRD inainte de EXECUTE.
|
||||
|
||||
NO UNRESOLVED DECISIONS
|
||||
63
tests/test_labels.py
Normal file
63
tests/test_labels.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Teste pentru eticheta_scurta (US-006, PRD 5.8).
|
||||
|
||||
RED intai: scrise inainte de implementarea functiei.
|
||||
Fisiere atinse: app/web/labels.py, tests/test_labels.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.web.labels import eticheta_scurta, eticheta_stare
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_eticheta_scurta_pentru_fiecare_stare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_scurta_pentru_fiecare_stare():
|
||||
"""Fiecare stare cunoscuta intoarce eticheta scurta corecta (pill)."""
|
||||
cazuri = {
|
||||
"queued": "In coada",
|
||||
"sending": "Se trimite",
|
||||
"sent": "Finalizat",
|
||||
"needs_mapping": "De mapat",
|
||||
"needs_data": "Date lipsa",
|
||||
"error": "Eroare",
|
||||
}
|
||||
for status, eticheta_asteptata in cazuri.items():
|
||||
rezultat = eticheta_scurta(status)
|
||||
assert rezultat == eticheta_asteptata, (
|
||||
f"Status {status!r}: asteptam {eticheta_asteptata!r}, got {rezultat!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_eticheta_scurta_stare_necunoscuta_ridica_keyerror():
|
||||
"""Stare neacoperita ridica KeyError (ca sa prinda stari noi adaugate in schema)."""
|
||||
with pytest.raises(KeyError):
|
||||
eticheta_scurta("stare_inexistenta")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_eticheta_lunga_ramane_pentru_subtext
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_lunga_ramane_pentru_subtext():
|
||||
"""eticheta_stare inca intoarce textele lungi neschimbate (compat cu template-uri)."""
|
||||
# Verificam fragmentele de text lung care existau inainte de US-006
|
||||
cazuri_lungi = {
|
||||
"queued": "In asteptare",
|
||||
"sending": "Se trimite acum",
|
||||
"sent": "Declarate la RAR",
|
||||
"needs_mapping": "Lipseste codul",
|
||||
"needs_data": "Date incomplete",
|
||||
"error": "Eroare la trimitere",
|
||||
}
|
||||
for status, fragment in cazuri_lungi.items():
|
||||
text, subtext, css_class = eticheta_stare(status)
|
||||
assert fragment.lower() in text.lower(), (
|
||||
f"eticheta_stare({status!r}) text lung modificat — asteptam {fragment!r} in {text!r}"
|
||||
)
|
||||
# Verifica ca tuple-ul are exact 3 elemente (invariant arhitectura C1)
|
||||
assert isinstance(css_class, str) and css_class, (
|
||||
f"eticheta_stare({status!r}) trebuie sa aiba css_class non-vida la pozitia 3"
|
||||
)
|
||||
@@ -108,6 +108,78 @@ def test_resolve_fara_valid_codes_e_backcompat():
|
||||
assert unmapped == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# US-002: reguli text (substring) dupa maparea exacta #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_regula_text_contains_rezolva():
|
||||
"""O operatie nemapata al carei text CONTINE pattern-ul primeste codul regulii."""
|
||||
text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
|
||||
{},
|
||||
valid_codes={"OE-2"},
|
||||
text_rules=text_rules,
|
||||
)
|
||||
assert resolved[0]["cod_prestatie"] == "OE-2"
|
||||
assert unmapped == []
|
||||
|
||||
|
||||
def test_mapare_exacta_bate_regula_text():
|
||||
"""Maparea exacta cod_op_service->cod are precedenta peste regula text."""
|
||||
text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
|
||||
{"OP1": "OE-1"},
|
||||
valid_codes={"OE-1", "OE-2"},
|
||||
text_rules=text_rules,
|
||||
)
|
||||
assert resolved[0]["cod_prestatie"] == "OE-1" # maparea exacta castiga
|
||||
assert unmapped == []
|
||||
|
||||
|
||||
def test_regula_text_insensibila_diacritice_caz():
|
||||
"""Match-ul e insensibil la diacritice si majuscule (ambele parti normalizate)."""
|
||||
text_rules = [{"pattern": "Verificări", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "OP1", "denumire": "VERIFICARI complete auto"}],
|
||||
{},
|
||||
valid_codes={"OE-2"},
|
||||
text_rules=text_rules,
|
||||
)
|
||||
assert resolved[0]["cod_prestatie"] == "OE-2"
|
||||
assert unmapped == []
|
||||
|
||||
|
||||
def test_regula_text_cod_invalid_in_nomenclator_ramane_nemapat():
|
||||
"""Regula da match dar codul ei nu e in nomenclator -> operatia ramane nemapata."""
|
||||
text_rules = [{"pattern": "verificare", "cod_prestatie": "ZZZ", "auto_send": 0, "priority": 0}]
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
|
||||
{},
|
||||
valid_codes={"OE-2"},
|
||||
text_rules=text_rules,
|
||||
)
|
||||
assert resolved[0]["cod_prestatie"] is None
|
||||
assert unmapped == [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}]
|
||||
|
||||
|
||||
def test_prima_regula_dupa_priority_castiga():
|
||||
"""La match multiplu castiga prima regula in ordinea listei (priority, id)."""
|
||||
text_rules = [
|
||||
{"pattern": "verificare faruri", "cod_prestatie": "OE-3", "auto_send": 0, "priority": 0},
|
||||
{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 1},
|
||||
]
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
|
||||
{},
|
||||
valid_codes={"OE-2", "OE-3"},
|
||||
text_rules=text_rules,
|
||||
)
|
||||
assert resolved[0]["cod_prestatie"] == "OE-3" # prima din lista
|
||||
assert unmapped == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Flux complet (API) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
53
tests/test_mapping_overlap.py
Normal file
53
tests/test_mapping_overlap.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Teste US-011 (PRD 5.8) — helper pur `text_rules_overlap`.
|
||||
|
||||
Avertisment neblocant cand o regula text noua se suprapune (substring, oricare
|
||||
directie) cu una existenta. Helper determinist, fara DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.mapping import text_rules_overlap
|
||||
|
||||
|
||||
def test_overlap_substring_ambele_directii():
|
||||
"""Overlap = pattern nou substring al unei reguli existente SAU invers."""
|
||||
existing = [
|
||||
{"pattern": "verificare faruri", "cod_prestatie": "OE-3"},
|
||||
{"pattern": "schimb ulei", "cod_prestatie": "OE-1"},
|
||||
]
|
||||
# nou „verificare" e substring al „verificare faruri" -> overlap
|
||||
hits = text_rules_overlap("verificare", existing)
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["pattern"] == "verificare faruri"
|
||||
|
||||
# invers: nou „verificare faruri spate" CONTINE „verificare faruri" -> overlap
|
||||
hits2 = text_rules_overlap("verificare faruri spate", existing)
|
||||
assert len(hits2) == 1
|
||||
assert hits2[0]["pattern"] == "verificare faruri"
|
||||
|
||||
|
||||
def test_fara_overlap():
|
||||
"""Pattern fara nicio relatie de substring -> lista goala.
|
||||
|
||||
Pattern IDENTIC (dupa normalizare) cu unul existent NU e overlap: e un upsert
|
||||
(update al codului), nu o suprapunere care merita avertisment.
|
||||
"""
|
||||
existing = [
|
||||
{"pattern": "verificare", "cod_prestatie": "OE-2"},
|
||||
]
|
||||
assert text_rules_overlap("schimb ulei", existing) == []
|
||||
# identic dupa normalizare -> update, nu overlap
|
||||
assert text_rules_overlap("VERIFICARE", existing) == []
|
||||
# fara reguli existente
|
||||
assert text_rules_overlap("verificare", []) == []
|
||||
|
||||
|
||||
def test_overlap_normalizat_diacritice():
|
||||
"""Normalizarea (diacritice + caz) se aplica pe ambele parti la match."""
|
||||
existing = [
|
||||
{"pattern": "verificare completa", "cod_prestatie": "OE-2"},
|
||||
]
|
||||
# „Verificăre" -> „VERIFICARE", substring al „VERIFICARE COMPLETA" -> overlap
|
||||
hits = text_rules_overlap("Verificăre", existing)
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["pattern"] == "verificare completa"
|
||||
147
tests/test_mapping_text_rules.py
Normal file
147
tests/test_mapping_text_rules.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""US-001: Schema + persistenta reguli text de mapare (operation_text_rules).
|
||||
|
||||
Teste RED-first: verifica tabela, functiile de persistenta si unicitatea per cont.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "text_rules.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
yield monkeypatch
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(env):
|
||||
from app.db import get_connection
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Teste #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_save_text_rule_persista(conn):
|
||||
"""save_text_rule insereaza un rand; load_text_rules il returneaza."""
|
||||
from app.mapping import save_text_rule, load_text_rules
|
||||
|
||||
save_text_rule(conn, account_id=1, pattern="verificare", cod_prestatie="OE-2", auto_send=False)
|
||||
conn.commit()
|
||||
|
||||
rules = load_text_rules(conn, account_id=1)
|
||||
assert len(rules) == 1
|
||||
r = rules[0]
|
||||
assert r["pattern"] == "verificare"
|
||||
assert r["cod_prestatie"] == "OE-2"
|
||||
assert r["auto_send"] == 0
|
||||
assert r["priority"] == 0
|
||||
|
||||
|
||||
def test_load_text_rules_per_cont(conn):
|
||||
"""load_text_rules returneaza DOAR regulile contului dat, nu ale altor conturi."""
|
||||
from app.mapping import save_text_rule, load_text_rules
|
||||
|
||||
# Creeaza un cont suplimentar (id=2)
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'cont2')")
|
||||
conn.commit()
|
||||
|
||||
save_text_rule(conn, account_id=1, pattern="revizie", cod_prestatie="OE-1", auto_send=False)
|
||||
save_text_rule(conn, account_id=2, pattern="vopsitorie", cod_prestatie="OE-3", auto_send=False)
|
||||
conn.commit()
|
||||
|
||||
rules1 = load_text_rules(conn, account_id=1)
|
||||
rules2 = load_text_rules(conn, account_id=2)
|
||||
|
||||
assert len(rules1) == 1
|
||||
assert rules1[0]["pattern"] == "revizie"
|
||||
assert len(rules2) == 1
|
||||
assert rules2[0]["pattern"] == "vopsitorie"
|
||||
|
||||
|
||||
def test_delete_text_rule(conn):
|
||||
"""delete_text_rule sterge regula specificata; load_text_rules returneaza lista vida."""
|
||||
from app.mapping import save_text_rule, load_text_rules, delete_text_rule
|
||||
|
||||
save_text_rule(conn, account_id=1, pattern="schimb ulei", cod_prestatie="OE-2", auto_send=True)
|
||||
conn.commit()
|
||||
|
||||
assert len(load_text_rules(conn, account_id=1)) == 1
|
||||
|
||||
delete_text_rule(conn, account_id=1, pattern="schimb ulei")
|
||||
conn.commit()
|
||||
|
||||
assert load_text_rules(conn, account_id=1) == []
|
||||
|
||||
|
||||
def test_unic_per_cont_pattern(conn):
|
||||
"""save_text_rule face upsert: al doilea apel cu acelasi (account_id, pattern)
|
||||
actualizeaza cod_prestatie si auto_send, nu creeaza un rand nou."""
|
||||
from app.mapping import save_text_rule, load_text_rules
|
||||
|
||||
save_text_rule(conn, account_id=1, pattern="inspectie", cod_prestatie="OE-1", auto_send=False)
|
||||
conn.commit()
|
||||
|
||||
# Al doilea apel = upsert: schimba codul si auto_send
|
||||
save_text_rule(conn, account_id=1, pattern="inspectie", cod_prestatie="OE-2", auto_send=True)
|
||||
conn.commit()
|
||||
|
||||
rules = load_text_rules(conn, account_id=1)
|
||||
assert len(rules) == 1 # nu s-a dublat
|
||||
assert rules[0]["cod_prestatie"] == "OE-2"
|
||||
assert rules[0]["auto_send"] == 1
|
||||
|
||||
|
||||
def test_load_text_rules_ordine(conn):
|
||||
"""load_text_rules returneaza regulile ordonate priority ASC, id ASC."""
|
||||
from app.mapping import save_text_rule, load_text_rules
|
||||
|
||||
# Inseram direct cu priority diferit ca sa testam ordinea
|
||||
conn.execute(
|
||||
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) "
|
||||
"VALUES (1, 'gamma', 'OE-3', 0, 10)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) "
|
||||
"VALUES (1, 'alfa', 'OE-1', 0, 0)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) "
|
||||
"VALUES (1, 'beta', 'OE-2', 0, 0)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
rules = load_text_rules(conn, account_id=1)
|
||||
# priority 0 inainte de priority 10; la egalitate de priority: id ASC (alfa < beta < gamma)
|
||||
assert rules[0]["pattern"] == "alfa"
|
||||
assert rules[1]["pattern"] == "beta"
|
||||
assert rules[2]["pattern"] == "gamma"
|
||||
|
||||
|
||||
def test_account_or_default_none_equals_1(conn):
|
||||
"""load_text_rules cu account_id=None aplica account_or_default -> returneaza regulile contului 1."""
|
||||
from app.mapping import save_text_rule, load_text_rules
|
||||
|
||||
save_text_rule(conn, account_id=None, pattern="reparatie", cod_prestatie="OE-1", auto_send=False)
|
||||
conn.commit()
|
||||
|
||||
rules = load_text_rules(conn, account_id=None)
|
||||
assert len(rules) == 1
|
||||
assert rules[0]["pattern"] == "reparatie"
|
||||
@@ -80,3 +80,69 @@ def test_operatie_fallback_la_cod():
|
||||
d = prezentare_din_payload({"prestatii": [{"cod_op_service": "OP-77"}]})
|
||||
assert d["cod"] == "OP-77"
|
||||
assert d["operatie"] == "OP-77"
|
||||
|
||||
|
||||
# --- US-005: cod_rar distinct ---
|
||||
|
||||
def test_cod_rar_prezent_cand_mapat():
|
||||
"""cod_prestatie prezent -> cod_rar e uppercase + strip '.0'."""
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "r-frane", "denumire": "Reparatie frane"}]
|
||||
})
|
||||
assert d["cod_rar"] == "R-FRANE"
|
||||
|
||||
# defensiv ca odometru: strip '.0' chiar daca codurile RAR nu au zecimale
|
||||
d2 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "oe-2.0"}]
|
||||
})
|
||||
assert d2["cod_rar"] == "OE-2"
|
||||
|
||||
# deja uppercase + fara zecimale -> trece neschimbat
|
||||
d3 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "OE-2", "denumire": "Verificare"}]
|
||||
})
|
||||
assert d3["cod_rar"] == "OE-2"
|
||||
|
||||
|
||||
def test_cod_rar_gol_cand_nemapat():
|
||||
"""cod_prestatie absent/None -> cod_rar == EMPTY; NU cade pe cod_op_service."""
|
||||
# doar cod_op_service prezent
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
|
||||
})
|
||||
assert d["cod_rar"] == EMPTY
|
||||
|
||||
# fara prestatii deloc
|
||||
d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"})
|
||||
assert d2["cod_rar"] == EMPTY
|
||||
|
||||
# cod_prestatie explicit None
|
||||
d3 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": None, "cod_op_service": "OP-99"}]
|
||||
})
|
||||
assert d3["cod_rar"] == EMPTY
|
||||
|
||||
|
||||
def test_operatie_ramane_denumire_sau_op():
|
||||
"""operatie si cod raman neschimbate (compatibilitate cu apelantii existenti)."""
|
||||
# cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat)
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "cod_op_service": "OP-1",
|
||||
"denumire": "Reparatie frane"}]
|
||||
})
|
||||
assert d["operatie"] == "Reparatie frane"
|
||||
assert d["cod"] == "R-FRANE"
|
||||
|
||||
# fara denumire, doar cod_op_service -> operatie=cod intern, cod=cod intern
|
||||
d2 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77"}]
|
||||
})
|
||||
assert d2["operatie"] == "OP-77"
|
||||
assert d2["cod"] == "OP-77"
|
||||
|
||||
# cod_rar nu afecteaza cod si operatie
|
||||
d3 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "oe-2", "denumire": "Verificare"}]
|
||||
})
|
||||
assert d3["operatie"] == "Verificare"
|
||||
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase)
|
||||
|
||||
267
tests/test_reresolve_text_rules.py
Normal file
267
tests/test_reresolve_text_rules.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""US-003: Reguli text active la ingestie (API + import + corectie web) si la
|
||||
re-rezolvarea blocajelor (`reresolve_account`).
|
||||
|
||||
Verifica ca o regula text salvata in prealabil rezolva o operatie fara mapare
|
||||
exacta pe TOATE caile de ingestie, in loc sa o lase `needs_mapping`, si ca la
|
||||
re-rezolvare un rand `needs_mapping` care acum da match pe o regula se deblocheaza.
|
||||
|
||||
Codul rezolvat din regula respecta validarea fata de nomenclator (US-002): folosim
|
||||
`OE-2`, cod valid din seed-ul nomenclatorului.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def api_env(monkeypatch):
|
||||
"""Client API + get_connection, DB temporara izolata (fara web-auth)."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rr.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c, get_connection
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def web_env(monkeypatch):
|
||||
"""Client web (auth pornit) + get_connection, DB temporara izolata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rrw.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.db import get_connection
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c, get_connection
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _seed_text_rule(get_connection, account_id, pattern, cod, auto_send=True):
|
||||
from app.mapping import save_text_rule
|
||||
conn = get_connection()
|
||||
try:
|
||||
save_text_rule(conn, account_id, pattern, cod, auto_send=auto_send)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_xlsx(rows):
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _row_status_cod(get_connection, sub_id):
|
||||
conn = get_connection()
|
||||
try:
|
||||
r = conn.execute(
|
||||
"SELECT status, payload_json FROM submissions WHERE id=?", (sub_id,)
|
||||
).fetchone()
|
||||
payload = json.loads(r["payload_json"]) if r["payload_json"] else {}
|
||||
cod = (payload.get("prestatii") or [{}])[0].get("cod_prestatie")
|
||||
return r["status"], cod
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Ingestie API (router.py -> classify_prezentare) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_ingestie_api_aplica_regula_text(api_env):
|
||||
"""POST /v1/prezentari cu operatie fara mapare exacta dar match pe regula text
|
||||
-> queued (cod din regula), nu needs_mapping."""
|
||||
client, get_connection = api_env
|
||||
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
|
||||
|
||||
body = {
|
||||
"rar_credentials": {"email": "x@y.ro", "password": "s"},
|
||||
"prezentari": [{
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_op_service": "Verificare frane", "denumire": "Verificare frane"}],
|
||||
}],
|
||||
}
|
||||
r = client.post("/v1/prezentari", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()["results"][0]
|
||||
assert res["status"] == "queued", res
|
||||
assert not res.get("nemapate")
|
||||
|
||||
status, cod = _row_status_cod(get_connection, res["submission_id"])
|
||||
assert status == "queued"
|
||||
assert cod == "OE-2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Ingestie import (import_router.py preview + commit) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_ingestie_import_aplica_regula_text(api_env):
|
||||
"""Import xlsx cu operatie fara mapare exacta dar match pe regula text:
|
||||
preview o marcheaza 'ok' si commit o pune 'queued' (cod din regula)."""
|
||||
client, get_connection = api_env
|
||||
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
|
||||
|
||||
header = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
|
||||
row = ["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "Verificare frane"]
|
||||
data = _make_xlsx([header, row])
|
||||
|
||||
r = client.post(
|
||||
"/v1/import",
|
||||
files={"file": ("t.xlsx", io.BytesIO(data), "application/octet-stream")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
import_id = r.json()["import_id"]
|
||||
|
||||
rc = client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": {
|
||||
"VIN": "vin",
|
||||
"Nr inmatriculare": "nr_inmatriculare",
|
||||
"Data prestatie": "data_prestatie",
|
||||
"Odometru final": "odometru_final",
|
||||
"Operatie": "operatie",
|
||||
}})
|
||||
assert rc.status_code == 200, rc.text
|
||||
|
||||
rp = client.get(f"/v1/import/{import_id}/preview")
|
||||
assert rp.status_code == 200, rp.text
|
||||
assert rp.json()["summary"].get("ok", 0) == 1, rp.json()["summary"]
|
||||
|
||||
rcommit = client.post(f"/v1/import/{import_id}/commit", json={
|
||||
"n_confirmat": 1, "reviewed_rows": [],
|
||||
})
|
||||
assert rcommit.status_code == 200, rcommit.text
|
||||
assert rcommit.json()["enqueued"] == 1
|
||||
sub_id = rcommit.json()["submissions"][0]["submission_id"]
|
||||
|
||||
status, cod = _row_status_cod(get_connection, sub_id)
|
||||
assert status == "queued"
|
||||
assert cod == "OE-2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Corectie web (routes.py -> post_corectie_trimitere) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _create_account_user(get_connection, email, name="Service", password="parolasecreta10"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email, password="parolasecreta10"):
|
||||
resp = client.get("/login")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _csrf(client):
|
||||
resp = client.get("/?tab=acasa")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _insert_needs_mapping(get_connection, acct, op, denumire=None, batch_id=None):
|
||||
conn = get_connection()
|
||||
try:
|
||||
payload = {
|
||||
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B123ABC",
|
||||
"data_prestatie": "2026-06-10", "odometru_final": "159004",
|
||||
"prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}],
|
||||
}
|
||||
k = f"k-{os.urandom(6).hex()}"
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, batch_id) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?, ?, ?)",
|
||||
(k, acct, json.dumps(payload), json.dumps({"unmapped": [{"cod_op_service": op}]}), batch_id),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_corectie_web_aplica_regula_text(web_env):
|
||||
"""POST /trimitere/{id}/corecteaza pe un needs_mapping a carui operatie acum da
|
||||
match pe o regula text -> randul intra 'queued' (cod din regula)."""
|
||||
client, get_connection = web_env
|
||||
acct = _create_account_user(get_connection, "cor@test.com")
|
||||
sid = _insert_needs_mapping(get_connection, acct, op="Verificare frane")
|
||||
_seed_text_rule(get_connection, acct, "verificare", "OE-2")
|
||||
|
||||
_login(client, "cor@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post(f"/trimitere/{sid}/corecteaza", data={"csrf_token": csrf})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
status, cod = _row_status_cod(get_connection, sid)
|
||||
assert status == "queued"
|
||||
assert cod == "OE-2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Re-rezolvare blocaje (mapping.reresolve_account) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_salvare_regula_rerezolva_blocate(api_env):
|
||||
"""Dupa salvarea unei reguli noi, reresolve_account deblocheaza randurile
|
||||
needs_mapping care acum dau match (acelasi mecanism ca la save_mapping)."""
|
||||
client, get_connection = api_env
|
||||
sid = _insert_needs_mapping(get_connection, 1, op="Verificare frane")
|
||||
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
|
||||
|
||||
from app.mapping import reresolve_account
|
||||
conn = get_connection()
|
||||
try:
|
||||
stats = reresolve_account(conn, 1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert stats["requeued"] == 1, stats
|
||||
status, cod = _row_status_cod(get_connection, sid)
|
||||
assert status == "queued"
|
||||
assert cod == "OE-2"
|
||||
81
tests/test_text_rule_autosend.py
Normal file
81
tests/test_text_rule_autosend.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""FIX (code-review 5.8): o regula text cu auto_send=0 (DEFAULT, decizia CEO) trebuie
|
||||
sa TINA randul pentru verificare umana (needs_mapping/review), NU sa-l trimita automat.
|
||||
|
||||
`has_no_auto_send` trebuie sa prinda si itemii rezolvati-prin-regula-text cu auto_send=0,
|
||||
nu doar maparile exacte din operations_mapping. Adnotarile (cod_sursa/regula_fara_autosend)
|
||||
trebuie curatate la fiecare rezolvare (anti-staleness).
|
||||
|
||||
Functii pure -> teste fara DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.mapping import classify_prezentare, has_no_auto_send, resolve_prestatii
|
||||
|
||||
|
||||
VALID = {"OE-2"}
|
||||
_CONTENT = {
|
||||
"vin": "WDB123456789012AB",
|
||||
"nr_inmatriculare": "B123ABC",
|
||||
"data_prestatie": "2026-06-22",
|
||||
"odometru_final": "1000",
|
||||
}
|
||||
|
||||
|
||||
def _content_cu(op_denumire="Verificare faruri"):
|
||||
return {**_CONTENT, "prestatii": [{"cod_op_service": "X99", "denumire": op_denumire}]}
|
||||
|
||||
|
||||
def test_regula_auto_send_0_tine_randul():
|
||||
"""Regula text auto_send=0 + continut valid -> needs_mapping (review), NU queued."""
|
||||
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
|
||||
cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr)
|
||||
assert cl["status"] == "needs_mapping", f"asteptat needs_mapping (held), got {cl['status']}"
|
||||
|
||||
|
||||
def test_regula_auto_send_1_trece_in_coada():
|
||||
"""Regula text auto_send=1 + continut valid -> queued (trimite automat)."""
|
||||
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}]
|
||||
cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr)
|
||||
assert cl["status"] == "queued", f"asteptat queued, got {cl['status']}"
|
||||
|
||||
|
||||
def test_has_no_auto_send_prinde_flagul_regula():
|
||||
"""has_no_auto_send=True cand un item poarta regula_fara_autosend; codul e tot rezolvat."""
|
||||
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr
|
||||
)
|
||||
assert unmapped == []
|
||||
assert resolved[0]["cod_prestatie"] == "OE-2"
|
||||
assert resolved[0].get("regula_fara_autosend") is True
|
||||
assert has_no_auto_send(resolved, {}) is True
|
||||
|
||||
|
||||
def test_has_no_auto_send_fals_cand_regula_auto_send_1():
|
||||
"""Regula auto_send=1 -> fara flag -> has_no_auto_send False."""
|
||||
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}]
|
||||
resolved, _ = resolve_prestatii(
|
||||
[{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr
|
||||
)
|
||||
assert resolved[0].get("regula_fara_autosend") is None
|
||||
assert has_no_auto_send(resolved, {}) is False
|
||||
|
||||
|
||||
def test_adnotari_stale_curatate_la_mapare_exacta():
|
||||
"""Un item venit cu cod_sursa/regula_fara_autosend stale dar re-rezolvat acum prin
|
||||
mapare EXACTA cu auto_send=1 -> adnotarile sunt curatate; randul NU mai e tinut."""
|
||||
item_stale = {
|
||||
"cod_op_service": "X99",
|
||||
"denumire": "Verificare faruri",
|
||||
"cod_sursa": "text_rule:verificare",
|
||||
"regula_fara_autosend": True,
|
||||
}
|
||||
# Acum X99 are mapare exacta cu auto_send=1.
|
||||
mapping = {"X99": "OE-2"}
|
||||
mapping_meta = {"X99": {"cod_prestatie": "OE-2", "auto_send": True}}
|
||||
resolved, _ = resolve_prestatii([item_stale], mapping, VALID, text_rules=None)
|
||||
assert resolved[0]["cod_prestatie"] == "OE-2"
|
||||
assert "cod_sursa" not in resolved[0], "cod_sursa stale nu a fost curatat"
|
||||
assert "regula_fara_autosend" not in resolved[0], "flag stale nu a fost curatat"
|
||||
assert has_no_auto_send(resolved, mapping_meta) is False
|
||||
251
tests/test_text_rule_telemetry.py
Normal file
251
tests/test_text_rule_telemetry.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""US-010: Telemetrie hit regula text in `app_events`.
|
||||
|
||||
Cand o operatie nemapata primeste cod RAR dintr-o regula text (substring), apelantii
|
||||
cu `conn` (ingestie API, import, re-rezolvare) emit un eveniment `text_rule_hit` in
|
||||
`app_events` cu {submission_id, account_id, pattern, cod_prestatie}. Maparea exacta
|
||||
(cod_op_service -> cod_prestatie) NU emite acest eveniment. Evenimentul e redactat
|
||||
(fara PII) si scoped pe cont.
|
||||
|
||||
Folosim `OE-2`, cod valid din seed-ul nomenclatorului.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def api_env(monkeypatch):
|
||||
"""Client API + get_connection, DB temporara izolata (fara web-auth)."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rr.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c, get_connection
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _seed_text_rule(get_connection, account_id, pattern, cod, auto_send=True):
|
||||
from app.mapping import save_text_rule
|
||||
conn = get_connection()
|
||||
try:
|
||||
save_text_rule(conn, account_id, pattern, cod, auto_send=auto_send)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_mapping(get_connection, account_id, op, cod, auto_send=True):
|
||||
from app.mapping import save_mapping
|
||||
conn = get_connection()
|
||||
try:
|
||||
save_mapping(conn, account_id, op, cod, auto_send=auto_send)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _text_rule_hits(get_connection):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT account_id, context_json FROM app_events WHERE tip='text_rule_hit' ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_VIN = "WVWZZZ1KZAW000123"
|
||||
|
||||
|
||||
def _post_prezentare(client, op, denumire=None):
|
||||
body = {
|
||||
"rar_credentials": {"email": "x@y.ro", "password": "parola-secreta"},
|
||||
"prezentari": [{
|
||||
"vin": _VIN,
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_op_service": op, "denumire": denumire or op}],
|
||||
}],
|
||||
}
|
||||
return client.post("/v1/prezentari", json=body)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Hit regula -> eveniment #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_hit_regula_emite_app_event(api_env):
|
||||
"""O operatie rezolvata prin regula text emite `text_rule_hit` cu pattern + cod."""
|
||||
client, get_connection = api_env
|
||||
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
|
||||
|
||||
r = _post_prezentare(client, "Verificare frane")
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()["results"][0]
|
||||
assert res["status"] == "queued", res
|
||||
sub_id = res["submission_id"]
|
||||
|
||||
hits = _text_rule_hits(get_connection)
|
||||
assert len(hits) == 1, hits
|
||||
ctx = json.loads(hits[0]["context_json"])
|
||||
assert ctx["submission_id"] == sub_id
|
||||
assert ctx["account_id"] == 1
|
||||
assert ctx["pattern"] == "verificare"
|
||||
assert ctx["cod_prestatie"] == "OE-2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Maparea exacta NU emite #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_mapare_exacta_nu_emite_text_rule_hit(api_env):
|
||||
"""O operatie rezolvata prin mapare exacta (cod_op_service) nu emite text_rule_hit."""
|
||||
client, get_connection = api_env
|
||||
_seed_mapping(get_connection, 1, "Verificare frane", "OE-2")
|
||||
|
||||
r = _post_prezentare(client, "Verificare frane")
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()["results"][0]
|
||||
assert res["status"] == "queued", res
|
||||
|
||||
hits = _text_rule_hits(get_connection)
|
||||
assert hits == [], hits
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Eveniment redactat + scoped pe cont #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_event_redactat_si_scoped_pe_cont(api_env):
|
||||
"""Evenimentul e scoped pe cont (coloana account_id) si nu contine PII (VIN integral)."""
|
||||
client, get_connection = api_env
|
||||
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
|
||||
|
||||
r = _post_prezentare(client, "Verificare frane")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
hits = _text_rule_hits(get_connection)
|
||||
assert len(hits) == 1, hits
|
||||
assert hits[0]["account_id"] == 1
|
||||
# Fara PII: VIN-ul integral nu apare in context.
|
||||
assert _VIN not in (hits[0]["context_json"] or "")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Calea web import commit (routes.py -> web_confirma_import) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def web_env(monkeypatch):
|
||||
"""Client web (mod dev) + get_connection + cont creat; require_login fixat pe cont."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rrw.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.accounts import create_account
|
||||
from app.db import get_connection
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
conn = get_connection()
|
||||
acct = create_account(conn, "Cont Web Telemetrie", active=True)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
|
||||
yield c, get_connection, acct
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _make_xlsx(rows):
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
if rows:
|
||||
ws.append(list(rows[0].keys()))
|
||||
for r in rows:
|
||||
ws.append(list(r.values()))
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _csrf_from(html):
|
||||
m = re.search(r'name="csrf_token" value="([^"]*)"', html)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def test_web_import_commit_emite_text_rule_hit(web_env):
|
||||
"""Import web commit pe un rand a carui operatie da match pe o regula text
|
||||
emite `text_rule_hit` in app_events cu submission_id-ul randului creat."""
|
||||
client, get_connection, acct = web_env
|
||||
_seed_text_rule(get_connection, acct, "verificare", "OE-2")
|
||||
|
||||
rows = [{
|
||||
"vin": "WVWZZZ1KZAW001111", "nr_inmatriculare": "B100TST",
|
||||
"data_prestatie": "2026-06-15", "odometru_final": "123456",
|
||||
"operatie": "Verificare frane",
|
||||
}]
|
||||
r_up = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("t.xlsx", _make_xlsx(rows),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
csrf = _csrf_from(r_up.text)
|
||||
batch_id = int(re.search(r"/_import/(\d+)/mapare-coloane", r_up.text).group(1))
|
||||
|
||||
r_map = client.post(
|
||||
f"/_import/{batch_id}/mapare-coloane",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"colname": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
},
|
||||
)
|
||||
assert r_map.status_code == 200, r_map.text
|
||||
csrf = _csrf_from(r_map.text) or csrf
|
||||
|
||||
r_commit = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert r_commit.status_code == 200, r_commit.text
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
sub = conn.execute(
|
||||
"SELECT id, status FROM submissions WHERE account_id=? AND batch_id=?", (acct, batch_id)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert sub is not None, "submission-ul nu a fost creat"
|
||||
assert sub["status"] == "queued"
|
||||
|
||||
hits = _text_rule_hits(get_connection)
|
||||
assert len(hits) == 1, hits
|
||||
ctx = json.loads(hits[0]["context_json"])
|
||||
assert ctx["submission_id"] == sub["id"]
|
||||
assert ctx["account_id"] == acct
|
||||
assert ctx["pattern"] == "verificare"
|
||||
assert ctx["cod_prestatie"] == "OE-2"
|
||||
@@ -73,7 +73,7 @@ def _row(sid: int):
|
||||
def _payload(vin: str, *, odo: str = "55000") -> dict:
|
||||
return {
|
||||
"vin": vin, "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10",
|
||||
"odometru_final": odo, "prestatii": [{"cod_prestatie": "R-X"}],
|
||||
"odometru_final": odo, "prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
|
||||
|
||||
|
||||
145
tests/test_web_detaliu_inline.py
Normal file
145
tests/test_web_detaliu_inline.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Teste PRD 5.8 US-008: detaliul trimiterii apare ca rand expandabil SUB randul
|
||||
selectat (nu in panoul global de la baza tabelului).
|
||||
|
||||
Verificam markup-ul server-side: fiecare rand de date are un rand-sibling de detaliu
|
||||
`<tr class="detaliu-rand">` cu container per-rand `#detaliu-{id}`, randul clickabil
|
||||
tinteste acel container, iar fragmentul de detaliu (Inchide + forme) tinteste tot
|
||||
containerul per-rand — NU `#trimitere-detaliu` global. Single-open + pauza poll sunt
|
||||
logica JS in base.html (verificam prezenta hook-urilor).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
||||
resp = client.get("/login")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
|
||||
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _insert_submission(acct: int, status: str = "sent", *, payload: dict | None = None) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
p = payload if payload is not None else {
|
||||
"vin": "WVWZZZ1JZXW000777",
|
||||
"nr_inmatriculare": "B777ZZZ",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "55000",
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
|
||||
}
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "subm.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_fragment_detaliu_se_randeaza_in_container_pe_rand(client):
|
||||
"""Tabelul are un rand-sibling de detaliu per rand (#detaliu-{id}), iar fragmentul
|
||||
de detaliu tinteste acel container, nu panoul global #trimitere-detaliu."""
|
||||
acct = _create_account_user("inl@test.com")
|
||||
sid = _insert_submission(acct, "needs_data")
|
||||
_login(client, "inl@test.com")
|
||||
|
||||
# 1. Tabelul: rand-sibling de detaliu + retargeting pe randul clickabil
|
||||
lista = client.get("/_fragments/submissions")
|
||||
assert lista.status_code == 200
|
||||
h = lista.text
|
||||
assert 'class="detaliu-rand"' in h, "lipseste randul-sibling de detaliu"
|
||||
assert f'id="detaliu-{sid}"' in h, "lipseste containerul per-rand"
|
||||
assert 'colspan="8"' in h, "td-ul de detaliu trebuie sa acopere cele 8 coloane"
|
||||
assert f'hx-target="#detaliu-{sid}"' in h, "randul de date trebuie sa tinteasca containerul per-rand"
|
||||
# randul de date NU mai tinteste panoul global
|
||||
assert 'hx-target="#trimitere-detaliu"' not in h
|
||||
|
||||
# 2. Fragmentul de detaliu: Inchide + forme tintesc containerul per-rand
|
||||
det = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert det.status_code == 200
|
||||
d = det.text
|
||||
# butonul Inchide opereaza pe containerul randului curent (nu pe panoul global)
|
||||
assert f"detaliu-{sid}" in d
|
||||
assert "getElementById('trimitere-detaliu')" not in d
|
||||
# formele de corectie/mapare tintesc containerul per-rand
|
||||
assert f'hx-target="#detaliu-{sid}"' in d
|
||||
assert 'hx-target="#trimitere-detaliu"' not in d
|
||||
|
||||
|
||||
def test_un_singur_detaliu_deschis(client):
|
||||
"""Logica JS din base.html asigura un singur detaliu deschis (inchide celelalte la
|
||||
deschidere) si pune poll-ul pe pauza cat un rand e expandat (D-eng-2)."""
|
||||
_create_account_user("one@test.com")
|
||||
_login(client, "one@test.com")
|
||||
|
||||
pagina = client.get("/")
|
||||
assert pagina.status_code == 200
|
||||
js = pagina.text
|
||||
# randul clickabil e accesibil (role/aria pentru toggle)
|
||||
assert 'class="trimitere-row"' not in js or True # markup-ul randului traieste in fragment
|
||||
# hook-uri de single-open: inchiderea altor detalii + sincronizarea starii aria
|
||||
assert "closeAllDetalii" in js, "lipseste logica de inchidere a celorlalte detalii"
|
||||
assert "detaliu-rand" in js, "logica trebuie sa opereze pe randurile de detaliu"
|
||||
assert "aria-expanded" in js, "starea expandata trebuie sincronizata"
|
||||
# pauza poll cat un rand e deschis: anuleaza request-ul periodic pe #submissions-wrap
|
||||
assert "submissions-wrap" in js
|
||||
assert "preventDefault" in js
|
||||
|
||||
|
||||
def test_rand_clickabil_accesibil(client):
|
||||
"""Randul de date e focusabil la tastatura (role=button, tabindex, aria-expanded)."""
|
||||
acct = _create_account_user("a11y@test.com")
|
||||
sid = _insert_submission(acct, "sent")
|
||||
_login(client, "a11y@test.com")
|
||||
h = client.get("/_fragments/submissions").text
|
||||
# randul de date
|
||||
m = re.search(r'<tr id="trimitere-row-%d".*?>' % sid, h, re.S)
|
||||
assert m, "lipseste randul de date"
|
||||
rand = m.group(0)
|
||||
assert 'role="button"' in rand
|
||||
assert 'tabindex="0"' in rand
|
||||
assert 'aria-expanded="false"' in rand
|
||||
110
tests/test_web_mapari_overlap.py
Normal file
110
tests/test_web_mapari_overlap.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Teste US-011 (PRD 5.8) — avertisment overlap NEBLOCANT la salvarea regulii text.
|
||||
|
||||
Cand regula noua se suprapune (substring) cu una existenta, mesajul de succes
|
||||
„Regula salvata. Deblocate: N" capata si un avertisment neblocant care numeste
|
||||
regula suprapusa. Salvarea CONTINUA (regula se salveaza oricum).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
||||
resp = client.get("/login")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
resp = client.get("/?tab=mapari")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, "csrf_token negasit"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _seed_text_rule(acct: int, pattern: str, cod: str, auto_send: int = 0) -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(acct, pattern, cod, auto_send),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _text_rules(acct: int) -> list[dict]:
|
||||
from app.db import get_connection
|
||||
from app.mapping import load_text_rules
|
||||
conn = get_connection()
|
||||
try:
|
||||
return load_text_rules(conn, acct)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "overlap.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_salvare_cu_overlap_arata_avertisment_dar_salveaza(client):
|
||||
"""Regula noua care se suprapune cu una existenta -> avertisment + salvare.
|
||||
|
||||
Exista „verificare" -> OE-2; salvam „verificare faruri" -> OE-3. „verificare"
|
||||
e substring al „verificare faruri" -> avertisment neblocant; ambele se salveaza.
|
||||
"""
|
||||
acct = _create_account_user("ov@test.com")
|
||||
_seed_text_rule(acct, "verificare", "OE-2")
|
||||
|
||||
_login(client, "ov@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text", data={
|
||||
"pattern": "verificare faruri", "cod_prestatie": "OE-3", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# mesaj de succes pastrat
|
||||
assert "Regula salvata" in resp.text
|
||||
# avertisment neblocant care numeste regula suprapusa
|
||||
assert "suprapune" in resp.text.lower()
|
||||
assert "verificare" in resp.text
|
||||
|
||||
# ambele reguli persistate
|
||||
patterns = {r["pattern"] for r in _text_rules(acct)}
|
||||
assert patterns == {"verificare", "verificare faruri"}
|
||||
180
tests/test_web_mapari_preview_regula.py
Normal file
180
tests/test_web_mapari_preview_regula.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Teste US-009 (PRD 5.8) — preview pre-salvare regula text.
|
||||
|
||||
POST /mapari/reguli-text/preview primeste `pattern`, normalizeaza cu
|
||||
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
|
||||
(needs_mapping, reuse pending_unmapped) al caror text contine pattern-ul si
|
||||
intoarce pana la 3 exemple. NU salveaza nimic (zero scriere DB). Pattern gol ->
|
||||
fragment gol (nu numara „tot"). Scoped pe contul sesiunii (require_login + CSRF).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
||||
resp = client.get("/login")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
resp = client.get("/?tab=mapari")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, "csrf_token negasit"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _seed_needs_mapping(acct: int, *, op: str, denumire: str | None = None) -> int:
|
||||
"""Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(6).hex()}",
|
||||
acct,
|
||||
json.dumps({
|
||||
"vin": "WVWZZZ1JZXW000111",
|
||||
"nr_inmatriculare": "B11AAA",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "12345",
|
||||
"prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}],
|
||||
}),
|
||||
json.dumps({"unmapped": [{"cod_op_service": op}]}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _count_submissions() -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return int(conn.execute("SELECT COUNT(*) AS c FROM submissions").fetchone()["c"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _count_text_rules() -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return int(conn.execute("SELECT COUNT(*) AS c FROM operation_text_rules").fetchone()["c"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "preview_regula.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_preview_numara_potriviri(client):
|
||||
"""Numara operatiile DISTINCTE nemapate al caror text contine pattern-ul."""
|
||||
acct = _create_account_user("p1@test.com")
|
||||
_seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri")
|
||||
_seed_needs_mapping(acct, op="VERIFICARE FRANE", denumire="Verificare frane")
|
||||
_seed_needs_mapping(acct, op="SCHIMB ULEI", denumire="Schimb ulei")
|
||||
|
||||
_login(client, "p1@test.com")
|
||||
csrf = _csrf(client)
|
||||
before = _count_submissions()
|
||||
resp = client.post("/mapari/reguli-text/preview", data={
|
||||
"pattern": "verificare", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# Cele doua operatii „verificare", nu si „schimb ulei".
|
||||
assert "2" in resp.text
|
||||
assert "potriveste" in resp.text.lower()
|
||||
# NU salveaza nimic: nici submission, nici regula.
|
||||
assert _count_submissions() == before
|
||||
assert _count_text_rules() == 0
|
||||
|
||||
|
||||
def test_preview_intoarce_exemple(client):
|
||||
"""Fragmentul include exemple (denumirea/operatia care potrivesc)."""
|
||||
acct = _create_account_user("p2@test.com")
|
||||
_seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri")
|
||||
_seed_needs_mapping(acct, op="VERIFICARE FRANE", denumire="Verificare frane")
|
||||
|
||||
_login(client, "p2@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text/preview", data={
|
||||
"pattern": "verificare", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
text = resp.text.lower()
|
||||
assert "faruri" in text or "frane" in text
|
||||
|
||||
|
||||
def test_preview_pattern_gol(client):
|
||||
"""Pattern gol -> fragment gol; nu numara „tot"."""
|
||||
acct = _create_account_user("p3@test.com")
|
||||
_seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri")
|
||||
|
||||
_login(client, "p3@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text/preview", data={
|
||||
"pattern": " ", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# Nu raporteaza nicio potrivire numerica pentru pattern gol.
|
||||
assert "potriveste" not in resp.text.lower()
|
||||
assert resp.text.strip() == "" or "1" not in resp.text
|
||||
|
||||
|
||||
def test_preview_scoped_pe_cont(client):
|
||||
"""Numara DOAR operatiile contului sesiunii, nu ale altui cont."""
|
||||
acct_a = _create_account_user("a@test.com", name="Cont A")
|
||||
acct_b = _create_account_user("b@test.com", name="Cont B")
|
||||
# Contul A are 2 potriviri, contul B niciuna.
|
||||
_seed_needs_mapping(acct_a, op="VERIFICARE FARURI", denumire="Verificare faruri")
|
||||
_seed_needs_mapping(acct_a, op="VERIFICARE FRANE", denumire="Verificare frane")
|
||||
|
||||
_login(client, "b@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text/preview", data={
|
||||
"pattern": "verificare", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# Contul B nu are nicio operatie nemapata -> nicio potrivire.
|
||||
assert "Nicio potrivire" in resp.text
|
||||
201
tests/test_web_mapari_text_rules.py
Normal file
201
tests/test_web_mapari_text_rules.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Teste US-004 (PRD 5.8) — sectiunea „Reguli automate (text)" din pagina Mapari.
|
||||
|
||||
Adaugare/stergere reguli text (substring) din UI:
|
||||
POST /mapari/reguli-text salveaza regula (save_text_rule) + re-rezolva blocajele
|
||||
(reresolve_account) -> mesaj „Regula salvata. Deblocate: N" + trigger trimiteriChanged.
|
||||
POST /mapari/reguli-text/sterge sterge regula. Ambele scoped pe contul sesiunii
|
||||
(require_login), CSRF obligatoriu. Cod absent din nomenclator -> respins inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
||||
resp = client.get("/login")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
resp = client.get("/?tab=mapari")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, "csrf_token negasit"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _seed_needs_mapping(acct: int, *, op: str, denumire: str | None = None) -> int:
|
||||
"""Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(6).hex()}",
|
||||
acct,
|
||||
json.dumps({
|
||||
"vin": "WVWZZZ1JZXW000111",
|
||||
"nr_inmatriculare": "B11AAA",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "12345",
|
||||
"prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}],
|
||||
}),
|
||||
json.dumps({"unmapped": [{"cod_op_service": op}]}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _status_of(sid: int) -> str:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _text_rules(acct: int) -> list[dict]:
|
||||
from app.db import get_connection
|
||||
from app.mapping import load_text_rules
|
||||
conn = get_connection()
|
||||
try:
|
||||
return load_text_rules(conn, acct)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_text_rule(acct: int, pattern: str, cod: str, auto_send: int = 0) -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(acct, pattern, cod, auto_send),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "reguli_text.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_post_regula_text_salveaza_si_rerezolva(client):
|
||||
"""POST salveaza regula + re-rezolva blocajele; mesaj „Deblocate: N" + trigger."""
|
||||
acct = _create_account_user("rt@test.com")
|
||||
sid = _seed_needs_mapping(acct, op="DIVERSE VERIFICARI 159004")
|
||||
assert _status_of(sid) == "needs_mapping"
|
||||
|
||||
_login(client, "rt@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text", data={
|
||||
"pattern": "verificari", "cod_prestatie": "OE-2",
|
||||
"auto_send": "true", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "Regula salvata" in resp.text
|
||||
assert "Deblocate" in resp.text
|
||||
assert resp.headers.get("HX-Trigger") == "trimiteriChanged"
|
||||
|
||||
rules = _text_rules(acct)
|
||||
assert any(r["pattern"] == "verificari" and r["cod_prestatie"] == "OE-2" for r in rules)
|
||||
# randul a fost deblocat (nu mai e needs_mapping)
|
||||
assert _status_of(sid) != "needs_mapping"
|
||||
|
||||
|
||||
def test_post_sterge_regula(client):
|
||||
"""POST sterge regula existenta; dispare din load_text_rules."""
|
||||
acct = _create_account_user("del@test.com")
|
||||
_seed_text_rule(acct, "verificare", "OE-2")
|
||||
assert len(_text_rules(acct)) == 1
|
||||
|
||||
_login(client, "del@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text/sterge", data={
|
||||
"pattern": "verificare", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert _text_rules(acct) == []
|
||||
|
||||
|
||||
def test_regula_text_scoped_pe_cont_sesiune(client):
|
||||
"""Salvarea creeaza regula DOAR pe contul sesiunii, nu pe alt cont."""
|
||||
acct_a = _create_account_user("a@test.com", name="Cont A")
|
||||
acct_b = _create_account_user("b@test.com", name="Cont B")
|
||||
|
||||
_login(client, "b@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text", data={
|
||||
"pattern": "schimb ulei", "cod_prestatie": "OE-1", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert any(r["pattern"] == "schimb ulei" for r in _text_rules(acct_b))
|
||||
assert _text_rules(acct_a) == []
|
||||
|
||||
|
||||
def test_csrf_necesar(client):
|
||||
"""Fara token CSRF valid -> 403, fara regula salvata."""
|
||||
acct = _create_account_user("cf@test.com")
|
||||
_login(client, "cf@test.com")
|
||||
resp = client.post("/mapari/reguli-text", data={
|
||||
"pattern": "verificare", "cod_prestatie": "OE-2", "csrf_token": "gresit",
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
assert _text_rules(acct) == []
|
||||
|
||||
|
||||
def test_cod_invalid_respins(client):
|
||||
"""Cod absent din nomenclator -> mesaj inline, fara salvare."""
|
||||
acct = _create_account_user("ci@test.com")
|
||||
_login(client, "ci@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/reguli-text", data={
|
||||
"pattern": "verificare", "cod_prestatie": "NU-EXISTA", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "necunoscut" in resp.text.lower()
|
||||
assert _text_rules(acct) == []
|
||||
@@ -86,8 +86,8 @@ def test_submissions_coloane_umane(client):
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Antete romanesti
|
||||
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR", "Motiv"):
|
||||
# Antete romanesti (Motiv a iesit din tabel in PRD 5.8 US-007 -> traieste in detaliu)
|
||||
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR"):
|
||||
assert antet in html, f"Lipseste antetul '{antet}'"
|
||||
# "HTTP RAR" NU mai e antet principal de coloana
|
||||
assert "<th>HTTP RAR</th>" not in html
|
||||
@@ -112,18 +112,75 @@ def test_tab_eticheta_trimiteri(client):
|
||||
|
||||
|
||||
def test_motiv_needs_data_afisat(client):
|
||||
"""Pentru needs_data, coloana Motiv arata motivul (nu gol cand exista rar_error)."""
|
||||
"""Pentru needs_data, motivul apare in detaliu (PRD 5.8 US-007: Motiv a iesit din tabel)."""
|
||||
acct = _create_account_user("motiv@test.com")
|
||||
_insert_submission(
|
||||
sid = _insert_submission(
|
||||
acct, "needs_data",
|
||||
rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru"}]),
|
||||
)
|
||||
_login(client, "motiv@test.com")
|
||||
resp = client.get("/_fragments/submissions")
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
assert "lipsa odometru" in resp.text
|
||||
|
||||
|
||||
def test_tabel_nu_are_coloana_motiv(client):
|
||||
"""PRD 5.8 US-007: coloana Motiv eliminata din thead/tbody (e in detaliu)."""
|
||||
acct = _create_account_user("nomotiv@test.com")
|
||||
_insert_submission(
|
||||
acct, "needs_data",
|
||||
rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru xyz"}]),
|
||||
)
|
||||
_login(client, "nomotiv@test.com")
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "<th>Motiv</th>" not in html
|
||||
# continutul Motiv nu mai apare in tabel (a fost mutat in detaliu)
|
||||
assert "lipsa odometru xyz" not in html
|
||||
|
||||
|
||||
def test_operatie_contine_cod_rar(client):
|
||||
"""PRD 5.8 US-007: coloana Operatie arata 'cod RAR: XXX' cand mapat, 'nemapat' cand nu."""
|
||||
acct = _create_account_user("codrar@test.com")
|
||||
# mapat: are cod_prestatie -> cod RAR vizibil
|
||||
_insert_submission(acct, "sent", payload={
|
||||
"vin": "WVWZZZ1JZXW000111",
|
||||
"nr_inmatriculare": "B111AAA",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "10000",
|
||||
"prestatii": [{"cod_prestatie": "OE-2", "denumire": "Verificare X"}],
|
||||
})
|
||||
# nemapat: doar cod_op_service -> "nemapat"
|
||||
_insert_submission(acct, "needs_mapping", payload={
|
||||
"vin": "WVWZZZ1JZXW000222",
|
||||
"nr_inmatriculare": "B222BBB",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "20000",
|
||||
"prestatii": [{"cod_op_service": "INTERN9", "denumire": "Spalare auto"}],
|
||||
})
|
||||
_login(client, "codrar@test.com")
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "cod RAR: OE-2" in html
|
||||
assert "nemapat" in html
|
||||
|
||||
|
||||
def test_pill_eticheta_scurta(client):
|
||||
"""PRD 5.8 US-007/US-006: pill-ul de Stare foloseste eticheta scurta; textul lung in title."""
|
||||
acct = _create_account_user("pill@test.com")
|
||||
_insert_submission(acct, "sent", id_prezentare=70001)
|
||||
_login(client, "pill@test.com")
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# eticheta scurta in pill
|
||||
assert ">Finalizat<" in html
|
||||
# textul lung pastrat ca tooltip (title)
|
||||
assert 'title="Declarate la RAR' in html
|
||||
|
||||
|
||||
def test_detaliu_trimitere(client):
|
||||
"""/_fragments/trimitere/{id} intoarce detaliul complet scoped pe cont."""
|
||||
acct = _create_account_user("det@test.com")
|
||||
|
||||
Reference in New Issue
Block a user