Compare commits
2 Commits
5a2c976d46
...
0bc618763e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc618763e | ||
|
|
c6e29aa65d |
@@ -63,6 +63,7 @@ Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → en
|
||||
- **Admin bootstrap**: primul user din TOATA baza la `/signup` (`count_admins()==0`, in tranzactie) devine `is_admin=1`; restul nu. `is_admin` da acces la `/admin` (panou global) si e independent de contul id=1 (acela e doar fallback dev pentru API neautentificat). Fix manual: `python3 -m tools.account set-admin --account N`.
|
||||
- **Mapare coloane memorata per `(account_id, signature_coloane)`** (`column_mappings`): reaplicata automat la fisiere cu aceleasi coloane; un cont poate avea mai multe formate.
|
||||
- **Mapare operatie→cod**: prestatia vine cu `cod_prestatie` (cod RAR) sau `cod_op_service` + `denumire`. Nerezolvat → `needs_mapping` (nu se trimite), editor web cu sugestie fuzzy; salvarea maparii re-rezolva automat submission-urile blocate.
|
||||
- **Excludere de la declarare** (`operations_mapping.exclus=1`; optiunea "Nu se declara la RAR" = sentinel `__NEDECLARAT__` in select-urile de mapare): operatia NU pleaca la RAR. Precedenta in `resolve_prestatii`: cod explicit valid pe rand > exclus > mapare exacta > reguli text. Itemii adnotati `exclus` sunt scosi din payload/cheie cu `split_prestatii_excluse` inainte de enqueue; rand de import cu toate operatiile excluse → stare `excluded` ("Nedeclarat", nu se comite); submission cu toate excluse → `needs_data` cu motiv explicit. `save_mapping` peste o regula exclusa reseteaza `exclus=0`.
|
||||
- **`cod_prestatie` VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): cod necunoscut NU se trimite raw — e promovat la `cod_op_service` (denumire=cod) si intra la mapat. Motiv (confirmat live): RAR accepta doar coduri din nomenclator (max 5 car.); cod necunoscut → HTTP 500 `ORA-12899`, iar RAR ne-tranzactional lasa record PARTIAL `FINALIZATA` pe care reconcilierea l-ar marca fals `sent`. La cod nemapat: `on_unmapped_error` (boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` → editor/`needs_mapping`; `true` → respins fara enqueue (`submission_id=null` + `erori`). Precedenta: cerere > `accounts.on_unmapped_error_default` > `false`.
|
||||
- **WAF RAR da 403 fara User-Agent de browser** — httpx trimite mereu `User-Agent: Mozilla/5.0` (`config.py`).
|
||||
- **422 fara echo de credentiale**: handler-ul din `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel reflecta `rar_credentials.password`).
|
||||
|
||||
@@ -49,11 +49,13 @@ from ...mapping import (
|
||||
_emite_text_rule_hits,
|
||||
account_or_default,
|
||||
has_no_auto_send,
|
||||
load_excluded_ops,
|
||||
load_mapping_meta,
|
||||
load_nomenclator_codes,
|
||||
load_text_rules,
|
||||
normalize_for_match,
|
||||
resolve_prestatii,
|
||||
split_prestatii_excluse,
|
||||
)
|
||||
from ...validation import validate_prezentare
|
||||
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
|
||||
@@ -135,11 +137,12 @@ def _resolve_row_for_preview(
|
||||
valid_codes: set[str] | None = None,
|
||||
text_rules: list[dict] | None = None,
|
||||
reviewed: bool = False,
|
||||
excluded_ops: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
|
||||
|
||||
Intoarce un dict cu:
|
||||
resolved_status: ok/needs_mapping/needs_data/needs_review
|
||||
resolved_status: ok/needs_mapping/needs_data/needs_review/excluded
|
||||
resolved: valorile finale rezolvate (VIN, data, km, prestatii)
|
||||
errors: lista erori validare
|
||||
flags: motive needs_review
|
||||
@@ -218,7 +221,7 @@ def _resolve_row_for_preview(
|
||||
|
||||
# Rezolvare prestatii
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
|
||||
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
|
||||
mapped["prestatii"] = resolved
|
||||
|
||||
# Determinare stare
|
||||
@@ -230,6 +233,19 @@ def _resolve_row_for_preview(
|
||||
"flags": all_flags,
|
||||
}
|
||||
|
||||
# Operatii excluse de la declarare: ies din prestatiile trimise (si din cheia
|
||||
# de idempotenta). Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza).
|
||||
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||
if excluse and not declarabile:
|
||||
return {
|
||||
"resolved_status": "excluded",
|
||||
"resolved": mapped,
|
||||
"errors": [],
|
||||
"flags": all_flags,
|
||||
}
|
||||
if excluse:
|
||||
mapped["prestatii"] = declarabile
|
||||
|
||||
# Validare continut
|
||||
errors = validate_prezentare(mapped)
|
||||
|
||||
@@ -266,8 +282,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar
|
||||
return build_key(account_id, canon, rar_env)
|
||||
|
||||
|
||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
|
||||
# Campuri de continut editabile in preview. Prestatiile (chips operatie<->cod RAR)
|
||||
# se editeaza tot in modal, dar se persista separat (param `prestatii` din
|
||||
# apply_row_override), nu prin EDIT_FIELDS. obs = text liber, se trateaza ca non-canonic
|
||||
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
|
||||
|
||||
@@ -318,9 +335,13 @@ def apply_row_override(
|
||||
account_id: int | None,
|
||||
row_index: int,
|
||||
fields: dict[str, str | None],
|
||||
prestatii: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare).
|
||||
|
||||
`prestatii`: None = neatins; lista = inlocuieste integral prestatiile randului
|
||||
(chips editate in modal, deja validate fata de nomenclator de apelant).
|
||||
|
||||
NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul
|
||||
prin `_resolve_row_for_preview` (un singur clasificator, fara drift).
|
||||
|
||||
@@ -358,6 +379,8 @@ def apply_row_override(
|
||||
current = dec
|
||||
|
||||
new_override = _merge_override(current, fields)
|
||||
if prestatii is not None:
|
||||
new_override["prestatii"] = prestatii
|
||||
enc = encrypt_creds(new_override) if new_override else None
|
||||
# Resetam reviewed=0 la orice schimbare de valoare — operatorul
|
||||
# trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
|
||||
@@ -763,9 +786,10 @@ def preview_import(
|
||||
# Incarca maparea de operatii o singura data
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# Validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
|
||||
# Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta
|
||||
# la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
|
||||
@@ -821,6 +845,7 @@ def preview_import(
|
||||
override=overrides[i] or None,
|
||||
valid_codes=valid_codes,
|
||||
text_rules=text_rules,
|
||||
excluded_ops=excluded_ops,
|
||||
)
|
||||
|
||||
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review
|
||||
@@ -1111,9 +1136,10 @@ 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()}
|
||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# Validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
|
||||
# Construieste payload-urile submissions
|
||||
enqueued: list[dict] = []
|
||||
@@ -1167,25 +1193,26 @@ def commit_import(
|
||||
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["obs"] = obs_denumire
|
||||
|
||||
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
|
||||
mapped["prestatii"] = resolved
|
||||
|
||||
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie)
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
||||
# Override editat in preview — aplicat INAINTE de resolve_prestatii,
|
||||
# ca in _resolve_row_for_preview: prestatiile din override (chips
|
||||
# editate in modal) trec prin ACELASI resolve, altfel raman raw si
|
||||
# hash-ul/telemetria ar folosi prestatiile pre-editare.
|
||||
override = ok_row.get("override") or {}
|
||||
if override:
|
||||
mapped.update(override)
|
||||
# Re-canonicalizeaza pentru a obtine cheia IDENTICA cu cea din preview
|
||||
# (_build_idempotency_key = canonicalize_row + build_key peste mapped).
|
||||
|
||||
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
|
||||
# Prestatiile excluse de la declarare NU pleaca la RAR (ca in preview);
|
||||
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
|
||||
resolved, _excluse = split_prestatii_excluse(resolved)
|
||||
if not resolved:
|
||||
continue
|
||||
mapped["prestatii"] = resolved
|
||||
|
||||
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie;
|
||||
# cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key).
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
"vin": canon["vin"],
|
||||
|
||||
@@ -31,6 +31,7 @@ from ...mapping import (
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
classify_prezentare,
|
||||
load_excluded_ops,
|
||||
load_mapping_meta,
|
||||
load_nomenclator_codes,
|
||||
load_text_rules,
|
||||
@@ -69,13 +70,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, text_rules=None) -> dict:
|
||||
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None, excluded_ops=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, text_rules)
|
||||
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules, excluded_ops)
|
||||
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
|
||||
return cl
|
||||
|
||||
@@ -186,6 +187,7 @@ def create_prezentari(
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
|
||||
# Rezolva mediul RAR tinta (cerut > default cont > ancora globala).
|
||||
@@ -285,7 +287,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, text_rules)
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||
if cl["blocked_error"]:
|
||||
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
||||
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
||||
@@ -337,7 +339,7 @@ def create_prezentari(
|
||||
|
||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||
if cl["blocked_error"]:
|
||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||
results.append(_rezultat_respins(None, cl, rar_env=env))
|
||||
@@ -397,6 +399,7 @@ def valideaza_prezentari(
|
||||
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)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
|
||||
# Rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
|
||||
@@ -429,7 +432,7 @@ def valideaza_prezentari(
|
||||
|
||||
for i, prez in enumerate(req.prezentari):
|
||||
content = prez.model_dump()
|
||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||
if res["blocked_error"]:
|
||||
res = {**res, "status": "error"}
|
||||
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||
|
||||
59
app/db.py
59
app/db.py
@@ -178,6 +178,26 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
# CHECK-ul resolved_status trebuie sa accepte 'excluded' (operatii excluse de la
|
||||
# declarare). SQLite nu poate altera CHECK -> rebuild o singura data (detectat
|
||||
# pe textul CREATE din sqlite_master).
|
||||
create_sql = str(conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='import_rows'"
|
||||
).fetchone()["sql"] or "")
|
||||
if "'excluded'" not in create_sql:
|
||||
_rebuild_import_rows_cu_excluded(conn)
|
||||
|
||||
# Coloana operations_mapping.exclus: regula "nu se declara la RAR" per operatie.
|
||||
om_tbl = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='operations_mapping'"
|
||||
).fetchone()
|
||||
if om_tbl:
|
||||
om_cols = {r["name"] for r in conn.execute("PRAGMA table_info(operations_mapping)").fetchall()}
|
||||
if "exclus" not in om_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE operations_mapping ADD COLUMN exclus INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK (exclus IN (0, 1))"
|
||||
)
|
||||
|
||||
# Index batch_id pe submissions (poate lipsi pe DB veche)
|
||||
existing_idx = {r["name"] for r in conn.execute(
|
||||
@@ -201,6 +221,45 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_import_rows_cu_excluded(conn: sqlite3.Connection) -> None:
|
||||
"""Rebuild import_rows cu 'excluded' in CHECK-ul resolved_status.
|
||||
|
||||
Ruleaza O SINGURA DATA pe DB-uri create inainte de starea 'excluded' (gardat de
|
||||
apelant pe textul CREATE). Copiaza toate randurile 1:1 si recreeaza indexul.
|
||||
"""
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE import_rows_new ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,"
|
||||
" row_index INTEGER NOT NULL,"
|
||||
" raw_json TEXT NOT NULL,"
|
||||
" override_json TEXT,"
|
||||
" reviewed INTEGER NOT NULL DEFAULT 0,"
|
||||
" resolved_status TEXT NOT NULL DEFAULT 'pending'"
|
||||
" CHECK (resolved_status IN ("
|
||||
" 'pending','ok','needs_mapping','needs_data',"
|
||||
" 'needs_review','already_sent','duplicate_in_file','excluded'"
|
||||
" )),"
|
||||
" error TEXT"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO import_rows_new "
|
||||
" (id, batch_id, row_index, raw_json, override_json, reviewed, resolved_status, error) "
|
||||
"SELECT id, batch_id, row_index, raw_json, override_json, reviewed, resolved_status, error "
|
||||
"FROM import_rows"
|
||||
)
|
||||
conn.execute("DROP TABLE import_rows")
|
||||
conn.execute("ALTER TABLE import_rows_new RENAME TO import_rows")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_import_rows_batch ON import_rows(batch_id)")
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||
"""Coloane medii RAR per cont + backfill din ancora globala.
|
||||
|
||||
|
||||
@@ -226,7 +226,9 @@ class EmbeddingEngine:
|
||||
{
|
||||
"cod": self._corpus_items[i]["cod"],
|
||||
"is_nul": bool(self._corpus_items[i].get("is_nul", False)),
|
||||
"similaritate": float(sims[i]),
|
||||
# clamp la [-1, 1]: eroarea de rotunjire float32 poate da
|
||||
# 1.0000001 pe text identic (ar afisa >100% in UI).
|
||||
"similaritate": min(1.0, max(-1.0, float(sims[i]))),
|
||||
}
|
||||
for i in idx
|
||||
]
|
||||
|
||||
132
app/mapping.py
132
app/mapping.py
@@ -39,6 +39,10 @@ DEFAULT_ACCOUNT_ID = 1
|
||||
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
|
||||
SUGGEST_MIN_SCORE = 60
|
||||
|
||||
# Sentinel pentru optiunea "nu se declara la RAR" din select-urile web de mapare.
|
||||
# NU e cod RAR (codurile au max 5 caractere) — nu poate coliziona cu nomenclatorul.
|
||||
EXCLUDE_SENTINEL = "__NEDECLARAT__"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pur: normalizare + fuzzy + rezolvare #
|
||||
@@ -243,11 +247,15 @@ def resolve_prestatii(
|
||||
mapping: dict[str, str],
|
||||
valid_codes: set[str] | None = None,
|
||||
text_rules: list[dict] | None = None,
|
||||
excluded_ops: set[str] | 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 `excluded_ops` -> adnotat `exclus=True`
|
||||
(NU e nemapat; nu se declara la RAR — apelantii il scot din payload cu
|
||||
`split_prestatii_excluse` inainte de enqueue).
|
||||
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
|
||||
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
|
||||
(substring) -> umplem cod_prestatie din prima regula care potriveste.
|
||||
@@ -258,9 +266,11 @@ def resolve_prestatii(
|
||||
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`.
|
||||
Precedenta (stricta): `cod_prestatie` direct valid > exclus de la declarare >
|
||||
mapare exacta `cod_op_service` in `mapping` > reguli text > nemapat. Un cod ales
|
||||
explicit pe rand (editor) bate regula de excludere; regula de excludere bate
|
||||
regulile text. 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
|
||||
@@ -285,6 +295,7 @@ def resolve_prestatii(
|
||||
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
|
||||
it.pop("cod_sursa", None)
|
||||
it.pop("regula_fara_autosend", None)
|
||||
it.pop("exclus", 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)
|
||||
@@ -299,7 +310,11 @@ def resolve_prestatii(
|
||||
it["cod_op_service"] = op
|
||||
if not it.get("denumire"):
|
||||
it["denumire"] = cod
|
||||
if op and op in mapping:
|
||||
if op and excluded_ops and op in excluded_ops:
|
||||
# Exclus de la declarare: nu e nemapat, nu se trimite la RAR.
|
||||
it["cod_prestatie"] = None
|
||||
it["exclus"] = True
|
||||
elif op and op in mapping:
|
||||
it["cod_prestatie"] = mapping[op]
|
||||
elif op:
|
||||
# Mapare exacta absenta -> incearca regulile text (substring).
|
||||
@@ -322,6 +337,17 @@ def resolve_prestatii(
|
||||
return resolved, unmapped
|
||||
|
||||
|
||||
def split_prestatii_excluse(prestatii: list[dict] | None) -> tuple[list[dict], list[dict]]:
|
||||
"""Separa prestatiile declarabile de cele adnotate `exclus` de resolve_prestatii.
|
||||
|
||||
Payload-ul trimis la RAR (si cheia de idempotenta) se construieste NUMAI din
|
||||
declarabile; cele excluse nu parasesc niciodata sistemul.
|
||||
"""
|
||||
declarabile = [p for p in (prestatii or []) if not p.get("exclus")]
|
||||
excluse = [p for p in (prestatii or []) if p.get("exclus")]
|
||||
return declarabile, excluse
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Persistenta (conn) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -404,10 +430,11 @@ def load_nomenclator_codes(conn) -> set[str]:
|
||||
|
||||
|
||||
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
||||
"""{cod_op_service -> cod_prestatie} pentru un cont."""
|
||||
"""{cod_op_service -> cod_prestatie} pentru un cont. Fara regulile de excludere."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
|
||||
"SELECT cod_op_service, cod_prestatie FROM operations_mapping "
|
||||
"WHERE account_id=? AND exclus=0",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
|
||||
@@ -417,10 +444,13 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
||||
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
||||
|
||||
Varianta extinsa care include si flagul auto_send per operatie.
|
||||
Fara regulile de excludere (cod_prestatie gol) — consumatorii construiesc din ea
|
||||
dict-uri de mapare op->cod; excluderile se incarca separat (load_excluded_ops).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping WHERE account_id=?",
|
||||
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping "
|
||||
"WHERE account_id=? AND exclus=0",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return {
|
||||
@@ -429,12 +459,23 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
||||
}
|
||||
|
||||
|
||||
def load_excluded_ops(conn, account_id: int | None) -> set[str]:
|
||||
"""Setul de operatii excluse de la declarare pentru un cont."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT cod_op_service FROM operations_mapping WHERE account_id=? AND exclus=1",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return {r["cod_op_service"] for r in rows}
|
||||
|
||||
|
||||
def classify_prezentare(
|
||||
content: dict,
|
||||
mapping: dict[str, str],
|
||||
mapping_meta: dict[str, dict],
|
||||
valid_codes: set[str] | None = None,
|
||||
text_rules: list[dict] | None = None,
|
||||
excluded_ops: set[str] | None = None,
|
||||
) -> dict:
|
||||
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
|
||||
|
||||
@@ -454,7 +495,7 @@ def classify_prezentare(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
|
||||
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
||||
c["prestatii"] = resolved
|
||||
|
||||
if unmapped:
|
||||
@@ -466,6 +507,19 @@ def classify_prezentare(
|
||||
)
|
||||
errors: list[dict] = []
|
||||
else:
|
||||
# Prestatiile excluse de la declarare NU intra in payload-ul trimis (nici in
|
||||
# cheia de idempotenta). Toate excluse -> needs_data cu motiv explicit;
|
||||
# payload-ul pastreaza itemii adnotati `exclus` ca detaliul sa arate operatiile.
|
||||
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||
if not declarabile and excluse:
|
||||
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
|
||||
errors = [{
|
||||
"field": "prestatii",
|
||||
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
|
||||
}]
|
||||
else:
|
||||
if excluse:
|
||||
c["prestatii"] = declarabile
|
||||
errors = validate_prezentare(c)
|
||||
if errors:
|
||||
status = "needs_data"
|
||||
@@ -522,6 +576,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
).fetchall()
|
||||
|
||||
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
excluded_by_acct: dict[int, set[str]] = {}
|
||||
for r in rows:
|
||||
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
|
||||
try:
|
||||
@@ -536,6 +591,11 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
op = (item.get("cod_op_service") or "").strip()
|
||||
if not op:
|
||||
continue
|
||||
# Operatiile excluse de la declarare nu sunt "de mapat" — nu apar in editor.
|
||||
if acct not in excluded_by_acct:
|
||||
excluded_by_acct[acct] = load_excluded_ops(conn, acct)
|
||||
if op in excluded_by_acct[acct]:
|
||||
continue
|
||||
key = (acct, op)
|
||||
entry = agg.setdefault(
|
||||
key,
|
||||
@@ -563,21 +623,44 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
|
||||
|
||||
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
|
||||
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service)."""
|
||||
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service).
|
||||
|
||||
Reseteaza `excluded=0`: maparea unei operatii excluse anterior o readuce
|
||||
in fluxul de declarare (aceeasi cheie UNIQUE, o operatie = o regula).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
op = (cod_op_service or "").strip()
|
||||
cod = (cod_prestatie or "").strip().upper()
|
||||
if not op or not cod:
|
||||
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
|
||||
"VALUES (?, ?, ?, ?, 0) "
|
||||
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
|
||||
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send, exclus=0",
|
||||
(acct, op, cod, 1 if auto_send else 0),
|
||||
)
|
||||
|
||||
|
||||
def save_exclusion(conn, account_id: int | None, cod_op_service: str) -> None:
|
||||
"""Upsert o regula 'nu se declara' pentru o operatie (UNIQUE pe account_id+op).
|
||||
|
||||
cod_prestatie ramane gol — regula nu mapeaza, doar exclude de la declarare.
|
||||
Suprascrie o mapare existenta pe aceeasi operatie (o operatie = o regula).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
op = (cod_op_service or "").strip()
|
||||
if not op:
|
||||
raise ValueError("cod_op_service este obligatoriu")
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
|
||||
"VALUES (?, ?, '', 0, 1) "
|
||||
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||
"cod_prestatie='', auto_send=0, exclus=1",
|
||||
(acct, op),
|
||||
)
|
||||
|
||||
|
||||
def load_text_rules(conn, account_id: int | None) -> list[dict]:
|
||||
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
|
||||
|
||||
@@ -929,6 +1012,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
# Incarca regulile text O DATA, inainte de bucla pe randuri.
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
|
||||
if batch_id is not None:
|
||||
# Scope la batch-ul specificat (import commit explicit).
|
||||
@@ -954,7 +1038,7 @@ 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, text_rules)
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
||||
content["prestatii"] = resolved
|
||||
payload_json = json.dumps(content, ensure_ascii=False)
|
||||
|
||||
@@ -969,6 +1053,28 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
||||
stats["still_blocked"] += 1
|
||||
continue
|
||||
|
||||
# Prestatiile excluse ies din payload-ul trimis; toate excluse -> needs_data
|
||||
# cu motiv explicit (randul nu se mai trimite; operatorul il poate sterge).
|
||||
# Payload-ul pastreaza itemii adnotati `exclus` DOAR in ramura blocata, ca
|
||||
# detaliul sa arate operatiile; la queued pleaca numai declarabilele.
|
||||
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||
if not declarabile and excluse:
|
||||
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
|
||||
motiv = [{
|
||||
"field": "prestatii",
|
||||
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
|
||||
}]
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps(motiv, ensure_ascii=False), r["id"]),
|
||||
)
|
||||
stats["needs_data"] += 1
|
||||
continue
|
||||
if excluse:
|
||||
content["prestatii"] = declarabile
|
||||
payload_json = json.dumps(content, ensure_ascii=False)
|
||||
|
||||
# Ramura auto_send eliminata din reresolve.
|
||||
# Un cod rezolvat -> queued direct (review_manual ramane 0).
|
||||
|
||||
|
||||
@@ -82,8 +82,9 @@ CREATE TABLE IF NOT EXISTS operations_mapping (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
cod_op_service TEXT NOT NULL,
|
||||
cod_prestatie TEXT NOT NULL,
|
||||
cod_prestatie TEXT NOT NULL, -- gol ('') cand exclus=1 (regula nu mapeaza)
|
||||
auto_send INTEGER NOT NULL DEFAULT 1,
|
||||
exclus INTEGER NOT NULL DEFAULT 0, -- 1 = operatia NU se declara la RAR
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (account_id, cod_op_service)
|
||||
);
|
||||
@@ -175,7 +176,8 @@ CREATE TABLE IF NOT EXISTS import_rows (
|
||||
resolved_status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (resolved_status IN (
|
||||
'pending','ok','needs_mapping','needs_data',
|
||||
'needs_review','already_sent','duplicate_in_file'
|
||||
'needs_review','already_sent','duplicate_in_file',
|
||||
'excluded'
|
||||
)),
|
||||
error TEXT
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ from .mapping import (
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
classify_prezentare,
|
||||
load_excluded_ops,
|
||||
load_mapping_meta,
|
||||
load_nomenclator_codes,
|
||||
)
|
||||
@@ -102,7 +103,8 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
|
||||
mapping_meta = load_mapping_meta(conn, account_id)
|
||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
|
||||
excluded_ops = load_excluded_ops(conn, account_id)
|
||||
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, excluded_ops=excluded_ops)
|
||||
|
||||
# Re-snapshot `held` din comutatorul contului la re-punere in coada
|
||||
# (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra
|
||||
|
||||
@@ -400,6 +400,7 @@ STARI_PREVIEW: dict[str, tuple[str, str]] = {
|
||||
"needs_data": ("Date incomplete", "s-needs_data"),
|
||||
"already_sent": ("Deja trimis", "s-already_sent"),
|
||||
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
|
||||
"excluded": ("Nedeclarat", "s-excluded"),
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +423,8 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
|
||||
"""
|
||||
if status in ("already_sent", "duplicate_in_file"):
|
||||
return ""
|
||||
if status == "excluded":
|
||||
return "Operatie exclusa de la declarare — randul nu se trimite la RAR."
|
||||
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
|
||||
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
|
||||
# si ascunde motivul real (cod lipsa).
|
||||
|
||||
@@ -72,12 +72,14 @@ from ..submissions_admin import (
|
||||
from ..mapping import (
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
_emite_text_rule_hits,
|
||||
EXCLUDE_SENTINEL,
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
delete_text_rule,
|
||||
enrich_suggestions,
|
||||
ensure_embeddings_corpus,
|
||||
has_no_auto_send,
|
||||
load_excluded_ops,
|
||||
load_mapping_meta,
|
||||
load_nomenclator,
|
||||
load_nomenclator_codes,
|
||||
@@ -86,8 +88,10 @@ from ..mapping import (
|
||||
pending_unmapped,
|
||||
reresolve_account,
|
||||
resolve_prestatii,
|
||||
save_exclusion,
|
||||
save_mapping,
|
||||
save_text_rule,
|
||||
split_prestatii_excluse,
|
||||
suggest_codes,
|
||||
text_rules_overlap,
|
||||
)
|
||||
@@ -1727,12 +1731,19 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||
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)
|
||||
excluded_ops = load_excluded_ops(conn, account_id)
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
||||
content["prestatii"] = resolved
|
||||
|
||||
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
|
||||
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
|
||||
|
||||
# Prestatiile excluse de la declarare ies din payload-ul trimis (ca in
|
||||
# reresolve_account); toate excluse -> needs_data cu motiv explicit mai jos.
|
||||
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||
if not unmapped and excluse and declarabile:
|
||||
content["prestatii"] = declarabile
|
||||
|
||||
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
||||
canon = canonicalize_row(content)
|
||||
content.update({
|
||||
@@ -1755,6 +1766,24 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
||||
message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."),
|
||||
)
|
||||
|
||||
if excluse and not declarabile:
|
||||
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
|
||||
motiv = [{
|
||||
"field": "prestatii",
|
||||
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
|
||||
}]
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps(motiv, ensure_ascii=False), row["id"]),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, conn=conn, account_id=account_id, error=True,
|
||||
message="Toate operatiile randului sunt excluse de la declarare — nu se trimite la RAR."),
|
||||
)
|
||||
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
# Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp.
|
||||
@@ -2571,7 +2600,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
||||
prestatiei jonctionat din nomenclator. Scoped pe cont (NOT NULL → simplu)."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
|
||||
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, o.exclus, n.nume_prestatie "
|
||||
"FROM operations_mapping o "
|
||||
"LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie "
|
||||
"WHERE o.account_id=? ORDER BY o.cod_op_service",
|
||||
@@ -2583,6 +2612,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
||||
"cod_op_service": r["cod_op_service"],
|
||||
"cod_prestatie": r["cod_prestatie"],
|
||||
"auto_send": bool(r["auto_send"]),
|
||||
"exclus": bool(r["exclus"]),
|
||||
"nume_prestatie": r["nume_prestatie"],
|
||||
}
|
||||
for r in rows
|
||||
@@ -2669,6 +2699,17 @@ def post_mapare(
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = cod_prestatie.strip().upper()
|
||||
if cod == EXCLUDE_SENTINEL:
|
||||
# Regula "nu se declara la RAR": exclude + re-rezolva blocatele
|
||||
# (randurile cu toate operatiile excluse trec pe needs_data cu motiv).
|
||||
save_exclusion(conn, account_id, cod_op_service)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
msg = (
|
||||
f"{cod_op_service.strip()} exclus de la declarare. "
|
||||
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} oprite/date lipsa, "
|
||||
f"{stats['still_blocked']} inca nemapate."
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message=msg)
|
||||
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
|
||||
if not exists:
|
||||
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
||||
@@ -2715,6 +2756,16 @@ def post_editeaza_mapare_salvata(
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = cod_prestatie.strip().upper()
|
||||
if cod == EXCLUDE_SENTINEL:
|
||||
# Trecerea unei mapari salvate pe "nu se declara" — exclude + re-rezolva.
|
||||
save_exclusion(conn, account_id, cod_op_service)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
msg = (
|
||||
f"{cod_op_service.strip()} exclus de la declarare. "
|
||||
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} oprite/date lipsa, "
|
||||
f"{stats['still_blocked']} inca nemapate."
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message=msg)
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
||||
).fetchone()
|
||||
@@ -3131,6 +3182,7 @@ def _web_compute_preview(
|
||||
# needs_mapping si exclus din commit. Incarcate o data.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
|
||||
# Detectie coercion flags din valorile stocate (VIN numeric)
|
||||
coercion_flags_map: dict[int, list[str]] = {}
|
||||
@@ -3177,6 +3229,7 @@ def _web_compute_preview(
|
||||
valid_codes=valid_codes,
|
||||
text_rules=text_rules,
|
||||
reviewed=reviewed_flags[i],
|
||||
excluded_ops=excluded_ops,
|
||||
)
|
||||
|
||||
key: str | None = None
|
||||
@@ -3806,6 +3859,51 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
|
||||
conn.close()
|
||||
|
||||
|
||||
def _chips_state_from_form(form) -> list[dict[str, str]] | None:
|
||||
"""Reconstruieste starea chips prestatii din inputurile paralele ale formularului.
|
||||
|
||||
Aceeasi conventie ca /form-chips (_chips_prestatii.html): 3 liste paralele
|
||||
cod_prestatie / chip_op_service / chip_denumire. Codurile alese in pickere dar
|
||||
ne-aprobate prin '+' (chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit,
|
||||
ca in post_corectie_trimitere. Dedup pe perechea (op, cod).
|
||||
|
||||
Intoarce None cand formularul nu contine deloc stare de chips (form fara
|
||||
sectiunea de prestatii) — apelantul lasa prestatiile neatinse.
|
||||
"""
|
||||
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
||||
if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker:
|
||||
return None
|
||||
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
|
||||
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
|
||||
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
|
||||
n = max(len(cod_list), len(op_list), len(den_list))
|
||||
chips: list[dict[str, str]] = []
|
||||
for i in range(n):
|
||||
cod = cod_list[i] if i < len(cod_list) else ""
|
||||
if not cod:
|
||||
# picker per-operatie cu cod ales dar ne-aprobat prin '+ Adauga'
|
||||
cod = str(form.get(f"chips_add_cod_{i}") or "").strip().upper()
|
||||
chips.append({
|
||||
"cod_prestatie": cod,
|
||||
"cod_op_service": op_list[i] if i < len(op_list) else "",
|
||||
"denumire": den_list[i] if i < len(den_list) else "",
|
||||
})
|
||||
pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips}
|
||||
if flat_picker and ("", flat_picker) not in pairs:
|
||||
chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": ""})
|
||||
seen: set = set()
|
||||
out: list[dict[str, str]] = []
|
||||
for c in chips:
|
||||
if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]):
|
||||
continue
|
||||
pair = (c["cod_op_service"], c["cod_prestatie"])
|
||||
if pair in seen:
|
||||
continue
|
||||
seen.add(pair)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
||||
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
||||
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
|
||||
@@ -3825,13 +3923,50 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
|
||||
camp: (str(form.get(camp)) if form.get(camp) is not None else None)
|
||||
for camp in EDIT_FIELDS
|
||||
}
|
||||
# Chips prestatii din modal: None = form fara sectiune de chips (neatins).
|
||||
chips_state = _chips_state_from_form(form)
|
||||
conn = get_connection()
|
||||
try:
|
||||
# Invariant ORA-12899: orice cod trimis din form se valideaza fata de
|
||||
# nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide).
|
||||
if chips_state is not None:
|
||||
valid_codes = load_nomenclator_codes(conn) or set()
|
||||
necunoscute = sorted(
|
||||
{c["cod_prestatie"] for c in chips_state if c["cod_prestatie"]} - valid_codes
|
||||
)
|
||||
if necunoscute:
|
||||
_nom = load_nomenclator(conn)
|
||||
return templates.TemplateResponse("_editare_preview_modal.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"row_index": row_index,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"vin": str(form.get("vin") or ""),
|
||||
"stare_css": "",
|
||||
"stare_eticheta": "",
|
||||
"form_nr": str(form.get("nr_inmatriculare") or ""),
|
||||
"form_vin": str(form.get("vin") or ""),
|
||||
"form_data": str(form.get("data_prestatie") or ""),
|
||||
"form_odo_final": str(form.get("odometru_final") or ""),
|
||||
"form_odo_initial": str(form.get("odometru_initial") or ""),
|
||||
"err_map": {},
|
||||
"fix_map": {},
|
||||
"vin_context": str(form.get("vin") or ""),
|
||||
"btn_label": "Salveaza",
|
||||
"message": f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. "
|
||||
"Alege un cod valid din lista.",
|
||||
"prestatii_chips": chips_state,
|
||||
"has_r_odo": _has_r_odo_chips(chips_state),
|
||||
"obs_val": str(form.get("obs") or "").strip(),
|
||||
"nomenclator_rar": _nom,
|
||||
"form_chips_url": "/form-chips",
|
||||
})
|
||||
|
||||
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
|
||||
# pastreaza formularul modal cu valorile la 4xx/5xx).
|
||||
apply_row_override(
|
||||
conn, import_id=import_id, account_id=account_id,
|
||||
row_index=row_index, fields=fields,
|
||||
row_index=row_index, fields=fields, prestatii=chips_state,
|
||||
)
|
||||
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
||||
if row is None or isinstance(result, str):
|
||||
@@ -4080,6 +4215,7 @@ async def web_mapare_operatii(
|
||||
codes_list = form.getlist("cod_prestatie")
|
||||
|
||||
salvate: list[str] = []
|
||||
excluse_ops: list[str] = []
|
||||
sarite_invalide: list[str] = []
|
||||
|
||||
for cod_op_service, cod_prestatie in zip(ops_list, codes_list):
|
||||
@@ -4090,6 +4226,12 @@ async def web_mapare_operatii(
|
||||
if not cod_op_service or not cod_prestatie:
|
||||
continue
|
||||
|
||||
# Regula "nu se declara la RAR": exclude operatia de la declarare.
|
||||
if cod_prestatie == EXCLUDE_SENTINEL:
|
||||
save_exclusion(conn, account_id, cod_op_service)
|
||||
excluse_ops.append(cod_op_service)
|
||||
continue
|
||||
|
||||
# Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
|
||||
@@ -4105,10 +4247,12 @@ async def web_mapare_operatii(
|
||||
parts: list[str] = []
|
||||
if salvate:
|
||||
parts.append(f"Salvate: {', '.join(salvate)}.")
|
||||
if excluse_ops:
|
||||
parts.append(f"Excluse de la declarare: {', '.join(excluse_ops)}.")
|
||||
if sarite_invalide:
|
||||
parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.")
|
||||
message = " ".join(parts) if parts else None
|
||||
error = bool(sarite_invalide) and not salvate
|
||||
error = bool(sarite_invalide) and not salvate and not excluse_ops
|
||||
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
@@ -4322,9 +4466,10 @@ 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()}
|
||||
# validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
excluded_ops = load_excluded_ops(conn, acct)
|
||||
|
||||
# Rezolva mediul RAR tinta al lotului: form > default cont > ancora globala.
|
||||
try:
|
||||
@@ -4377,23 +4522,24 @@ async def web_confirma_import(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# Rezolva prestatii
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules)
|
||||
mapped["prestatii"] = resolved_p
|
||||
|
||||
# Canonicalizare
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
||||
# Override editat in preview — aplicat INAINTE de resolve_prestatii,
|
||||
# ca in _resolve_row_for_preview: prestatiile din override (chips
|
||||
# editate in modal) trec prin ACELASI resolve.
|
||||
override = item.get("override") or {}
|
||||
if override:
|
||||
mapped.update(override)
|
||||
|
||||
# Rezolva prestatii
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops)
|
||||
# Prestatiile excluse NU pleaca la RAR (ca in preview); rand fara nicio
|
||||
# prestatie declarabila = 'excluded' in preview -> defensiv skip.
|
||||
resolved_p, _excluse_p = split_prestatii_excluse(resolved_p)
|
||||
if not resolved_p:
|
||||
continue
|
||||
mapped["prestatii"] = resolved_p
|
||||
|
||||
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
"vin": canon["vin"],
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
|
||||
aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
||||
<option value="">— alege cod RAR —</option>
|
||||
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
|
||||
{% for n in nomenclator %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
@@ -129,7 +130,7 @@
|
||||
<tbody>
|
||||
{% for m in saved_mappings %}
|
||||
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
|
||||
<tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}">
|
||||
<tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}{% if m.exclus %} nedeclarat exclus{% endif %}">
|
||||
<td data-eticheta="Operatie">
|
||||
<form id="map-salv-{{ loop.index }}" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
@@ -142,14 +143,16 @@
|
||||
</form>
|
||||
<div><strong>{{ m.cod_op_service }}</strong></div>
|
||||
<div class="muted map-acum" style="font-size:12px;">
|
||||
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
||||
{% if m.exclus %}acum: <span class="pill s-excluded" style="font-size:11px;">Nu se declara la RAR</span>
|
||||
{% else %}acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td data-eticheta="Cod RAR">
|
||||
<select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
|
||||
aria-label="Cod RAR pentru {{ m.cod_op_service }}">
|
||||
<option value="__NEDECLARAT__" {% if m.exclus %}selected{% endif %}>Nu se declara la RAR (exclude operatia)</option>
|
||||
{% for n in nomenclator %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
|
||||
<option value="{{ n.cod_prestatie }}" {% if not m.exclus and n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
('needs_data', 'Date incomplete'),
|
||||
('already_sent', 'Deja trimis'),
|
||||
('duplicate_in_file','Duplicat in fisier'),
|
||||
('excluded', 'Nedeclarat'),
|
||||
] %}
|
||||
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
{% for status_key, label in status_labels %}
|
||||
@@ -126,6 +127,7 @@
|
||||
<div class="mapcol">
|
||||
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
||||
<option value="">— alege cod RAR —</option>
|
||||
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
|
||||
{% for n in nomenclator %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
||||
.s-ok{color:var(--ok);}
|
||||
.s-needs_review{color:var(--warn);}
|
||||
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
|
||||
.s-already_sent,.s-duplicate_in_file,.s-excluded{color:var(--muted);}
|
||||
/* Badge mediu RAR — semantica risc L.142.
|
||||
Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila.
|
||||
Testare: outline discret, ton --muted — mediu de proba, low-stakes.
|
||||
|
||||
304
tests/test_exclude_declarare.py
Normal file
304
tests/test_exclude_declarare.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Teste regula "exclude de la declarare" (operations_mapping.exclus).
|
||||
|
||||
O operatie exclusa nu se declara la RAR: randurile de import cu toate operatiile
|
||||
excluse devin 'excluded' (Nedeclarat) si nu se comit; operatiile excluse dispar
|
||||
din panoul de mapare; submission-urile API blocate trec pe needs_data cu motiv.
|
||||
Un cod ales explicit pe rand bate regula de excludere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web_preview_edit import ( # helpers reutilizate
|
||||
_csv_bytes,
|
||||
_get_csrf,
|
||||
_upload_and_preview,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "excl.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.crypto import reset_cache
|
||||
reset_cache()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
reset_cache()
|
||||
|
||||
|
||||
_ROWS_ITP = [
|
||||
{
|
||||
"VIN": "WVWZZZ3CZ9E123456",
|
||||
"Nr": "TM789BC",
|
||||
"Data": "2026-05-15",
|
||||
"KM": "82500",
|
||||
"Operatie": "OP-ITP",
|
||||
},
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000123",
|
||||
"Nr": "B001TST",
|
||||
"Data": "2026-06-10",
|
||||
"KM": "123456",
|
||||
"Operatie": "OP-1",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed(account_id: int = 1) -> None:
|
||||
"""Nomenclator + mapare OP-1 -> R-FRANE (OP-ITP ramane nemapat)."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
for cod, nume in (("R-FRANE", "Reparatie frane"), ("OE-2", "Verificare")):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
(cod, nume),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (?, 'OP-1', 'R-FRANE', 1)",
|
||||
(account_id,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row_status(iid: int, row_index: int) -> str:
|
||||
from app.db import get_connection
|
||||
from app.web.routes import _preview_one_row
|
||||
conn = get_connection()
|
||||
try:
|
||||
result, row = _preview_one_row(conn, iid, 1, row_index)
|
||||
assert row is not None and not isinstance(result, str)
|
||||
return row["resolved_status"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_panoul_de_mapare_ofera_optiunea_nedeclarat(client):
|
||||
_seed()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
r = client.get(f"/_import/{iid}/preview")
|
||||
assert r.status_code == 200
|
||||
assert '__NEDECLARAT__' in r.text, "Selectul de mapare trebuie sa ofere 'Nu se declara la RAR'"
|
||||
|
||||
|
||||
def test_exclude_din_preview_marcheaza_randul_nedeclarat(client):
|
||||
_seed()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
assert _row_status(iid, 0) == "needs_mapping"
|
||||
|
||||
csrf = _get_csrf(client)
|
||||
r = client.post(f"/_import/{iid}/mapare-operatii", data={
|
||||
"cod_op_service": "OP-ITP",
|
||||
"cod_prestatie": "__NEDECLARAT__",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "Excluse de la declarare: OP-ITP" in r.text
|
||||
assert "Nedeclarat" in r.text
|
||||
|
||||
assert _row_status(iid, 0) == "excluded"
|
||||
assert _row_status(iid, 1) == "ok"
|
||||
# Operatia exclusa nu mai apare in panoul de mapat
|
||||
assert "Operatii de mapat" not in r.text or "OP-ITP" not in r.text.split("Operatii de mapat")[1][:2000]
|
||||
|
||||
# Regula persistata
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
rule = conn.execute(
|
||||
"SELECT cod_prestatie, exclus FROM operations_mapping WHERE account_id=1 AND cod_op_service='OP-ITP'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert rule is not None and rule["exclus"] == 1 and rule["cod_prestatie"] == ""
|
||||
|
||||
|
||||
def test_commit_sare_randurile_excluse(client):
|
||||
_seed()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
csrf = _get_csrf(client)
|
||||
r = client.post(f"/_import/{iid}/mapare-operatii", data={
|
||||
"cod_op_service": "OP-ITP",
|
||||
"cod_prestatie": "__NEDECLARAT__",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Doar randul OP-1 e "gata de trimis" -> n_confirmat=1
|
||||
csrf = _get_csrf(client)
|
||||
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert rc.status_code == 200, rc.text
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
subs = conn.execute("SELECT payload_json FROM submissions WHERE batch_id=?", (iid,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
assert len(subs) == 1, "Doar randul declarabil se enqueue-uieste"
|
||||
payload = json.loads(subs[0]["payload_json"])
|
||||
assert payload["prestatii"][0]["cod_prestatie"] == "R-FRANE"
|
||||
|
||||
|
||||
def test_rand_mixt_exclude_doar_operatia_exclusa(client):
|
||||
"""Rand cu 2 operatii (una exclusa, una mapata) -> ok; payload fara cea exclusa."""
|
||||
_seed()
|
||||
rows = [{
|
||||
"VIN": "WVWZZZ1KZAW000789",
|
||||
"Nr": "B003TST",
|
||||
"Data": "2026-06-12",
|
||||
"KM": "90000",
|
||||
"Operatie": "OP-ITP",
|
||||
}]
|
||||
iid = _upload_and_preview(client, rows=rows)
|
||||
csrf = _get_csrf(client)
|
||||
client.post(f"/_import/{iid}/mapare-operatii", data={
|
||||
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
|
||||
})
|
||||
assert _row_status(iid, 0) == "excluded"
|
||||
|
||||
# Editorul de rand: adauga explicit un cod suplimentar -> randul redevine declarabil
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||
"cod_prestatie": ["", "OE-2"],
|
||||
"chip_op_service": ["OP-ITP", ""],
|
||||
"chip_denumire": ["", ""],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _row_status(iid, 0) == "ok"
|
||||
|
||||
csrf = _get_csrf(client)
|
||||
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert rc.status_code == 200, rc.text
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
sub = conn.execute("SELECT payload_json FROM submissions WHERE batch_id=?", (iid,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
payload = json.loads(sub["payload_json"])
|
||||
coduri = [p.get("cod_prestatie") for p in payload["prestatii"]]
|
||||
assert coduri == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri}"
|
||||
|
||||
|
||||
def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client):
|
||||
"""Chips cu cod explicit PE operatia exclusa -> codul ales de user castiga."""
|
||||
_seed()
|
||||
rows = [_ROWS_ITP[0]]
|
||||
iid = _upload_and_preview(client, rows=rows)
|
||||
csrf = _get_csrf(client)
|
||||
client.post(f"/_import/{iid}/mapare-operatii", data={
|
||||
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
|
||||
})
|
||||
assert _row_status(iid, 0) == "excluded"
|
||||
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||
"cod_prestatie": "OE-2",
|
||||
"chip_op_service": "OP-ITP",
|
||||
"chip_denumire": "Inspectie",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _row_status(iid, 0) == "ok"
|
||||
|
||||
|
||||
def test_mapari_tab_exclude_si_rerezolva_submissions_api(client):
|
||||
"""Excluderea din tab-ul Mapari trece submission-urile blocate (canal API) pe needs_data."""
|
||||
_seed()
|
||||
# Submission blocat pe OP-XYZ (canal API, batch_id NULL)
|
||||
r = client.post("/v1/prezentari", json={
|
||||
"rar_credentials": {"email": "x@y.ro", "password": "s"},
|
||||
"prezentari": [{
|
||||
"vin": "WVWZZZ1KZAW000555",
|
||||
"nr_inmatriculare": "B055TST",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_op_service": "OP-XYZ", "denumire": "Operatie interna"}],
|
||||
}],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
sid = r.json()["results"][0]["submission_id"]
|
||||
assert r.json()["results"][0]["status"] == "needs_mapping"
|
||||
|
||||
# Operatia apare la mapat
|
||||
frag = client.get("/_fragments/mapari")
|
||||
assert "OP-XYZ" in frag.text
|
||||
|
||||
csrf = _get_csrf(client)
|
||||
resp = client.post("/mapari", data={
|
||||
"cod_op_service": "OP-XYZ",
|
||||
"cod_prestatie": "__NEDECLARAT__",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "exclus de la declarare" in resp.text
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT status, rar_error FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||
# Operatia exclusa nu mai apare in pending
|
||||
from app.mapping import pending_unmapped
|
||||
pend = pending_unmapped(conn, 1)
|
||||
finally:
|
||||
conn.close()
|
||||
assert row["status"] == "needs_data"
|
||||
assert "excluse de la declarare" in (row["rar_error"] or "")
|
||||
assert all(e["cod_op_service"] != "OP-XYZ" for e in pend)
|
||||
|
||||
|
||||
def test_maparea_unei_operatii_excluse_reactiveaza_declararea(client):
|
||||
"""save_mapping peste o regula de excludere reseteaza exclus=0."""
|
||||
_seed()
|
||||
from app.db import get_connection
|
||||
from app.mapping import save_exclusion, load_excluded_ops, load_mapping
|
||||
conn = get_connection()
|
||||
try:
|
||||
save_exclusion(conn, 1, "OP-ITP")
|
||||
assert "OP-ITP" in load_excluded_ops(conn, 1)
|
||||
from app.mapping import save_mapping
|
||||
save_mapping(conn, 1, "OP-ITP", "OE-2", auto_send=False)
|
||||
assert "OP-ITP" not in load_excluded_ops(conn, 1)
|
||||
assert load_mapping(conn, 1)["OP-ITP"] == "OE-2"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_ingestie_api_cu_operatie_exclusa_nu_blocheaza(client):
|
||||
"""POST /v1/prezentari cu operatie deja exclusa -> needs_data cu motiv, nu needs_mapping."""
|
||||
_seed()
|
||||
from app.db import get_connection
|
||||
from app.mapping import save_exclusion
|
||||
conn = get_connection()
|
||||
try:
|
||||
save_exclusion(conn, 1, "OP-ITP")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post("/v1/prezentari", json={
|
||||
"rar_credentials": {"email": "x@y.ro", "password": "s"},
|
||||
"prezentari": [{
|
||||
"vin": "WVWZZZ1KZAW000777",
|
||||
"nr_inmatriculare": "B077TST",
|
||||
"data_prestatie": "2026-06-21",
|
||||
"odometru_final": "60000",
|
||||
"prestatii": [{"cod_op_service": "OP-ITP", "denumire": "ITP"}],
|
||||
}],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
rez = r.json()["results"][0]
|
||||
assert rez["status"] == "needs_data", rez
|
||||
@@ -538,3 +538,155 @@ def test_base_html_deschide_modal_pentru_btn_editeaza():
|
||||
".btn-editeaza (sau sa verifice hx-target='#detaliu-modal-body') pentru a "
|
||||
"apela open() si instala focus-trap-ul."
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Persistarea chips-urilor prestatii la salvarea din modal #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_ROWS_ITP = [
|
||||
{
|
||||
"VIN": "WVWZZZ3CZ9E123456",
|
||||
"Nr": "TM789BC",
|
||||
"Data": "2026-05-15",
|
||||
"KM": "82500",
|
||||
"Operatie": "OP-ITP",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed_nomenclator_extra() -> None:
|
||||
"""Nomenclator cu mai multe coduri pentru testele de chips (fara mapare OP-ITP)."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
for cod, nume in (("R-FRANE", "Reparatie frane"), ("OE-2", "Verificare"), ("OE-3", "Reglaj")):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
(cod, nume),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _preview_row0(client: TestClient, iid: int) -> dict:
|
||||
"""Recalculeaza preview-ul si intoarce randul 0 rezolvat (direct pe DB)."""
|
||||
from app.db import get_connection
|
||||
from app.web.routes import _preview_one_row
|
||||
conn = get_connection()
|
||||
try:
|
||||
result, row = _preview_one_row(conn, iid, 1, 0)
|
||||
assert row is not None and not isinstance(result, str)
|
||||
return row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_salvare_chips_persista_si_deblocheaza_randul(client):
|
||||
"""Bug reprodus: cod ales in editor pentru operatie nemapata + coduri extra,
|
||||
Salveaza -> randul ramanea needs_mapping si chips-urile se pierdeau la redeschidere.
|
||||
|
||||
Dupa fix: chips-urile din form se persista in override -> randul devine ok,
|
||||
iar redeschiderea editorului arata codurile alese.
|
||||
"""
|
||||
_seed_nomenclator_extra()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
|
||||
row = _preview_row0(client, iid)
|
||||
assert row["resolved_status"] == "needs_mapping"
|
||||
|
||||
# Salvare cu chips: OP-ITP -> OE-2 + cod extra OE-3 (fara operatie).
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||
"cod_prestatie": ["OE-2", "OE-3"],
|
||||
"chip_op_service": ["OP-ITP", ""],
|
||||
"chip_denumire": ["Inspectie Tehnica Periodica", ""],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "inchideModal" in r.headers.get("HX-Trigger-After-Settle", ""), \
|
||||
"Salvarea cu chips valide trebuie sa inchida modalul (succes)"
|
||||
|
||||
row = _preview_row0(client, iid)
|
||||
assert row["resolved_status"] == "ok", f"Randul trebuie deblocat, e {row['resolved_status']}"
|
||||
coduri = [p.get("cod_prestatie") for p in row["resolved"]["prestatii"]]
|
||||
assert coduri == ["OE-2", "OE-3"], coduri
|
||||
|
||||
# Redeschiderea editorului arata chips-urile salvate.
|
||||
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
|
||||
assert r2.status_code == 200
|
||||
assert "OE-2" in r2.text and "OE-3" in r2.text
|
||||
|
||||
|
||||
def test_salvare_chips_cod_din_picker_neaprobat_se_aplica(client):
|
||||
"""Cod ales in pickerul per-operatie dar ne-aprobat prin '+ Adauga' se aplica la salvare."""
|
||||
_seed_nomenclator_extra()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||
"cod_prestatie": "",
|
||||
"chip_op_service": "OP-ITP",
|
||||
"chip_denumire": "Inspectie Tehnica Periodica",
|
||||
"chips_add_cod_0": "OE-2",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
row = _preview_row0(client, iid)
|
||||
assert row["resolved_status"] == "ok"
|
||||
assert row["resolved"]["prestatii"][0]["cod_prestatie"] == "OE-2"
|
||||
|
||||
|
||||
def test_salvare_chips_cod_necunoscut_respins(client):
|
||||
"""Cod inexistent in nomenclator -> modal re-randat cu eroare, override neatins."""
|
||||
_seed_nomenclator_extra()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||
"cod_prestatie": "XX-INEXISTENT",
|
||||
"chip_op_service": "OP-ITP",
|
||||
"chip_denumire": "Inspectie",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert "inchideModal" not in r.headers.get("HX-Trigger-After-Settle", "")
|
||||
assert "necunoscut" in r.text
|
||||
row = _preview_row0(client, iid)
|
||||
assert row["resolved_status"] == "needs_mapping", "Codul invalid nu trebuie persistat"
|
||||
|
||||
|
||||
def test_salvare_fara_sectiune_chips_lasa_prestatiile_neatinse(client):
|
||||
"""Form fara inputuri de chips (doar campuri de continut) nu atinge prestatiile."""
|
||||
_seed_op1()
|
||||
iid = _upload_and_preview(client)
|
||||
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"data_prestatie": "2026-06-15"})
|
||||
assert r.status_code == 200
|
||||
row = _preview_row0(client, iid)
|
||||
assert row["resolved"]["prestatii"][0]["cod_prestatie"] == "R-FRANE"
|
||||
|
||||
|
||||
def test_commit_foloseste_prestatiile_editate_in_modal(client):
|
||||
"""Confirmarea batch-ului trimite prestatiile din override (chips editate), re-rezolvate."""
|
||||
import json as _json
|
||||
_seed_nomenclator_extra()
|
||||
iid = _upload_and_preview(client, rows=_ROWS_ITP)
|
||||
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||
"cod_prestatie": "OE-2",
|
||||
"chip_op_service": "OP-ITP",
|
||||
"chip_denumire": "Inspectie Tehnica Periodica",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
csrf = _get_csrf(client)
|
||||
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert rc.status_code == 200, rc.text
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
sub = conn.execute(
|
||||
"SELECT payload_json FROM submissions WHERE batch_id=?", (iid,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert sub is not None, "Randul editat trebuie enqueued la confirmare"
|
||||
payload = _json.loads(sub["payload_json"])
|
||||
assert payload["prestatii"][0]["cod_prestatie"] == "OE-2"
|
||||
|
||||
Reference in New Issue
Block a user