diff --git a/TODOS.md b/TODOS.md index 50745c6..6806668 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,15 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare. +## Din hardening 80/20 (/autoplan, 2026-07-03) + +- [ ] **Traefik IP-allowlist pe /v1 + /metrics in fereastra de lansare** (T4) — cu zero clienti API, + suprafata anonima de ingestie n-are apelant legitim extern (ROAAUTO propriu vine de la IP cunoscut). + O regula Traefik declarativa (allowlist IP ROAAUTO + admin) elimina complet ingestia anonima in + perioada cea mai riscanta. Deferat: P0-1 (auth obligatoriu pe /v1) + T3 (startup guard) acopera deja + gaura principala. De activat cand: primesc trafic de scanare pe /v1 SAU inainte de expunere cu presa. + Effort: S (config Traefik). Depinde de: acces la config Traefik in Dokploy. + ## Din PRD 5.12 (2026-06-26) - [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci diff --git a/app/accounts.py b/app/accounts.py index 0fdb3d9..23ec5b8 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -150,6 +150,40 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None: ) +def get_auto_send(conn: sqlite3.Connection, account_id: int) -> bool: + """Comutatorul Auto per cont (PRD 5.19 US-001): `accounts.auto_send_enabled` ca bool. + + Cont inexistent -> False (Auto OFF: sigur, tine randurile). Nu comita (ca set_active). + """ + row = conn.execute( + "SELECT auto_send_enabled FROM accounts WHERE id=?", (account_id,) + ).fetchone() + if not row: + return False + return bool(row["auto_send_enabled"]) + + +def set_auto_send(conn: sqlite3.Connection, account_id: int, enabled: bool) -> None: + """Comuta `accounts.auto_send_enabled` (PRD 5.19 US-001). Idempotent (set pe aceeasi + valoare nu arunca). Scoped pe cont. Nu comita — respecta conventia fisierului (apelantul + comita, ca la set_active/set_status). Cont inexistent -> UPDATE fara efect (no-op sigur). + """ + conn.execute( + "UPDATE accounts SET auto_send_enabled=? WHERE id=?", + (1 if enabled else 0, account_id), + ) + + +def held_for_account(conn: sqlite3.Connection, account_id: int) -> int: + """Snapshot-ul `held` la ingestie (PRD 5.19 US-001). SURSA UNICA (chokepoint) folosita de + TOATE situ-rile de ingestie care scriu `status='queued'` (US-002). + + Intoarce 0 (nu tine) daca `accounts.auto_send_enabled=1`, altfel 1 (tine randul). Cont + inexistent -> 1 (sigur: tine randul, nu-l trimite fara comutator explicit). + """ + return 0 if get_auto_send(conn, account_id) else 1 + + def set_tier( conn: sqlite3.Connection, account_id: int, diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index 61c566f..5935660 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -32,6 +32,7 @@ from pydantic import BaseModel, Field from datetime import datetime, timezone from ... import errors +from ...accounts import held_for_account from ...auth import require_api_access, resolve_account_id from ...crypto import decrypt_creds, encrypt_creds from ...db import get_connection @@ -1125,6 +1126,9 @@ def commit_import( conn.execute("BEGIN IMMEDIATE") try: purge_after_sql = "datetime('now', '+90 days')" + # PRD 5.19 US-002: snapshot `held` din comutatorul contului (chokepoint unic + # held_for_account) — 0 daca Auto ON, 1 daca Auto OFF. Per-cont -> o data. + held_val = held_for_account(conn, acct) for ok_row in ok_rows: row_dict = ok_row["data"] @@ -1208,9 +1212,9 @@ def commit_import( # INSERT ON CONFLICT DO NOTHING (TOCTOU) cur = conn.execute( "INSERT OR IGNORE INTO submissions " - "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) " - "VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)", - (key, acct, payload_json, import_id, row_index, env), + "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env, held) " + "VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?, ?)", + (key, acct, payload_json, import_id, row_index, env, held_val), ) if cur.rowcount == 0: diff --git a/app/api/v1/router.py b/app/api/v1/router.py index cca26ad..6e1ea56 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +from ...accounts import held_for_account from ...auth import require_api_access, resolve_account_id from ...crypto import encrypt_creds from ...db import get_connection @@ -87,6 +88,20 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]: ] +# PRD 5.19 US-010: mesaj uman pentru un rand tinut manual (queued AND held=1). +# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea +# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals. +MOTIV_HELD = ( + "In asteptare — tinut pentru verificare; NU trimis la RAR (Auto OFF). " + "Elibereaza manual (trimite-acum) sau activeaza Trimiterea automata." +) + + +def _motiv_held(status: str, held: bool) -> str | None: + """Motiv uman pentru un rand tinut manual; None cand nu e cazul (status!=queued sau held=0).""" + return MOTIV_HELD if (held and status == "queued") else None + + def _motiv_clasificare(cl: dict) -> str | None: """Rezumat uman pe o linie pentru un rezultat de clasificare. @@ -107,18 +122,22 @@ def _motiv_clasificare(cl: dict) -> str | None: return None -def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult: +def _rezultat_enqueue(submission_id: int | None, cl: dict, held: int = 0, **extra) -> SubmissionResult: """SubmissionResult onest dintr-un rezultat de clasificare. Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman) - pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None. + pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None, EXCEPTAND + randurile tinute manual (PRD 5.19 US-010: `held=1` -> held=true + motiv non-null). """ + tinut = bool(held) + motiv = _motiv_clasificare(cl) or _motiv_held(cl["status"], tinut) return SubmissionResult( submission_id=submission_id, status=cl["status"], erori=list(cl["errors"]), nemapate=_erori_nemapate(cl["unmapped"]), - motiv=_motiv_clasificare(cl), + motiv=motiv, + held=tinut, **extra, ) @@ -240,6 +259,11 @@ def create_prezentari( ), ) + # PRD 5.19 US-002: snapshot `held` din comutatorul contului (chokepoint unic + # held_for_account). 0 daca Auto ON, 1 daca Auto OFF (tine randul). Depinde de + # cont, nu de rand -> calculat o data. NU intra in payload/idempotenta. + held_val = held_for_account(conn, acct) + for prez in req.prezentari: content = prez.model_dump() # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). @@ -254,7 +278,7 @@ def create_prezentari( "odometru_final": canon["odometru_final"], }) existing = conn.execute( - "SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?", + "SELECT id, status, id_prezentare, held FROM submissions WHERE idempotency_key=?", (key,), ).fetchone() if existing: @@ -267,13 +291,16 @@ def create_prezentari( # on_unmapped_error=True: nu reactivam; randul ramane 'error'. results.append(_rezultat_respins(existing["id"], cl, rar_env=env)) continue + # PRD 5.19 US-002 (Eng Finding A — bug de bypass): la reactivare + # re-snapshot-am `held` din comutatorul contului. Fara asta randul + # pastra held VECHI si se auto-trimitea desi contul e Auto OFF. cur = conn.execute( "UPDATE submissions SET status=?, payload_json=?, rar_error=?, " "rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, " "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, " - "rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'", + "held=?, rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'", (cl["status"], json.dumps(cl["content"], ensure_ascii=False), - cl["rar_error"], creds_enc, env, existing["id"]), + cl["rar_error"], creds_enc, held_val, env, existing["id"]), ) if cur.rowcount == 1: # Creds noi se propaga si in slotul durabil per-env al contului @@ -286,20 +313,25 @@ def create_prezentari( _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"]) # Raspuns onest si la reactivare: daca re-clasificarea cade pe # needs_data/needs_mapping, expune motivul (nu doar status). - results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True, rar_env=env)) + results.append(_rezultat_enqueue(existing["id"], cl, held=held_val, reactivated=True, rar_env=env)) continue # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE # (rowcount==0) -> raspuns dedup pe starea CURENTA. existing = conn.execute( - "SELECT id, status, id_prezentare FROM submissions WHERE id=?", + "SELECT id, status, id_prezentare, held FROM submissions WHERE id=?", (existing["id"],), ).fetchone() + # Echo onest pe dedup (PRD 5.19 US-002/US-010): propaga `held` al randului + # existent — un rand tinut NU trebuie sa apara ca un "queued" curat fals. + dedup_held = bool(existing["held"]) results.append( SubmissionResult( submission_id=existing["id"], status=existing["status"], id_prezentare=existing["id_prezentare"], deduped=True, + held=dedup_held, + motiv=_motiv_held(existing["status"], dedup_held), rar_env=env, ) ) @@ -312,15 +344,17 @@ def create_prezentari( # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). results.append(_rezultat_respins(None, cl, rar_env=env)) continue + # PRD 5.19 US-002: `held` = snapshot comutator cont (chokepoint held_for_account). cur = conn.execute( - "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env), + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env, held) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env, held_val), ) sub_id = int(cur.lastrowid) _emite_text_rule_hits(conn, acct, sub_id, cl["resolved"]) - # Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv. - results.append(_rezultat_enqueue(sub_id, cl, rar_env=env)) + # Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv; + # pe queued tinut manual (held=1) expune held=true + motiv. + results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env)) # Audit cerere API per cont. Doar metadate (count + distributie status), # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL). @@ -433,7 +467,7 @@ def list_prezentari( # rar_env inclus (US-005): badge mediu in lista. cols = ( "id, status, id_prezentare, rar_status_code, retry_count, " - "created_at, updated_at, payload_json, rar_env" + "created_at, updated_at, payload_json, rar_env, held" ) if status: rows = conn.execute( @@ -470,6 +504,8 @@ _PREZENTARE_FIELDS = frozenset({ "rar_error", # US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API. "rar_env", + # PRD 5.19 US-010: flag `held` (tinut manual) — onestitate GET (dev vede ca NU a plecat). + "held", }) @@ -548,6 +584,38 @@ def repune_prezentare( conn.close() +@router.post("/prezentari/{submission_id}/trimite-acum") +def trimite_acum_prezentare( + submission_id: int, + account_id: int = Depends(resolve_account_id), +) -> dict: + """Elibereaza manual un rand tinut (PRD 5.19 US-010, paritate API cu /repune). + + Scoped strict pe contul cheii API (account_id din sesiune/cheie, NICIODATA din body). + 404-before-leak pe id strain/inexistent. `held: 1 -> 0` DOAR daca randul e + `queued AND held=1` (no-op sigur altfel: un rand deja `sending`/`sent` ramane neatins). + Worker-ul preia randul la urmatorul poll (trimitere asincrona). + """ + conn = get_connection() + try: + scope_sql, scope_params = account_scope_clause(account_id) + row = conn.execute( + f"SELECT id FROM submissions WHERE id=? AND {scope_sql}", + [submission_id] + scope_params, + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="submission inexistent") + cur = conn.execute( + f"UPDATE submissions SET held=0, updated_at=datetime('now') " + f"WHERE id=? AND {scope_sql} AND status='queued' AND held=1", + [submission_id] + scope_params, + ) + conn.commit() + return {"ok": True, "eliberat": cur.rowcount == 1} + finally: + conn.close() + + @router.get("/nomenclator") def get_nomenclator() -> dict: conn = get_connection() diff --git a/app/config.py b/app/config.py index 1d8cb2a..5444871 100644 --- a/app/config.py +++ b/app/config.py @@ -35,6 +35,12 @@ class Settings(BaseSettings): # Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z # ale `sent` — un blocat n-are valoare de audit. blocked_retention_days: int = 30 + # Prag zile (PRD 5.19 US-007): randurile tinute manual (`queued AND held=1`) mai vechi de + # atat declanseaza bannerul de avertizare L.142 (declararea are termen legal). + held_warn_days: int = 7 + # Retentie randuri tinute manual (PRD 5.19 US-008): un `queued AND held=1` mai vechi de atat + # expira la `error` (`TINUT_EXPIRAT`) + purge_after direct. Aliniat retentiei `sent` (90z). + held_retention_days: int = 90 # --- Securitate --- # Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie -> diff --git a/app/db.py b/app/db.py index 74367a3..70032e4 100644 --- a/app/db.py +++ b/app/db.py @@ -83,6 +83,13 @@ def _migrate(conn: sqlite3.Connection) -> None: "CHECK (rar_env IN ('test', 'prod'))" ) _backfill_submissions_rar_env(conn) + if "held" not in sub_cols: + # PRD 5.19 US-001: flag rand tinut manual. Default 0 (randurile pre-migrare nu erau + # tinute — pastreaza comportamentul; comutatorul de cont e OFF/held la ingestie NOUA). + conn.execute( + "ALTER TABLE submissions ADD COLUMN held INTEGER NOT NULL DEFAULT 0 " + "CHECK (held IN (0, 1))" + ) # Coloane accounts acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} @@ -113,6 +120,14 @@ def _migrate(conn: sqlite3.Connection) -> None: "ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 " "CHECK (on_unmapped_error_default IN (0, 1))" ) + if "auto_send_enabled" not in acc_cols: + # PRD 5.19 US-001: comutator Auto per cont. Default 0 (Auto OFF) — conturile existente + # trec pe „tine randurile" la ingestie noua pana cand operatorul porneste Auto explicit. + # Contul id=1 ramane pe default (0), fara tratament special. + conn.execute( + "ALTER TABLE accounts ADD COLUMN auto_send_enabled INTEGER NOT NULL DEFAULT 0 " + "CHECK (auto_send_enabled IN (0, 1))" + ) if "email" not in acc_cols: # Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy. conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT") @@ -178,6 +193,12 @@ def _migrate(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_submissions_account_status " "ON submissions(account_id, status)" ) + if "idx_submissions_held" not in existing_idx: + # PRD 5.19 US-001 (Eng MEDIUM): CREATE TABLE IF NOT EXISTS nu se declanseaza pe DB + # existent -> indexul partial trebuie creat si aici, nu doar in schema.sql. + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_submissions_held ON submissions(held) WHERE held=1" + ) def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None: diff --git a/app/main.py b/app/main.py index c4ce7a2..cd5feda 100644 --- a/app/main.py +++ b/app/main.py @@ -177,9 +177,21 @@ def metrics() -> str: conn = get_connection() try: rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() + # Coada tinuta (PRD 5.19 US-007): observabilitate ops pt. default Auto OFF. + # Gauge-uri DERIVATE (zero stare noua): total randuri tinute + varsta celui + # mai vechi (din created_at), scoped global. Semnaleaza esecul silentios + # cand prezentari raman nedeclarate tacit (risc L.142). + held = conn.execute( + "SELECT COUNT(*) AS n, " + "COALESCE(MAX(strftime('%s','now') - strftime('%s', created_at)), 0) AS oldest " + "FROM submissions WHERE status='queued' AND held=1" + ).fetchone() finally: conn.close() lines = ["# submissions pe status"] for r in rows: lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}') + # randuri tinute (queued AND held=1) + lines.append(f'autopass_held_submissions {held["n"]}') + lines.append(f'autopass_held_oldest_age_seconds {held["oldest"]}') return "\n".join(lines) + "\n" diff --git a/app/mapping.py b/app/mapping.py index 28eeca2..9fe16d0 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -23,6 +23,7 @@ from typing import Any from rapidfuzz import fuzz, process from . import errors as err_mod +from .accounts import held_for_account from .nomenclator_seed import FALLBACK_NOMENCLATOR from .validation import validate_prezentare @@ -891,10 +892,14 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) ) stats["needs_data"] += 1 else: + # PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la trecerea + # needs_mapping -> queued (chokepoint held_for_account, `acct` deja normalizat + # prin account_or_default la intrarea in reresolve_account). Altfel un rand + # deblocat din needs_mapping ar pleca automat desi contul e Auto OFF (R2). conn.execute( "UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, " - "retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?", - (payload_json, r["id"]), + "retry_count=0, next_attempt_at=NULL, held=?, updated_at=datetime('now') WHERE id=?", + (payload_json, held_for_account(conn, acct), r["id"]), ) stats["requeued"] += 1 return stats diff --git a/app/models.py b/app/models.py index d493e43..d5d001d 100644 --- a/app/models.py +++ b/app/models.py @@ -109,6 +109,10 @@ class SubmissionResult(BaseModel): # RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza # semantica actuala (clientii vechi care testeaza `deduped` nu se sparg). reactivated: bool = False + # Ecou PUR de coada (PRD 5.19 US-010): True cand randul e `queued AND held=1` + # (tinut manual, cont Auto OFF) -> NU a plecat la RAR, asteapta eliberarea umana. + # NU influenteaza payload/idempotenta; onestitate fata de integratorul API (invariant 5.7). + held: bool = False # Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie). rar_env: str = "test" # Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune diff --git a/app/schema.sql b/app/schema.sql index 3da0ce2..1582cbf 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -33,6 +33,14 @@ CREATE TABLE IF NOT EXISTS accounts ( -- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error. on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 CHECK (on_unmapped_error_default IN (0, 1)), + -- Comutator Auto per cont (PRD 5.19 US-001). AL TREILEA `auto_send` din schema (R6): + -- DISTINCT de operations_mapping.auto_send si operation_text_rules.auto_send, care sunt + -- PER-OPERATIE. Acesta e PER-CONT si guverneaza DOAR: (a) valoarea implicita a lui + -- submissions.held la ingestie (held = 0 daca 1, altfel 1 — vezi accounts.held_for_account); + -- (b) eliberarea in bloc a randurilor tinute la comutarea OFF -> ON. NU e per-operatie si + -- NU intra in payload/idempotenta/RAR. Default 0 = Auto OFF (randuri noi tinute manual). + auto_send_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (auto_send_enabled IN (0, 1)), -- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier). -- trial_until: daca != NULL si > now -> effective_tier() intoarce 'pro' (trial Pro activ). -- Cont nou primeste tier='free' + trial_until=now+30z via create_account. @@ -110,13 +118,23 @@ CREATE TABLE IF NOT EXISTS submissions ( purge_after TEXT, -- sent + 90z (T16) batch_id INTEGER, -- import batch (T7; NULL = canal API) row_index INTEGER, -- rand in batch (T7; NULL = canal API) + -- Randuri tinute manual (PRD 5.19 US-001). rand `queued AND held=1` = "In asteptare + -- (manual)": tinut pana la eliberare umana (trimitere manuala per rand/bulk: held 1 -> 0). + -- Worker-ul (claim_one) ia doar `queued AND held=0`. held NU intra in payload/idempotenta + -- si NU pleaca la RAR — e pur control de coada (ca import_rows.reviewed). Snapshot la + -- ingestie din accounts.auto_send_enabled (vezi accounts.held_for_account). + held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0, 1)), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status); CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status); --- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche). +-- Nota: idx_submissions_batch si idx_submissions_held se creeaza in _migrate, NU aici. `init_db` +-- ruleaza executescript(schema.sql) INAINTE de _migrate; pe un DB existent pre-`held`/`batch_id` +-- coloana inca nu exista in momentul executarii schemei, deci un CREATE INDEX pe ea ar esua +-- (no such column). Indexul partial `idx_submissions_held ON submissions(held) WHERE held=1` +-- (PRD 5.19 US-001) traieste doar in _migrate, ca `idx_submissions_batch`. -- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane). CREATE TABLE IF NOT EXISTS column_mappings ( diff --git a/app/submissions_admin.py b/app/submissions_admin.py index 8ed7734..2e648f6 100644 --- a/app/submissions_admin.py +++ b/app/submissions_admin.py @@ -20,6 +20,7 @@ from __future__ import annotations import json +from .accounts import held_for_account from .mapping import ( account_or_default, account_scope_clause, @@ -103,11 +104,15 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict: valid_codes = load_nomenclator_codes(conn) or None cl = classify_prezentare(content, mapping, mapping_meta, valid_codes) + # PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada + # (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra + # `held` VECHI si s-ar auto-trimite desi contul e Auto OFF (sau ar ramane blocat cand e ON). + held_val = held_for_account(conn, account_or_default(account_id)) conn.execute( "UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, " - "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, updated_at=datetime('now') " + "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, held=?, updated_at=datetime('now') " "WHERE id=?", - (cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid), + (cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], held_val, sid), ) log_event( "submission_repus", diff --git a/app/web/labels.py b/app/web/labels.py index 9c7c060..80c728f 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -71,13 +71,40 @@ ETICHETE_SCURTE: dict[str, str] = { } -def eticheta_scurta(status: str) -> str: +# --------------------------------------------------------------------------- +# Randuri tinute manual (held) — US-006, PRD 5.19 +# +# `held` NU e o stare noua in masina de stari: un rand cu `status='queued' AND +# held=1` este in coada, dar tinut pana la eliberarea umana. Il afisam distinct +# ("In asteptare (manual)") cu culoare de AVERTIZARE (--warn / amber) — asteptare +# benigna, NU eroare. Reutilizam clasa `s-needs_review` (definita in base.html ca +# `color:var(--warn)`); NU folosim clasele needs_* rosii (s-needs_data / +# s-needs_mapping = --err). CHECK-ul din schema.sql ramane neatins. +# --------------------------------------------------------------------------- + +ETICHETA_HELD: Eticheta = ( + "In asteptare (manual)", + "Tinuta pentru confirmarea ta; nu pleaca spre RAR pana nu o eliberezi.", + "s-needs_review", +) + +ETICHETA_HELD_SCURTA = "Manual" + + +def eticheta_scurta(status: str, held: bool = False) -> str: """ Returneaza eticheta compacta (pill) pentru o stare de submission. + Cand `status='queued'` si `held` este adevarat, randul e tinut manual si + intoarce "Manual" (distinct de "In coada") — vezi PRD 5.19 D5. Pentru orice + alta stare `held` este ignorat, iar apelul fara `held` ramane identic cu cel + dinainte de US-006. + Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda stari noi adaugate in schema fara mapare corespunzatoare. """ + if held and status == "queued": + return ETICHETA_HELD_SCURTA try: return ETICHETE_SCURTE[status] except KeyError: @@ -87,13 +114,20 @@ def eticheta_scurta(status: str) -> str: ) -def eticheta_stare(status: str) -> Eticheta: +def eticheta_stare(status: str, held: bool = False) -> Eticheta: """ Returneaza (text, subtext, css_class) pentru o stare de submission. + Cand `status='queued'` si `held` este adevarat, randul e tinut manual si + intoarce eticheta "In asteptare (manual)" cu clasa de avertizare (amber / + --warn) — vezi PRD 5.19 US-006/D5. Pentru orice alta stare `held` este + ignorat, iar apelul fara `held` ramane identic cu cel dinainte de US-006. + Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda stari noi adaugate in schema fara mapare corespunzatoare. """ + if held and status == "queued": + return ETICHETA_HELD try: return STARI_SUBMISSION[status] except KeyError: diff --git a/app/web/routes.py b/app/web/routes.py index f175ad8..5ab9b55 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -53,6 +53,7 @@ from ..api.v1.import_router import ( apply_row_override, EDIT_FIELDS, ) +from ..accounts import get_auto_send, held_for_account, set_auto_send from ..config import get_settings from ..crypto import decrypt_creds, encrypt_creds from ..db import get_connection, read_app_events, read_heartbeat @@ -245,6 +246,29 @@ def _status_counts(conn, account_id: int) -> dict[str, int]: return counts +def _held_counts(conn, account_id: int) -> tuple[int, int]: + """Contoare randuri tinute manual (PRD 5.19 US-006/US-007) pentru contul din sesiune. + + Intoarce (manual_count, aged_count): + - manual_count = randuri `queued AND held=1` (contorul "In asteptare (manual)"). + - aged_count = subset mai vechi de `held_warn_days` (banner de conformitate L.142). + Derivat, fara stare noua; varsta pe `created_at` (US-007 AC). + """ + days = int(get_settings().held_warn_days) + row = conn.execute( + "SELECT " + " COUNT(*) AS manual, " + " COUNT(CASE WHEN created_at < datetime('now', ?) THEN 1 END) AS aged " + "FROM submissions " + "WHERE status='queued' AND held=1 " + " AND (account_id = ? OR (? = 1 AND account_id IS NULL))", + (f"-{days} days", account_id, account_id), + ).fetchone() + if not row: + return (0, 0) + return (int(row["manual"] or 0), int(row["aged"] or 0)) + + def _trimiteri_versiune(conn, account_id: int) -> str: """Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent updated_at. Se schimba la orice insert/update/delete -> nudge-ul "Date noi" o compara @@ -340,9 +364,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: counts = _status_counts(conn, account_id) blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) + # US-005 (5.19): buton bulk "Trimite toate (N)" — N = randuri tinute ale contului. + # env-ul tinta al eliberarii (badge in modalul de confirmare tipata). + held_manual_count, _held_aged = _held_counts(conn, account_id) + env_bulk = rar_env_efectiv_cont(conn, account_id) or "prod" + return { "request": request, "are_creds": are_creds, + # US-005: coada tinuta manual + mediul RAR tinta pentru butonul "Trimite toate". + "held_manual_count": held_manual_count, + "env_bulk": env_bulk, "are_trimiteri": are_trimiteri, "are_cheie_folosita": are_cheie_folosita, "blocate_total": blocate_total, @@ -703,6 +735,9 @@ def _layout_header_ctx(conn, account_id: int) -> dict: "tier_label": tier_label, "sanatate_ok": sanatate_ok, "last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None), + # US-004 (5.19): starea comutatorului "Trimite automat la RAR" pentru toggle-ul + # din clusterul de header (D1) — randat in afara swap-ului periodic al barei de status. + "auto_send_enabled": get_auto_send(conn, account_id), } # US-006 (5.17): context plan pentru linia detaliata din meniul burger. ctx.update(_plan_ctx(conn, account_id)) @@ -939,6 +974,9 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa medii_disp = medii_disponibile_cont(conn, account_id) env_default = rar_env_efectiv_cont(conn, account_id) or "prod" + # US-006/US-007 (5.19): contor "In asteptare (manual)" + banner coada tinuta imbatranita. + held_manual_count, held_aged_count = _held_counts(conn, account_id) + status_ctx = { "request": request, "worker_lbl": worker_lbl, @@ -963,6 +1001,11 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa # US-011: indicator mediu RAR + toggle conditionat "env_default": env_default, "medii_disponibile": medii_disp, + # US-006/US-007 (5.19): contor manual + banner coada tinuta imbatranita. + "auto_send_enabled": get_auto_send(conn, account_id), + "held_manual_count": held_manual_count, + "held_aged_count": held_aged_count, + "held_aged_days": int(get_settings().held_warn_days), "csrf_token": get_csrf_token(request), } # US-006 (5.17): context plan pentru linia de consum/trial in _status.html. @@ -1043,13 +1086,20 @@ def _eticheta_problema(status: str, motiv: str) -> str: def _submission_row_view(r) -> dict: """Imbogateste un rand de submission cu campuri afisabile umane.""" - eticheta = eticheta_stare(r["status"]) + # US-006 (PRD 5.19 / D5): camp derivat "tinut" calculat AICI (view-model), nu in + # template. `held` e boolean pur de coada; eticheta/pill de avertizare (amber) se + # obtin din labels.py cu held=True doar cand randul e `queued AND held=1`. + held = bool(r["held"]) if ("held" in r.keys() and r["held"] is not None) else False + tinut = held and r["status"] == "queued" + eticheta = eticheta_stare(r["status"], held=tinut) motiv = motiv_uman(r["status"], r["rar_error"]) return { "id": r["id"], "status": r["status"], + # US-006: randul e tinut manual (buton "Trimite" apare DOAR pe el). + "tinut": tinut, # pill = eticheta scurta; textul lung ramane ca tooltip (title=). - "stare_scurt": eticheta_scurta(r["status"]), + "stare_scurt": eticheta_scurta(r["status"], held=tinut), "stare_text": eticheta[0], "stare_css": eticheta[2], "prez": prezentare_din_payload(r["payload_json"]), @@ -1076,6 +1126,7 @@ def fragment_submissions( vehicul: str | None = None, data_de: str | None = None, data_pana: str | None = None, + held: int | None = None, page: int = 1, ) -> HTMLResponse: """Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare. @@ -1090,7 +1141,7 @@ def fragment_submissions( vehicul_q = (vehicul or "").strip().upper() or None data_de = (data_de or "").strip() or None data_pana = (data_pana or "").strip() or None - filtru_activ = bool(status or vehicul_q or data_de or data_pana) + filtru_activ = bool(status or vehicul_q or data_de or data_pana or held == 1) filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python page = max(1, page) # pre-clamp >= 1 @@ -1103,6 +1154,10 @@ def fragment_submissions( if status: where.append("status=?") params.append(status) + # US-007 (PRD 5.19): deep-link banner "coada tinuta" -> lista filtrata pe tinute + # (queued AND held=1). Filtru derivat, fara stare noua. + if held == 1: + where.append("held=1") where_sql = " AND ".join(where) if filtru_python: @@ -1110,7 +1165,7 @@ def fragment_submissions( # FARA LIMIT — altfel paginile >8 ar disparea silentios. rows_db = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " - f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC", + f"updated_at, payload_json, rar_env, held FROM submissions WHERE {where_sql} ORDER BY id DESC", params, ).fetchall() @@ -1151,7 +1206,7 @@ def fragment_submissions( rows_db = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " - f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC " + f"updated_at, payload_json, rar_env, held FROM submissions WHERE {where_sql} ORDER BY id DESC " "LIMIT ? OFFSET ?", params + [_PAGE_SIZE, offset], ).fetchall() @@ -1204,7 +1259,7 @@ def _render_submissions(request: Request, conn, account_id: int, scope_sql, scope_params = account_scope_clause(account_id) rows = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " - f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200", + f"updated_at, payload_json, rar_env, held FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200", scope_params, ).fetchall() view = [_submission_row_view(r) for r in rows] @@ -1691,11 +1746,12 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR ) try: + # PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada. conn.execute( "UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, " "rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), " - "updated_at=datetime('now') WHERE id=?", - (new_key, payload_json, row["id"]), + "held=?, updated_at=datetime('now') WHERE id=?", + (new_key, payload_json, held_for_account(conn, account_or_default(account_id)), row["id"]), ) except sqlite3.IntegrityError: # Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key): @@ -1828,11 +1884,12 @@ async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLRes ), ) try: + # PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada. conn.execute( "UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, " "rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), " - "updated_at=datetime('now') WHERE id=? AND account_id=?", - (new_key, payload_json, row["id"], account_id), + "held=?, updated_at=datetime('now') WHERE id=? AND account_id=?", + (new_key, payload_json, held_for_account(conn, account_or_default(account_id)), row["id"], account_id), ) conn.commit() except sqlite3.IntegrityError: @@ -1927,6 +1984,168 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse: conn.close() +# =========================================================================== # +# PRD 5.19 — comutator "Trimite automat la RAR" (US-004) + trimitere manuala # +# per rand / bulk (US-005). Toate scoped pe contul sesiunii (NICIODATA din # +# formular), CSRF obligatoriu, eliberare atomica (un singur UPDATE). # +# =========================================================================== # + + +def _auto_send_toggle_ctx(request: Request, conn, account_id: int) -> dict: + """Context minimal pentru fragmentul toggle `_auto_send_toggle.html`. + + Reflecta starea persistata `accounts.auto_send_enabled` a contului din sesiune. + """ + return { + "request": request, + "auto_send_enabled": get_auto_send(conn, account_id), + "csrf_token": get_csrf_token(request), + "confirmare_release": False, + "curata_modal": False, + } + + +@router.post("/auto-send", response_class=HTMLResponse) +async def post_auto_send(request: Request) -> HTMLResponse: + """Comuta comutatorul "Trimite automat la RAR" (PRD 5.19 US-004), persistat pe cont. + + `account_id` din SESIUNE (NICIODATA din formular). Non-optimist: raspunde cu toggle-ul + re-randat din starea reala din DB (OOB outerHTML pe #auto-send-toggle-wrap). + + Garda de confirmare OFF->ON (F4/D4): daca exista N>0 randuri `queued AND held=1`, NU + comita ON pana la o confirmare tipata (modal) — intoarce toggle-ul pe starea veche + + modalul de confirmare (OOB in #modal-confirma-slot). Cu `confirma=1`: comita ON si + elibereaza randurile tinute printr-un SINGUR UPDATE atomic, scoped strict pe cont. + ON->OFF nu retrage randuri deja eliberate. + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + enabled = str(form.get("enabled") or "") not in ("", "false", "0", "off") + confirma = str(form.get("confirma") or "") not in ("", "false", "0", "off") + conn = get_connection() + try: + eliberate = 0 + if enabled: + manual_count, _aged = _held_counts(conn, account_id) + if manual_count > 0 and not confirma: + # Garda: NU comita ON; toggle ramane pe starea din DB (OFF) + modal OOB. + env_default = rar_env_efectiv_cont(conn, account_id) or "prod" + ctx = _auto_send_toggle_ctx(request, conn, account_id) + ctx.update({ + "confirmare_release": True, + "release_count": manual_count, + "release_env": env_default, + }) + return templates.TemplateResponse("_auto_send_toggle.html", ctx) + # Comita ON + eliberare atomica (single UPDATE scoped, NU loop). + set_auto_send(conn, account_id, True) + scope_sql, scope_params = account_scope_clause(account_id) + cur = conn.execute( + "UPDATE submissions SET held=0, updated_at=datetime('now') " + f"WHERE status='queued' AND held=1 AND {scope_sql}", + scope_params, + ) + eliberate = int(cur.rowcount or 0) + conn.commit() + else: + set_auto_send(conn, account_id, False) + conn.commit() + # Audit (US-009): comutarea + eventuala eliberare in bloc. + log_event( + "auto_send_schimbat", account_id=account_or_default(account_id), + mesaj=f"auto_send -> {1 if enabled else 0}", + context={"enabled": 1 if enabled else 0, "eliberate": eliberate}, conn=conn, + ) + if enabled and eliberate: + log_event( + "held_eliberat", account_id=account_or_default(account_id), + mesaj=f"eliberate {eliberate} (auto)", + context={"count": eliberate, "mod": "auto"}, conn=conn, + ) + # OOB: toggle re-randat (starea noua) + curata slotul de modal + refresh status/lista. + ctx = _auto_send_toggle_ctx(request, conn, account_id) + ctx["curata_modal"] = True + resp = templates.TemplateResponse("_auto_send_toggle.html", ctx) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + +@router.post("/trimitere/{submission_id}/trimite-acum", response_class=HTMLResponse) +async def post_trimite_acum(request: Request, submission_id: int) -> HTMLResponse: + """Trimitere manuala per rand (PRD 5.19 US-005): elibereaza un rand tinut (held 1->0). + + Scoped pe sesiune (404-before-leak pe id strain via `_fetch_submission_scoped`). + UPDATE include `AND status='queued' AND held=1` -> no-op sigur daca randul a fost deja + luat de worker (`sending`) sau eliberat intre afisaj si click (edge race). Worker-ul + preia randul la urmatorul poll (trimitere asincrona). + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + conn = get_connection() + try: + row = _fetch_submission_scoped(conn, account_id, submission_id) + if not row: + raise HTTPException(status_code=404, detail="trimitere inexistenta") + scope_sql, scope_params = account_scope_clause(account_id) + cur = conn.execute( + "UPDATE submissions SET held=0, updated_at=datetime('now') " + f"WHERE id=? AND status='queued' AND held=1 AND {scope_sql}", + [submission_id] + scope_params, + ) + conn.commit() + eliberate = int(cur.rowcount or 0) + if eliberate: + log_event( + "held_eliberat", account_id=account_or_default(account_id), + mesaj="eliberat 1 (rand)", + context={"count": eliberate, "mod": "rand", "submission_id": submission_id}, + conn=conn, + ) + resp = _render_submissions(request, conn, account_id) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + +@router.post("/trimite-toate", response_class=HTMLResponse) +async def post_trimite_toate(request: Request) -> HTMLResponse: + """Trimitere manuala in bloc (PRD 5.19 US-005): elibereaza TOATE randurile tinute ale + contului din sesiune (queued AND held=1 -> held=0) printr-un SINGUR UPDATE atomic scoped. + + NU poate elibera randurile altui cont (scope strict pe sesiune). Worker-ul preia + randurile la urmatorul poll. + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + conn = get_connection() + try: + scope_sql, scope_params = account_scope_clause(account_id) + cur = conn.execute( + "UPDATE submissions SET held=0, updated_at=datetime('now') " + f"WHERE status='queued' AND held=1 AND {scope_sql}", + scope_params, + ) + conn.commit() + eliberate = int(cur.rowcount or 0) + if eliberate: + log_event( + "held_eliberat", account_id=account_or_default(account_id), + mesaj=f"eliberate {eliberate} (bulk)", + context={"count": eliberate, "mod": "bulk"}, conn=conn, + ) + resp = _render_submissions(request, conn, account_id) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + # =========================================================================== # # US-010 (PRD 5.15): Bulk-fix — aplica un cod RAR la selectia de randuri # # blocate. Reuse form #bulk-trimiteri + validare cod din post_corectie. # @@ -1980,6 +2199,9 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: 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) + # PRD 5.19 US-002: snapshot `held` din comutatorul contului (nu depinde de rand) + # re-aplicat la re-punerea in coada — paritate cu create_prezentari/requeue_submission. + held_val = held_for_account(conn, account_or_default(account_id)) for raw in ids: try: @@ -2073,8 +2295,8 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: conn.execute( "UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, " "rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), " - "updated_at=datetime('now') WHERE id=?", - (new_key, payload_json, sid), + "held=?, updated_at=datetime('now') WHERE id=?", + (new_key, payload_json, held_val, sid), ) reusite += 1 except sqlite3.IntegrityError: diff --git a/app/web/templates/_auto_send_toggle.html b/app/web/templates/_auto_send_toggle.html new file mode 100644 index 0000000..5608035 --- /dev/null +++ b/app/web/templates/_auto_send_toggle.html @@ -0,0 +1,54 @@ +{# + _auto_send_toggle.html — comutatorul "Trimite automat la RAR" (PRD 5.19 US-004). + + Plasat in clusterul de header (base.html, langa .rar-chip) — D1. NU face parte din + swap-ul periodic al #status-bar (every 15s) -> fara flicker / pierdere focus (D3). + + Non-optimist (D2): la esec POST bifa revine + toast eroare (hx-on::response-error). + account_id vine din SESIUNE server-side (ruta /auto-send), niciodata din formular. + + Raspunsul POST re-randeaza acest fragment (outerHTML pe #auto-send-toggle-wrap): + - confirmare_release=True -> injecteaza modalul de confirmare OOB in #modal-confirma-slot; + - curata_modal=True -> goleste slotul de modal (dupa activare reusita). +#} + + + + + {% if confirmare_release|default(false) %} + {# OOB: modal de confirmare tipata pentru auto-release OFF->ON (garda F4/D4). #} + + {% set cf_id = 'modal-auto-release' %} + {% set cf_titlu = 'Activezi trimiterea automata?' %} + {% set cf_count = release_count %} + {% set cf_env = release_env %} + {% set cf_url = '/auto-send' %} + {% set cf_vals = '{"enabled":"1","confirma":"1"}' %} + {% set cf_target = '#auto-send-toggle-wrap' %} + {% set cf_swap = 'outerHTML' %} + {% set cf_confirm_label = 'Activez si trimit ' ~ release_count %} + {% set cf_deschis = true %} + {% include '_modal_confirma_trimitere.html' %} + + {% endif %} + + {% if curata_modal|default(false) %} + {# Curata slotul de modal dupa o comutare reusita (fara modal rezidual). #} + + {% endif %} + diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index 4c6d093..4acc4a7 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -10,6 +10,38 @@ lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
+ Un POST /v1/prezentari poate intoarce 200 cu
+ status: "queued", dar randul sa NU plece automat la RAR. Cele trei motive:
+
| Motiv | +Ce inseamna | +Cum deblochezi | +
|---|---|---|
| held (Auto OFF) | +Contul are Trimiterea automata OPRITA → randul e tinut
+ (held=true), NU pleaca la RAR. |
+ Elibereaza manual din dashboard (butonul Trimite / Trimite toate)
+ sau activeaza Trimiterea automata. Verifici cu
+ GET /v1/prezentari/{id} (held=true). |
+
| needs_mapping | +Cod operatie fara mapare la un cod RAR din nomenclator. | +Intra in editorul de mapare din dashboard; la salvarea maparii randul + se re-rezolva automat. | +
| needs_data | +Validare de continut esuata (VIN / data prestatie / odometru). | +Corectezi datele si repui prezentarea. | +