diff --git a/app/accounts.py b/app/accounts.py index a12b68e..e52a9cc 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -15,6 +15,7 @@ inca fluxul de trimitere. (Addendum A2.) from __future__ import annotations import sqlite3 +from datetime import datetime, timedelta, timezone def _norm_cui(cui: str | None) -> str | None: @@ -57,10 +58,16 @@ def create_account( cui = _norm_cui(cui) email = _norm_email(email) try: + # Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z. + trial_until = ( + (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") + ) # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'. cur = conn.execute( - "INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)", - (name, cui, email, 1 if active else 0, "active" if active else "pending"), + "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (name, cui, email, 1 if active else 0, "active" if active else "pending", + "free", trial_until), ) except sqlite3.IntegrityError: existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() @@ -107,6 +114,8 @@ def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None: # Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de # retentie); restul sunt reversibile. VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted") +# Tieruri de cont valide (5.17). Sursa de adevar: app/plans.py#PLANS (nu duplica valorile). +VALID_TIERS = ("free", "standard", "pro", "premium") # Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b). _PROTECTED_ACCOUNT_ID = 1 @@ -131,6 +140,51 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None: ) +def set_tier( + conn: sqlite3.Connection, + account_id: int, + tier: str, + trial_until: str | None = None, +) -> None: + """Seteaza planul unui cont (tier + trial_until). + + tier invalid -> ValueError cu mesaj clar. + Contul de sistem id=1 e protejat (ca set_status). + Cont inexistent -> ValueError. + Logheaza schimbarea in app_events (reuse observ.log_event, fara PII nou). + + trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul). + """ + if tier not in VALID_TIERS: + raise ValueError( + f"tier invalid: {tier!r} (valid: {', '.join(VALID_TIERS)})" + ) + row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() + if not row: + raise ValueError(f"cont inexistent: {account_id}") + if account_id == _PROTECTED_ACCOUNT_ID: + raise ValueError( + "Contul default (id=1) nu poate fi mutat pe alt plan via CLI " + "(cont de sistem, tratat coerent)." + ) + conn.execute( + "UPDATE accounts SET tier=?, trial_until=? WHERE id=?", + (tier, trial_until, account_id), + ) + # Audit in app_events (decizie PRD 5.17 US-008, fara PII nou) + try: + from .observ import log_event + log_event( + "plan_schimbare_tier", + account_id=account_id, + mesaj=f"tier -> {tier}", + context={"tier": tier, "trial_until": trial_until}, + conn=conn, + ) + except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event) + pass + + def delete_account(conn: sqlite3.Connection, account_id: int) -> None: """Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API @@ -154,7 +208,7 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]: """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted' (stergere soft -> invizibile in panou).""" rows = conn.execute( - "SELECT id, name, cui, email, active, status, created_at FROM accounts " + "SELECT id, name, cui, email, active, status, tier, trial_until, created_at FROM accounts " "WHERE status != 'deleted' ORDER BY id" ).fetchall() return [dict(r) for r in rows] diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index b140339..b55a810 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -29,8 +29,10 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +from datetime import datetime, timezone + from ... import errors -from ...auth import resolve_account_id +from ...auth import require_api_access, resolve_account_id from ...crypto import decrypt_creds, encrypt_creds from ...db import get_connection from ...idempotency import build_key, canonicalize_row @@ -413,7 +415,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di async def upload_import( file: UploadFile, sheet_name: str | None = None, - account_id: int = Depends(resolve_account_id), + account_id: int = Depends(require_api_access), ) -> dict: """Upload fisier xlsx/csv -> staging in import_batches/import_rows. @@ -934,7 +936,7 @@ class CommitIn(BaseModel): def commit_import( import_id: int, req: CommitIn, - account_id: int = Depends(resolve_account_id), + account_id: int = Depends(require_api_access), ) -> dict: """Gate HARD confirmare + enqueue randuri ok + log atestare. @@ -1022,6 +1024,48 @@ def commit_import( if n_total_ok == 0: raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.") + # T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta). + # Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). + from ...config import get_settings as _get_settings + from ...plans import PLANS, effective_tier, monthly_usage + from ...observ import log_event as _log_event_plan + _settings = _get_settings() + if _settings.enforce_plans: + _acct_row = conn.execute( + "SELECT tier, trial_until FROM accounts WHERE id=?", (acct,) + ).fetchone() + _now = datetime.now(timezone.utc) + _et = effective_tier(_acct_row, _now) + _plan_limit = PLANS[_et].get("monthly_limit") + if _plan_limit is not None: + _usage = monthly_usage(conn, acct, _now) + if _usage + n_total_ok > _plan_limit: + _remaining = max(0, _plan_limit - _usage) + _log_event_plan( + "plan_limita_lunara_atinsa", + account_id=acct, + nivel="WARNING", + mesaj=f"Import de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})", + context={ + "n_to_enqueue": n_total_ok, "usage": _usage, + "plan_limit": _plan_limit, "tier": _et, + }, + conn=conn, + ) + raise HTTPException( + status_code=422, + detail={ + "error": "plan_limita_lunara", + **errors.eroare( + "PLAN_LIMITA_LUNARA", + cauza=( + f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;" + f" mai poti trimite {_remaining}." + ), + ), + }, + ) + # Incarca maparea de coloane pentru a construi payload-ul first_row_db = conn.execute( "SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1", diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 5474a9d..a2f722b 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -13,11 +13,13 @@ import csv import io import json +from datetime import datetime, timezone + from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from ...auth import resolve_account_id +from ...auth import require_api_access, resolve_account_id from ...crypto import encrypt_creds from ...db import get_connection from ...errors import eroare as err_eroare @@ -135,7 +137,7 @@ def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult: @router.post("/prezentari", response_model=PrezentariResponse) def create_prezentari( req: PrezentareRequest, - account_id: int = Depends(resolve_account_id), + account_id: int = Depends(require_api_access), ) -> PrezentariResponse: """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. @@ -165,6 +167,46 @@ def create_prezentari( # Reguli text incarcate o data per cerere (seam partajat cu dry-run). text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) + + # T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta). + # Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). + from ...config import get_settings as _get_settings + from ...plans import PLANS, effective_tier, monthly_usage + _settings = _get_settings() + if _settings.enforce_plans: + _acct_row = conn.execute( + "SELECT tier, trial_until FROM accounts WHERE id=?", (acct,) + ).fetchone() + _now = datetime.now(timezone.utc) + _et = effective_tier(_acct_row, _now) + _plan_limit = PLANS[_et].get("monthly_limit") + if _plan_limit is not None: + _usage = monthly_usage(conn, acct, _now) + _nr_cerut = len(req.prezentari) + if _usage + _nr_cerut > _plan_limit: + _remaining = max(0, _plan_limit - _usage) + log_event( + "plan_limita_lunara_atinsa", + account_id=acct, + nivel="WARNING", + mesaj=f"Lot de {_nr_cerut} respins (usage={_usage}, limita={_plan_limit})", + context={ + "nr_cerut": _nr_cerut, "usage": _usage, + "plan_limit": _plan_limit, "tier": _et, + }, + conn=conn, + ) + raise HTTPException( + status_code=422, + detail=err_eroare( + "PLAN_LIMITA_LUNARA", + cauza=( + f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;" + f" mai poti trimite {_remaining}." + ), + ), + ) + for prez in req.prezentari: content = prez.model_dump() # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). diff --git a/app/auth.py b/app/auth.py index 436e632..d8767a8 100644 --- a/app/auth.py +++ b/app/auth.py @@ -18,8 +18,9 @@ from __future__ import annotations import hashlib import secrets import sqlite3 +from datetime import datetime, timezone -from fastapi import Header, HTTPException, Request +from fastapi import Depends, Header, HTTPException, Request from .config import get_settings from .db import get_connection @@ -162,3 +163,59 @@ def resolve_account_id( _log_auth_esuat(request, plaintext, "cheie API invalida sau revocata") raise HTTPException(status_code=401, detail="cheie API invalida sau revocata") return account_id + + +def require_api_access( + account_id: int = Depends(resolve_account_id), +) -> int: + """Dependency FastAPI (T4, PRD 5.17): verifica ca tier-ul efectiv permite accesul la API. + + Reguli: + - enforce_plans=False (kill-switch): sare verificarea. + - dev id=1 cu require_api_key=False: bypass (dogfooding, testele existente nu pica). + - Pro/Premium sau trial Pro activ: permit. + - Free/Standard fara trial: 403 PLAN_FARA_API cu eroare 3 niveluri. + + Refoloseste resolve_account_id (account_id deja rezolvat din cheie API). + Se ataseaza ca Depends() pe rutele de ingestie API (POST /v1/prezentari, + POST /v1/import, POST /v1/import/{id}/commit). valideaza + nomenclator raman libere. + """ + from .plans import PLANS, effective_tier + from .errors import eroare as _eroare + + settings = get_settings() + # Kill-switch operare: sare toate gate-urile de plan. + if not settings.enforce_plans: + return account_id + # Bypass pentru contul implicit dev (id=1) in modul fara cheie API obligatorie. + # In prod (require_api_key=True), id=1 nu are bypass implicit (cheie = obligatorie). + if not settings.require_api_key and account_id == DEFAULT_ACCOUNT_ID: + return account_id + + conn = get_connection() + try: + row = conn.execute( + "SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,) + ).fetchone() + finally: + conn.close() + + now = datetime.now(timezone.utc) + et = effective_tier(row, now) + if not PLANS[et].get("api_access"): + from .observ import log_event + log_event( + "plan_api_refuzat", + account_id=account_id, + nivel="WARNING", + mesaj=f"Acces API refuzat: tier efectiv={et}", + context={"tier_efectiv": et}, + ) + raise HTTPException( + status_code=403, + detail=_eroare( + "PLAN_FARA_API", + cauza=f"Tier efectiv: {et}. API disponibil pe Pro/Premium.", + ), + ) + return account_id diff --git a/app/config.py b/app/config.py index 812ac11..05b1527 100644 --- a/app/config.py +++ b/app/config.py @@ -104,6 +104,13 @@ class Settings(BaseSettings): worker_retry_max_s: int = 300 worker_max_retries: int = 8 # peste atat -> error + banner + # --- Planuri de cont (PRD 5.17) --- + # Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ. + # False = kill-switch de operare: sare toate gate-urile de plan (util pentru debugging + # sau rollback rapid fara revert de cod). Enforcement DUR e activ implicit de la deploy + # (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE. + enforce_plans: bool = True + # --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) --- # DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX # (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari. diff --git a/app/db.py b/app/db.py index 52e6336..de4c11e 100644 --- a/app/db.py +++ b/app/db.py @@ -84,6 +84,15 @@ def _migrate(conn: sqlite3.Connection) -> None: 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") + if "tier" not in acc_cols: + # Plan de cont (US-001, PRD 5.17). Legacy -> 'free' fara trial (enforcement DUR la deploy). + conn.execute( + "ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' " + "CHECK (tier IN ('free','standard','pro','premium'))" + ) + if "trial_until" not in acc_cols: + # Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial). + conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT") # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" diff --git a/app/errors.py b/app/errors.py index f74b15a..a58d251 100644 --- a/app/errors.py +++ b/app/errors.py @@ -178,6 +178,22 @@ CATALOG: dict[str, dict[str, str]] = { " cererii (request_id) afisat." ), }, + # Coduri de plan (PRD 5.17) + "PLAN_LIMITA_LUNARA": { + "problema": "Ai atins limita planului Gratuit (60 prestatii/luna)", + "fix": ( + "Treci pe planul Standard sau Pro, sau asteapta inceperea lunii urmatoare." + " Numarul de prestatii ramase in luna curenta e in campul cauza." + ), + }, + "PLAN_FARA_API": { + "problema": "Importul prin API e disponibil pe planul Pro", + "fix": ( + "Planul tau curent nu include accesul la API." + " Endpoint-ul /v1/prezentari/valideaza ramane disponibil pentru testare fara upgrade." + " Contacteaza-ne pentru a face upgrade la planul Pro." + ), + }, } diff --git a/app/plans.py b/app/plans.py new file mode 100644 index 0000000..caaf7ff --- /dev/null +++ b/app/plans.py @@ -0,0 +1,130 @@ +"""Definitia planurilor de cont (sursa unica de adevar). Modul PUR, fara import DB/HTTP. + +Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard. +Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare. + +Decizii implementare (PRD 5.17 / autoplan 2026-06-28): + - FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod. + - CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada. + - effective_tier: `now` injectabil (decizie #2) pentru teste deterministe. + - monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone + +# Limita lunara pentru planul Gratuit. +# Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS. +# Tunabila fara a modifica logica de enforcement. +FREE_MONTHLY_LIMIT: int = 60 + +# Statusurile care consuma din cota lunara (decizie #20, 2026-06-28). +# Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate. +# Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate. +CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent") + +# Sursa unica de adevar pentru planuri. Fiecare plan are: +# label -- eticheta afisata in RO (UI, mesaje) +# monthly_limit -- None = nelimitat; int = limita prestatii/luna +# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard +# +# Aliniat landing-ului comercial (PRD 5.17 US-001): +# Gratuit: 60/luna, fara API +# Standard: nelimitat, fara API +# Pro: nelimitat, cu API +# Premium: nelimitat, cu API (suport dedicat) +PLANS: dict[str, dict] = { + "free": { + "label": "Gratuit", + "monthly_limit": FREE_MONTHLY_LIMIT, + "api_access": False, + }, + "standard": { + "label": "Standard", + "monthly_limit": None, + "api_access": False, + }, + "pro": { + "label": "Pro", + "monthly_limit": None, + "api_access": True, + }, + "premium": { + "label": "Premium", + "monthly_limit": None, + "api_access": True, + }, +} + + +def effective_tier(account_row, now: datetime) -> str: + """Returneaza tier-ul efectiv al contului la momentul `now` (injectabil pentru determinism). + + Daca `trial_until` e in viitor -> 'pro' (trial Pro activ). + Altfel -> `tier`-ul de baza al contului. + trial_until malformat/NULL -> fallback defensiv la tier de baza (nu arunca niciodata). + + `now` TREBUIE injectat explicit (nu datetime.now() intern) — decizie #2 din autoplan. + Suporta sqlite3.Row si dict. + """ + # Citire robusta: suporta sqlite3.Row (IndexError pe key absent) si dict (KeyError) + try: + tier = account_row["tier"] + except (KeyError, IndexError, TypeError): + tier = "free" + try: + trial_until_str = account_row["trial_until"] + except (KeyError, IndexError, TypeError): + trial_until_str = None + + # Fallback defensiv la 'free' daca tier e None/gol + if not tier: + tier = "free" + + if not trial_until_str: + return tier + + try: + # Parseaza trial_until; stocam ca "YYYY-MM-DD HH:MM:SS" (UTC implicit) sau ISO + tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T")) + # Daca fara timezone -> assume UTC (cum stocam in DB) + if tu.tzinfo is None: + tu = tu.replace(tzinfo=timezone.utc) + # Normalizeaza `now` la aware daca e naive + now_cmp = now + if now_cmp.tzinfo is None: + now_cmp = now_cmp.replace(tzinfo=timezone.utc) + if tu > now_cmp: + return "pro" + except (ValueError, AttributeError, TypeError): + pass # malformat -> fallback defensiv la tier de baza + + return tier + + +def monthly_usage(conn: sqlite3.Connection, account_id: int, now: datetime) -> int: + """Numara prestatiile contului acceptate in coada in luna calendaristica curenta. + + Definitia 'luna curenta': strftime('%Y-%m', created_at, 'localtime') corespunde + lunii lui `now` (acelasi pattern ca E7/5.15 din routes.py — consistent cu 'localtime'). + `now` injectabil pentru teste deterministe. Scoped strict pe account_id. + created_at NULL/malformat -> exclus defensiv (nu arunca niciodata). + + NOTA: containerul are /etc/localtime=UTC, deci 'localtime' = UTC in mediul de test. + Testele de granita construiesc timestamp-uri relative la luna curenta calculata cu + acelasi 'localtime', nu valori absolute care presupun +2/+3h. + """ + # Formatam `now` ca string SQLite si folosim acelasi modificator 'localtime' ca routes.py + now_str = now.strftime("%Y-%m-%d %H:%M:%S") + placeholders = ",".join("?" * len(CONSUMED_STATUSES)) + row = conn.execute( + f"SELECT COUNT(*) AS n FROM submissions " + f"WHERE account_id = ? " + f" AND status IN ({placeholders}) " + f" AND created_at IS NOT NULL " + f" AND strftime('%Y-%m', created_at, 'localtime') = strftime('%Y-%m', ?, 'localtime')", + (account_id, *CONSUMED_STATUSES, now_str), + ).fetchone() + return int(row["n"]) if row else 0 diff --git a/app/schema.sql b/app/schema.sql index 94aa7de..94b68b7 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -25,6 +25,13 @@ 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)), + -- 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. + -- Contul implicit id=1 (dev) primeste DEFAULT 'free' + trial_until=NULL (fara trial). + tier TEXT NOT NULL DEFAULT 'free' + CHECK (tier IN ('free','standard','pro','premium')), + trial_until TEXT, -- ISO datetime UTC sau NULL; nullable created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi diff --git a/app/web/routes.py b/app/web/routes.py index 8120117..ef74397 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -26,6 +26,7 @@ from fastapi.templating import Jinja2Templates from .. import __version__ from .. import errors as _errors from ..auth import rotate_api_key +from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS from ..payload_view import prezentare_din_payload from ..web.csrf import get_csrf_token, verify_csrf from .labels import ( @@ -374,7 +375,7 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str: ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) account_meta = _fetch_account_meta(conn, acct) - return templates.get_template("_cont.html").render({ + cont_ctx = { "request": request, "csrf_token": get_csrf_token(request), "api_key": None, @@ -385,7 +386,10 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str: "account_meta": account_meta, "date_firma_mesaj": None, "date_firma_eroare": None, - }) + } + # US-006 (5.17): context plan pentru sectiunea Plan din _cont.html. + cont_ctx.update(_plan_ctx(conn, account_id)) + return templates.get_template("_cont.html").render(cont_ctx) def _render_panel_nomenclator(request: Request, conn) -> str: @@ -531,6 +535,139 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta return _render_panel_acasa(request) +# Etichete tier pentru badge in antet (US-010 PRD 5.16). +_TIER_LABELS: dict[str, str] = { + "free": "Gratuit", + "standard": "Standard", + "pro": "Pro", + "premium": "Premium", +} + + +def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict: + """Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger. + + Returneaza: + plan_linie — linie completa cu copy RO (cele 6 stari) + plan_warn — True la >=80% consum sau limita atinsa (culoare + text) + plan_limita_atinsa — True la 100% consum (--err in loc de --warn) + trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time) + usage_lunar — numar prestatii acceptate in coada luna curenta + monthly_limit_val — limita lunara (60 pt free, None pt nelimitat) + effective_tier_name — tier-ul efectiv ('free','standard','pro','premium') + """ + if now is None: + now = datetime.now(timezone.utc) + + acct = account_or_default(account_id) + row = conn.execute( + "SELECT tier, trial_until FROM accounts WHERE id=?", (acct,) + ).fetchone() + + tier_base = (row["tier"] if row else None) or "free" + trial_until_str = (row["trial_until"] if row else None) + + eff = _eff_tier(row, now) if row else "free" + monthly_limit = _PLANS.get(eff, _PLANS["free"]).get("monthly_limit") + + usage = _monthly_usage(conn, acct, now) + + # Calcul zile ramase din trial activ + trial_ultima_zi = False + trial_days: int | None = None + if trial_until_str and eff == "pro" and tier_base == "free": + try: + tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T")) + if tu.tzinfo is None: + tu = tu.replace(tzinfo=timezone.utc) + now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc) + delta = tu - now_cmp + trial_days = delta.days # 0 = < 1 zi ramasa (azi), 1 = < 2 zile, etc. + trial_ultima_zi = (trial_days <= 0) + except (ValueError, AttributeError, TypeError): + pass + + # Construieste plan_linie si stari aferente (cele 6 stari din PRD) + warn_aproape = False + plan_limita_atinsa = False + trial_expirat_recent = False + + if eff == "pro" and tier_base == "free" and trial_until_str: + # Trial Pro activ + if trial_ultima_zi: + plan_linie = "Plan: Pro · trial expira azi" + else: + n = trial_days or 0 + z = "zi" if n == 1 else "zile" + plan_linie = f"Plan: Pro · trial {n} {z} ramase" + elif eff == "free": + # Free — cu sau fara trial expirat recent + if trial_until_str: + trial_expirat_recent = True + if monthly_limit is not None: + if usage >= monthly_limit: + plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — limita atinsa" + warn_aproape = True + plan_limita_atinsa = True + elif monthly_limit > 0 and usage >= int(monthly_limit * 0.8): + plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — aproape de limita" + warn_aproape = True + else: + plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} luna asta" + else: + plan_linie = "Plan: Gratuit" + else: + # Platit (tier de baza != free, ex. standard/pro/premium alocat de admin) + label = _PLANS.get(eff, {}).get("label", eff.capitalize()) + plan_linie = f"Plan: {label}" + + return { + "plan_linie": plan_linie, + "plan_warn": warn_aproape, + "plan_limita_atinsa": plan_limita_atinsa, + "trial_expirat_recent": trial_expirat_recent, + "usage_lunar": usage, + "monthly_limit_val": monthly_limit, + "effective_tier_name": eff, + } + + +def _layout_header_ctx(conn, account_id: int) -> dict: + """Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16). + + Citeste account_name, tier si starea de sanatate RAR pentru a popula: + - account_name: numele service-ului, afisat sub titlu cand logat + - tier_label: eticheta planului (Gratuit/Standard/Pro/Premium) + - sanatate_ok: True daca worker viu si RAR ok (dot verde in antet) + - last_login: data/ora ultimei autentificari RAR (format romanesc) + - plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger + + Apelat aditiv din dashboard() fara a atinge alti handlere. + """ + row = conn.execute( + "SELECT name, tier FROM accounts WHERE id=?", (account_id,) + ).fetchone() + account_name = (row["name"] if row else None) or "" + tier = (row["tier"] if row else "free") or "free" + tier_label = _TIER_LABELS.get(tier, "Gratuit") + + hb = read_heartbeat(conn) + worker_alive = _worker_alive(hb) + rar_state = _rar_state(hb, worker_alive) + rar_ok = rar_state == "ok" + sanatate_ok = worker_alive and rar_ok + + ctx = { + "account_name": account_name, + "tier_label": tier_label, + "sanatate_ok": sanatate_ok, + "last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None), + } + # US-006 (5.17): context plan pentru linia detaliata din meniul burger. + ctx.update(_plan_ctx(conn, account_id)) + return ctx + + @router.get("/", response_class=HTMLResponse) def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: """Dashboard principal cu tab-uri. @@ -578,6 +715,9 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) - "is_admin": is_account_admin(conn, account_id), "csrf_token": get_csrf_token(request), } + # US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR). + # Adaugat aditiv, fara a atinge handlerele altora. + ctx.update(_layout_header_ctx(conn, account_id)) return templates.TemplateResponse("dashboard.html", ctx) finally: conn.close() @@ -749,7 +889,7 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa else: sanatate_text = "Declaratiile curg normal" - return { + status_ctx = { "request": request, "worker_lbl": worker_lbl, "rar_lbl": rar_lbl, @@ -771,6 +911,9 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa "mapari_badge": counts.get("needs_mapping", 0), "oob": oob, } + # US-006 (5.17): context plan pentru linia de consum/trial in _status.html. + status_ctx.update(_plan_ctx(conn, account_id)) + return status_ctx @router.get("/_fragments/status", response_class=HTMLResponse) @@ -1363,6 +1506,18 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR c.strip().upper() if isinstance(c, str) else "" for c in codes_raw ] + # US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. + # Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit. + # Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional. + # Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899). + _flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper() + if _flat_picker and _flat_picker not in codes_positional: + codes_positional.append(_flat_picker) + for _i in range(len(codes_positional)): + if not codes_positional[_i]: + _op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper() + if _op_val: + codes_positional[_i] = _op_val # Verifica daca cel putin un cod non-gol a fost trimis codes_nonempty = [c for c in codes_positional if c] if codes_nonempty: @@ -1923,6 +2078,7 @@ async def post_form_chips(request: Request) -> HTMLResponse: }) action = str(form.get("chips_action") or "").strip() + chips_extra_error = False # T-C1/T-E4 (5.16): semnal pentru add_extra esuat conn = get_connection() try: @@ -1950,6 +2106,28 @@ async def post_form_chips(request: Request) -> HTMLResponse: if exists: chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""}) + elif action == "add_extra": + # US-005 (5.16): Adauga cod RAR liber (extra, fara op_service) in modul operatii. + # Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item E4 pastrat). + # T-C1/T-E4: select gol sau cod invalid → chips_extra_error = True (semnal vizibil). + add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper() + if add_cod_extra: + exists = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_extra,) + ).fetchone() + if exists: + # Dedup per-item (E4): nu adauga un chip extra identic deja existent + existing_pairs = { + (c.get("cod_op_service", ""), c.get("cod_prestatie", "")) + for c in chips + } + if ("", add_cod_extra) not in existing_pairs: + chips.append({"cod_prestatie": add_cod_extra, "cod_op_service": "", "denumire": ""}) + else: + chips_extra_error = True # cod necunoscut in nomenclator + else: + chips_extra_error = True # select gol + elif action == "remove": # Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata) try: @@ -1983,6 +2161,7 @@ async def post_form_chips(request: Request) -> HTMLResponse: "has_r_odo": has_r_odo, "form_chips_url": "/form-chips", "chips_section_id": "chips-section", + "chips_extra_error": chips_extra_error, # T-C1/T-E4 (5.16) }) @@ -3525,6 +3704,46 @@ async def web_confirma_import( n_total_ok = len(to_enqueue) + # T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta). + # Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). Canal web. + from ..config import get_settings as _get_settings_plan + from ..plans import PLANS as _PLANS, effective_tier as _effective_tier, monthly_usage as _monthly_usage + _plan_settings = _get_settings_plan() + if _plan_settings.enforce_plans and n_total_ok > 0: + from datetime import datetime, timezone as _tz + _acct_row = conn.execute( + "SELECT tier, trial_until FROM accounts WHERE id=?", (acct,) + ).fetchone() + _now_plan = datetime.now(_tz.utc) + _et = _effective_tier(_acct_row, _now_plan) + _plan_limit = _PLANS[_et].get("monthly_limit") + if _plan_limit is not None: + _usage = _monthly_usage(conn, acct, _now_plan) + if _usage + n_total_ok > _plan_limit: + _remaining = max(0, _plan_limit - _usage) + log_event( + "plan_limita_lunara_atinsa", + account_id=acct, + nivel="WARNING", + mesaj=f"Import web de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})", + context={ + "n_to_enqueue": n_total_ok, "usage": _usage, + "plan_limit": _plan_limit, "tier": _et, + }, + conn=conn, + ) + _err_msg = ( + f"Ai atins limita planului Gratuit: {_usage}/{_plan_limit} prezentari luna aceasta." + f" Mai poti trimite {_remaining} luna aceasta." + f" Treci pe Standard sau Pro, sau asteapta luna viitoare." + ) + _prev_result = _web_compute_preview(conn, import_id, account_id) + if isinstance(_prev_result, str): + return templates.TemplateResponse("_upload.html", _ctx(request, error=_err_msg)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, import_id=import_id, message=_err_msg, error=True, **_prev_result + )) + # Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis if n_confirmat != n_total_ok: result = _web_compute_preview(conn, import_id, account_id) diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index 5220a32..82000d9 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -21,7 +21,7 @@ In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul intern) →
ramane neatins → containerul ramane deschis intre pasi. === #}
- Importa un fisier + + Importa fisier (XLSX / CSV) {% include '_upload.html' %}
diff --git a/app/web/templates/_chips_prestatii.html b/app/web/templates/_chips_prestatii.html index cc6f81a..5830447 100644 --- a/app/web/templates/_chips_prestatii.html +++ b/app/web/templates/_chips_prestatii.html @@ -117,6 +117,60 @@ {% endif %} {% endfor %} + {# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #} + {# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat) #} +
+ {% for chip in _chips %} + {% if not chip.cod_op_service and chip.cod_prestatie %} + {% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} + + {{ chip.cod_prestatie }} + + + {% endif %} + {% endfor %} +
+ {% if nomenclator_rar %} + + + + + {% else %} + {# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #} +
+ Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila. +
+ {% endif %} + {% if chips_extra_error %} + {# T-C1/T-E4 (5.16): semnal vizibil cand add_extra are select gol sau cod invalid #} + + {% endif %} + {% else %} {# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
@@ -144,7 +198,7 @@ style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);"> {% for n in nomenclator_rar %} - + {% endfor %}
{% endif %} diff --git a/app/web/templates/_cont.html b/app/web/templates/_cont.html index e3a8181..0623940 100644 --- a/app/web/templates/_cont.html +++ b/app/web/templates/_cont.html @@ -1,6 +1,38 @@

Contul meu

+ + {% if plan_linie is defined %} +
+

Plan curent

+ +
+ {{ plan_linie }} +
+ + {% if monthly_limit_val is defined and monthly_limit_val is not none and effective_tier_name|default('') == 'free' %} +
+ Planul Gratuit include {{ monthly_limit_val }} prestatii/luna prin dashboard-ul web. + {% if plan_limita_atinsa|default(false) %} + Limita lunara a fost atinsa — trimiterile noi sunt blocate pana la inceputul lunii urmatoare. + {% elif plan_warn|default(false) %} + Te apropii de limita lunara. + {% endif %} +
+ {% endif %} + +
+ Vrei sa treci pe Standard, Pro sau Premium? + Contacteaza-ne pentru alocare manuala — nu exista inca plata self-service. + Pro adauga import prin API; Standard si + Premium ridica limita de volum. +
+
+ {% endif %} +

Date firma

diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html index 940bf92..3344ac5 100644 --- a/app/web/templates/_mapcoloane.html +++ b/app/web/templates/_mapcoloane.html @@ -4,7 +4,7 @@ {# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #} {%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
-

+

Mapare coloane — {{ filename or ("import #" ~ import_id) }}

@@ -20,19 +20,19 @@
{% endif %} -

+

Asociaza fiecare coloana din fisier cu campul canonic corespunzator. Maparea se retine automat pentru fisiere cu acelasi antet.

- {# Tabel orizontal preview: antet + prima inregistrare (US-003) #} + {# Tabel orizontal preview: antet + prima inregistrare (compatibilitate teste) #}
- +
{% for col in columns %} {% endfor %} @@ -44,7 +44,7 @@ {% for col in columns %} {%- set val = prima_inreg.get(col, '') | string -%} @@ -53,7 +53,7 @@ {% else %} @@ -69,7 +69,7 @@
-
- {% for col in columns %} - {%- set sugg = fuzzy_suggestions.get(col, []) -%} - {%- set best = sugg[0].camp_canonic if sugg else '' -%} - -
-
-
{{ col }}
- {% if sugg %} -
- sugestie: {{ sugg[0].camp_canonic }} - ({{ sugg[0].score | round | int }}%) -
- {% endif %} - {%- set ns = namespace(samples=[]) -%} - {%- for row in sample_rows -%} - {%- if row.get(col) is not none and row.get(col) != '' -%} - {%- set ns.samples = ns.samples + [row[col] | string] -%} - {%- endif -%} - {%- endfor -%} - {% if ns.samples %} -
- ex: {{ ns.samples[:2] | join(", ") }} -
- {% endif %} -
-
- -
+ white-space:nowrap; font-weight:600; font-size:var(--fs-xs); color:var(--ink);"> {{ col }} {{ val[:40] }}{% if val | length > 40 %}…{% endif %}
Antet fara randuri de date
+ + + + + + + + + {% for col in columns %} + {%- set sugg = fuzzy_suggestions.get(col, []) -%} + {%- set best = sugg[0].camp_canonic if sugg else '' -%} + {%- set ns = namespace(samples=[]) -%} + {%- for row in sample_rows -%} + {%- if row.get(col) is not none and row.get(col) != '' -%} + {%- set ns.samples = ns.samples + [row[col] | string] -%} + {%- endif -%} + {%- endfor -%} + + + + + {% endfor %} - - + +
+ Coloana din fisier + + Exemplu + + Camp RAR +
+ + {{ col }} + {% if sugg %} +
+ sugestie: {{ sugg[0].camp_canonic }} + ({{ sugg[0].score | round | int }}%) +
+ {% endif %} +
+ {% if ns.samples %} + + {{ ns.samples[:2] | join(", ") }} + + {% else %} + + {% endif %} + + + +
- {% endfor %}
{% if not prima_inreg %} - + Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare. {% else %} - + maparea se retine pentru fisiere cu acelasi antet {% endif %} @@ -144,7 +175,7 @@
diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index 3c8383e..1ca37dc 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -10,11 +10,11 @@ {% set pas = 3 %}{% include '_stepper.html' %}
-

+

Preview — {{ filename or ("import #" ~ import_id) }}

- {{ total }} randuri + {{ total }} randuri
{% if message %} @@ -37,7 +37,8 @@ {% for status_key, label in status_labels %} {%- set cnt = summary.get(status_key, 0) -%} {% if cnt > 0 %} - {{ cnt }} {{ label | lower }} + + {{ cnt }} {{ label | lower }} {% endif %} {% endfor %}
@@ -46,14 +47,14 @@
{% for status_key, label in status_labels %} {%- set cnt = summary.get(status_key, 0) -%} {% if cnt > 0 %} @@ -66,7 +67,7 @@ {% if unmapped_ops %}

Operatii de mapat la cod RAR

-

+

Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e preselectata) si salveaza — randurile blocate trec automat in ok si maparea se retine pentru fisierele viitoare. @@ -167,7 +168,7 @@

@@ -190,7 +191,7 @@
- + din {{ summary.get('ok', 0) }} gata de trimis
@@ -207,13 +208,13 @@ din {{ summary.get('ok', 0) }} gata de trimis
{% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %} + style="font-size:var(--fs-xs); text-align:center;"> descarca randuri cu probleme (CSV) {% endif %} @@ -226,7 +227,7 @@ din {{ summary.get('ok', 0) }} gata de trimis
- Incarca alt fisier
diff --git a/app/web/templates/_preview_rand.html b/app/web/templates/_preview_rand.html index 81e215c..ad1ff3d 100644 --- a/app/web/templates/_preview_rand.html +++ b/app/web/templates/_preview_rand.html @@ -25,7 +25,8 @@ style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}"> {{ row.row_index + 1 }} - {{ row.stare_eticheta }} + + {{ row.stare_eticheta }} {{ row.prez.vehicul_nr }} @@ -44,7 +45,7 @@ {{ row.prez.data_prestatie }} {{ row.prez.odometru }} + style="font-size:var(--fs-xs); white-space:normal;"> {% if status == 'already_sent' and row.get('already_sent_info') %} {% set ai = row.already_sent_info %} deja trimis {{ (ai.get('created_at') or '')[:10] }} @@ -78,7 +79,7 @@ style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> {% for status_key, label in status_labels %} {%- set cnt = summary.get(status_key, 0) -%} - {% if cnt > 0 %}{{ cnt }} {{ label | lower }}{% endif %} + {% if cnt > 0 %}{{ cnt }} {{ label | lower }}{% endif %} {% endfor %}
diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html index 9d0377a..a7f264f 100644 --- a/app/web/templates/_status.html +++ b/app/web/templates/_status.html @@ -14,55 +14,84 @@
{% endif %} - {# === D6: Strip sanatate mereu-vizibil DEASUPRA contoarelor === - Verde: worker viu + RAR ok → "Declaratiile curg normal" - Rosu: worker oprit SAU RAR inaccesibil → "Blocat: ... — declaratiile NU pleaca" - Glife accesibile ✓/✗ (nu doar culoare). Layout: glifa+text stanga, ultima auth dreapta. + {# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare + dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage. + Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #} + {% if trial_expirat_recent|default(false) %} + + + {% endif %} + + {# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%). + OK = dot verde in antet (base.html); banda nu mai apare cand totul e ok. + Elementul id="strip-sanatate" ramane in DOM mereu, dar goleste continutul cand OK, + astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului. #} + {% if sanatate_ok %} + + {% else %}
+ background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);">
- {% if sanatate_ok %} - - {% else %} - {% endif %} {{ sanatate_text }}
- + {{ eticheta_ultima_auth }}: {{ last_login }}
+ {% endif %} - {# === D4: 3 carduri-contor (mockup exact: Trimise / In coada / De corectat) === - Responsive: flex-wrap => 3 pe rand desktop, 2/stivuite pe mobil (min-width:120px). - Trimise: all-time (cifra mare) + sub-linie "luna N · azi N" (D4 + E7). - De corectat: rosu cand >0 (s-error), muted cand 0. + {# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px). + Total / Luna asta / Azi / In coada / De corectat. #} -
+ {# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in + base.html), NU inline, ca media query-ul <=560px sa le poata ascunde pe mobil (bara compacta). #} +
- {# Trimise (all-time principal, luna/azi secundar) #} -
+ {# Total trimise (all-time) #} +
{{ counts_sent }}
-
Trimise (total)
-
luna {{ sent_month }} · azi {{ sent_today }}
+
Total
- {# In coada (accent/albastru) #} -
+ {# Luna asta #} +
+
{{ sent_month }}
+
Luna asta
+
+ + {# Azi #} +
+
{{ sent_today }}
+
Azi
+
+ + {# In coada #} +
{{ counts_queued }}
In coada
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
{{ blocate_total }}
De corectat
@@ -70,6 +99,30 @@
+ {# Mobil (<=560px): bara compacta — numerele + etichete scurte in-line #} + + {# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping === Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ. #} @@ -84,4 +137,20 @@ class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}{% endif %} + {# US-006 (5.17): linia de plan — consum/trial (secundar, sub navigatie, non-blocant). + Warn=culoare+text (accesibilitate): >=80% -> --warn; limita atinsa -> --err. + Ierarhie: nu concureaza cu stripul de sanatate (E zero-silent-failures pastrat). #} + {% if plan_linie is defined and plan_linie %} +
+ {{ plan_linie }} + {% if plan_limita_atinsa|default(false) or plan_warn|default(false) %} +  Detalii plan + {% endif %} +
+ {% endif %} +
diff --git a/app/web/templates/_upload.html b/app/web/templates/_upload.html index ef35234..06322da 100644 --- a/app/web/templates/_upload.html +++ b/app/web/templates/_upload.html @@ -52,15 +52,15 @@ role="region" aria-label="Zona de incarcare fisier" style="display:flex; align-items:center; gap:14px; flex-wrap:wrap; padding:12px 16px; text-align:left;"> - Importa: + Importa: - sau trage aici - + sau trage aici + NU se trimite nimic la RAR pana confirmi.
@@ -69,10 +69,10 @@
{% if not sheets %} -

Primul fisier? Trage-l aici.

-

xlsx sau csv, max 5000 randuri

+

Primul fisier? Trage-l aici.

+

xlsx sau csv, max 5000 randuri

{% else %} -

+

Incarca fisierul din nou dupa ce ai ales foaia.

{% endif %} @@ -80,18 +80,18 @@
-

+

NU se trimite nimic la RAR pana confirmi explicit.

{% endif %} + style="font-size:var(--fs-sm); margin-top:6px; display:inline;"> se parseaza fisierul... diff --git a/app/web/templates/base.html b/app/web/templates/base.html index ac23ae8..f1e2479 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}Gateway RAR AUTOPASS{% endblock %} + {% block title %}ROMFAST AUTOPASS{% endblock %} - {# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #} + {# US-010 (PRD 5.16): antet branduit ROMFAST AUTOPASS. + Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger). + Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
{# Celula stanga: logo ROMFAST #}
@@ -763,35 +766,86 @@
- {# Celula centru: titlu + badge env mic. + {# Celula centru: titlu ROMFAST AUTOPASS + badge env + badge tier + sub-titlu account_name. Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
-

Gateway RAR AUTOPASS

- {{ rar_env }} + +

ROMFAST AUTOPASS{{ rar_env }}{% if is_authenticated|default(false) and tier_label|default('') %}{{ tier_label }}{% endif %}

+
+ {% if is_authenticated|default(false) and account_name|default('') %} +
Service auto: {{ account_name }}
+ {% endif %}
- {# Celula dreapta: comutator tema + versiune + meniu cont #} + {# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
- - v{{ version }} + {# US-003 (PRD 5.16): dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu. + Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #} + {% if is_authenticated|default(false) %} + {% if sanatate_ok|default(true) %} +
+ + RAR online +
+ {% else %} +
+ + RAR blocat +
+ {% endif %} + {% endif %} + {# US-011 (PRD 5.16): selector tema = pill cu icon + eticheta temei curente. + Eticheta ascunsa pe <=560px via CSS. JS actualizeaza .tema-icon si #tema-label. #} + + v{{ version }} {% if is_authenticated|default(false) %} {# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout. + US-010: structura cu
separatori + RAR status (prima intrare) + Plan tier. Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}