diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..bd34ec8 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,150 @@ +# DESIGN.md — Sistem de design AutoPass (by ROMFAST) + +> Sursa de adevar pentru identitatea vizuala a dashboard-ului. Implementarea concreta sta in +> `app/web/templates/base.html` (variabile CSS `:root` + `[data-theme="light"]`). Acest fisier +> spune *ce* si *de ce*; base.html spune *cum*. + +## Lucrul de retinut + +> „Software serios pentru o obligatie legala serioasa — dar parte din familia ROMFAST/ROA, nu un +> tool anonim." Operatorul de service trebuie sa simta ca declara la stat printr-un instrument de +> incredere, cu identitatea producatorului (ROMFAST) prezenta discret, nu griul generic de azi. + +## Context produs + +Gateway web care declara prezentari de service-auto la RAR AUTOPASS (L.142/2023). Utilizatori: +operatori de service-auto si integratori ROAAUTO. Face parte din familia **ROA — Romfast +Applications** (ERP romanesc, modul Service Auto). Referinta de brand: **romfast.ro** — alb curat, +accent albastru azur, pill-uri rotunjite, comutator de tema, logo rosu+albastru. + +## Decizie cromatica + +Accentul functional = **albastrul ROMFAST** (acelasi cu „FAST" din logo si cu accentul de pe +romfast.ro), nu albastrul generic SaaS de pana acum. Rosul apare DOAR in wordmark-ul „ROM" — nu ca +accent de UI, fiindca rosul e rezervat starilor de eroare. Un singur accent, restul neutre, ca +sistemul sa ramana discret. + +### Paleta — Dark (default) + +``` +--bg: #0f1218 fundal aplicatie +--card: #181c24 suprafete (carduri, modal, inputuri pe fundal) +--ink: #e6e9ef text principal +--muted: #8b93a7 text secundar (label-uri, coduri, „by") +--line: #262b36 borduri, separatoare +--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus +--ok: #2FBF8F sent / succes +--warn: #E0A93B sending / atentie / Lipsa cod +--err: #E05D5D error / needs_data / Date incomplete +``` + +### Paleta — Light (`[data-theme="light"]`) + +``` +--bg: #f5f7fa fundal (alb-rece ca romfast.ro) +--card: #ffffff suprafete +--ink: #1a1d24 text principal +--muted: #5c6473 text secundar +--line: #e2e5ea borduri +--accent:#1F66C9 azur, variantă mai inchisa pentru contrast AA pe alb +--ok: #15803d verde AA pe alb +--warn: #b45309 chihlimbar AA pe alb +--err: #dc2626 rosu AA pe alb +``` + +### Paleta — Petrol (`[data-theme="petrol"]`, tema selectabila) + +Tema intunecata alternativa, cu accent petrol-teal (directia initiala aleasa, pastrata ca optiune). +Aceleasi neutre-calde inchise; doar accentul difera de azur. + +``` +--bg: #0e1416 fundal petrol-inchis +--card: #161e20 suprafete +--ink: #e6e9ef text principal +--muted: #8b93a7 text secundar +--line: #232c2e borduri +--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus +--ok: #2FBF8F sent +--warn: #E0A93B atentie +--err: #E05D5D eroare +``` + +### Culori de brand (doar wordmark, NU variabile de UI) + +``` +ROM: #D1342F rosu logo +FAST: #2E74D6 albastru logo (= accentul de UI in dark) +``` + +Contrast: textul principal pe fundal ramane AA in ambele teme; accentul pe alb foloseste varianta +mai inchisa (`#1F66C9`) ca text/linkul sa treaca AA. + +## Tipografie + +- **UI / titluri**: **IBM Plex Sans** — sans-serif cu caracter ingineresc, open-source, potrivit + pentru „software serios", parte din limbajul vizual tehnic. Fallback: `system-ui, sans-serif`. +- **Coduri / monospace**: **IBM Plex Mono** — pentru coduri RAR (REV2), VIN, numar inmatriculare, + detalii tehnice. Inlocuieste `ui-monospace/Menlo` actual cu o familie coerenta cu UI-ul. +- **Incarcare**: self-host `woff2` (subset latin + latin-ext pentru diacritice romanesti) in + `app/web/static/fonts/`, `font-display: swap`. Fara CDN extern (gateway intern, fara dependente + de retea la runtime). Pana la self-host, fallback la stiva de sistem nu strica layout-ul. + +## Header & branding + +- Titlul „Gateway RAR AUTOPASS" **centrat** pe header. +- Sub titlu, mic: **logo-ul ROMFAST** (`/static/romfast_logo.png`, ~28px inaltime). Decizie user + (2026-06-25, US-012b): se foloseste PNG-ul real al logo-ului (ROM rosu + FAST albastru, fundal + transparent — lizibil pe light/dark/petrol), NU wordmark-ul text. Wordmark-ul text (`by ROM FAST` + cu `ROM #D1342F` / `FAST #2E74D6`) ramane documentat ca alternativa, dar livrabila finala + foloseste imaginea. +- Controalele (comutator tema, versiune, hamburger ☰) raman la **dreapta**, fara a strica + centrarea optica a titlului (ex. grila 3 coloane: stanga goala/echilibru, centru titlu, dreapta + controale). +- Responsiv: pe mobil, wordmark-ul ramane sub titlu; controalele nu se suprapun (degrada elegant, + eventual titlu mai mic). + +## Selector de tema + +Inlocuieste comutatorul binar soare/luna cu un **buton ciclic** (pattern ca demoanaf.ro): un +singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label` +care arata tema curenta („Tema: Light" etc.). + +Ordinea ciclului: **Light → Dark → Petrol → Auto → (inapoi la Light)**. + +- `Light` → `data-theme="light"` (azur pe alb) +- `Dark` → `data-theme="dark"` (azur pe inchis, comportamentul implicit actual) +- `Petrol` → `data-theme="petrol"` (teal pe petrol-inchis) +- `Auto` → urmeaza `prefers-color-scheme`; rezolva la Light azur sau Dark azur in functie de OS + (nu seteaza `data-theme` fix, ci il deriva la paint). + +Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul +anti-FOUC din `
` trebuie sa rezolve „Auto"→light/dark inainte de primul paint (fara blink). +Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Auto (OS-aware), ca azi. + +## Componente — note de aplicare + +- **Pill-uri de stare/filtru**: rotunjite (`border-radius:99px`), ca badge-ul „ROA" de pe + romfast.ro. Pill activ = fundal accent discret (`color-mix(in srgb, var(--accent) ...)`), text + pe accent. Categoriile de problema isi pastreaza registrul: Date incomplete/Eroare = `--err`, + Lipsa cod = `--warn`. +- **Butoane primare**: fundal `--accent`, text alb (neschimbat ca structura, doar culoarea noua). +- **Linkuri / sugestii**: `--accent`. +- **Focus**: `outline:2px solid var(--accent)` (deja folosit pe randuri). +- **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`, + deci se adapteaza automat la noua paleta si la light/dark. + +## Ce NU schimbam + +- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram, + doar reimprospatam variabilele. +- Nu introducem rosu ca accent de UI (conflict cu eroare). +- ~~Nu folosim PNG-ul logo cu efect 3D in interfata (wordmark redat ca text).~~ REVIZUIT + (decizie user 2026-06-25): logo-ul PNG real e folosit in header (US-012b). Fundal transparent + + culori proprii il fac lizibil pe toate temele; nu aplicam filtre. +- Nu adaugam un al doilea accent — sistemul ramane monocrom-accent + neutre. + +## Legatura cu implementarea (PRD 5.10) + +US-012 (header „by ROMFAST" + titlu centrat) si US-013 (paleta) din +`docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md` implementeaza acest sistem. Valorile de +mai sus sunt sursa pentru variabilele din `base.html`. diff --git a/app/payload_view.py b/app/payload_view.py index 592c090..403f720 100644 --- a/app/payload_view.py +++ b/app/payload_view.py @@ -115,6 +115,16 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: # cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0" cod_rar = _clean_cod_rar(item.get("cod_prestatie")) + # US-002: operatia de service originala (codul intern + denumire venita prin API/import), + # distincta de operatia RAR mapata (cod_rar). + # Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—". + # Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`. + # Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—". + op_service_cod = _clean_str(item.get("cod_op_service")) + # op_service_denumire e relevant doar cand exista un cod de operatie de service; + # altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect. + op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else "" + return { "vehicul_nr": nr or EMPTY, "vin": vin or EMPTY, @@ -124,4 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: "odometru": odo or EMPTY, "cod": cod or EMPTY, "cod_rar": cod_rar or EMPTY, + # US-002: chei noi cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus + "op_service_cod": op_service_cod, + "op_service_denumire": op_service_denumire, } diff --git a/app/web/routes.py b/app/web/routes.py index df4678a..d2a16c8 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -13,6 +13,7 @@ from __future__ import annotations import hashlib import json +import math import sqlite3 from datetime import datetime, timezone from pathlib import Path @@ -555,43 +556,26 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: return rezultat -# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014). -_BLOCATE_SAMPLE = 3 +def _pills_categorii(counts: dict[str, int]) -> list[dict]: + """Pill-uri pentru starile cu problema (US-003 PRD 5.10). - -def _blocate_actionabil(conn, account_id: int) -> list[dict]: - """Categorii blocate cu identificatorii primelor randuri + deep-link (US-014). - - Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori - (VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman. - Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge). + Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand). + Reutilizeaza contoarele deja calculate din _status_counts. + Returneza lista goala daca nu exista nicio stare blocata. """ - from ..security import vin_partial - scope_sql, scope_params = account_scope_clause(account_id) - out: list[dict] = [] - for status in ("needs_mapping", "needs_data", "error"): - rows = conn.execute( - f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC", - scope_params + [status], - ).fetchall() - if not rows: - continue - sample = [] - for r in rows[:_BLOCATE_SAMPLE]: - prez = prezentare_din_payload(r["payload_json"]) - sample.append({ - "id": r["id"], - "vin": vin_partial(prez.get("vin") or ""), - "nr": prez.get("vehicul_nr") or "", - }) - out.append({ - "status": status, - "eticheta": eticheta_stare(status), - "n": len(rows), - "randuri": sample, - "rest": max(0, len(rows) - len(sample)), - }) - return out + # DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu). + # Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului, + # pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill). + PILL_DEFS = [ + ("needs_mapping", "Lipsa cod", "--warn"), + ("needs_data", "Date incomplete", "--err"), + ("error", "Eroare", "--err"), + ] + return [ + {"status": status, "label": label, "color_var": color_var, "n": counts.get(status, 0)} + for status, label, color_var in PILL_DEFS + if counts.get(status, 0) > 0 + ] @router.get("/_fragments/status", response_class=HTMLResponse) @@ -630,23 +614,31 @@ def fragment_status(request: Request) -> HTMLResponse: "counts_sent": counts.get("sent", 0), "blocate_total": blocate_total, "blocate_defalcat": _blocate_defalcat(counts), - "blocate_actionabil": _blocate_actionabil(conn, account_id), + "pills_categorii": _pills_categorii(counts), "account_active": _account_active(conn, account_id), }) finally: conn.close() -def _is_iso_date(value: object) -> bool: - """True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect).""" +def _iso_date_prefix(value: object) -> str | None: + """Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None. + + Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda + (ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data + fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10). + Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si + sunt excluse din filtru — comportament actual pastrat. + """ s = str(value or "").strip() - if len(s) != 10: - return False + if len(s) < 10: + return None + prefix = s[:10] try: - datetime.strptime(s, "%Y-%m-%d") - return True + datetime.strptime(prefix, "%Y-%m-%d") + return prefix except (ValueError, TypeError): - return False + return None # Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana @@ -693,6 +685,9 @@ def _submission_row_view(r) -> dict: } +_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10) + + @router.get("/_fragments/submissions", response_class=HTMLResponse) def fragment_submissions( request: Request, @@ -700,12 +695,14 @@ def fragment_submissions( vehicul: str | None = None, data_de: str | None = None, data_pana: str | None = None, + page: int = 1, ) -> HTMLResponse: - """Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009). + """Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004). - Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status); - filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie - se face dupa parsarea payload_json in Python (plafon perf notat — eng review). + US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru: + - FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET + - CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice + SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru). """ account_id = require_login(request) status = (status or "").strip() or None @@ -713,6 +710,9 @@ def fragment_submissions( 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_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python + + page = max(1, page) # pre-clamp >= 1 conn = get_connection() try: @@ -722,45 +722,79 @@ def fragment_submissions( if status: where.append("status=?") params.append(status) - # Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am - # taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care - # potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam - # afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review). - limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200" - rows = conn.execute( - "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " - "updated_at, payload_json FROM submissions " - f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}", - params, - ).fetchall() + where_sql = " AND ".join(where) - view = [] - for r in rows: - v = _submission_row_view(r) - prez = v["prez"] - if vehicul_q: - hay = f"{prez['vehicul_nr']} {prez['vin']}".upper() - if vehicul_q not in hay: - continue - if data_de or data_pana: - d = prez["data_prestatie"] - # Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string - # ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru. - if not _is_iso_date(d): - continue - if data_de and d < data_de: - continue - if data_pana and d > data_pana: - continue - view.append(v) - if len(view) >= 200: - break + if filtru_python: + # Calea B: fetch-all, filtreaza in Python, slice (US-004 H1) + # FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1) + rows_db = conn.execute( + "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " + f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC", + params, + ).fetchall() + + view_all: list[dict] = [] + for r in rows_db: + v = _submission_row_view(r) + prez = v["prez"] + if vehicul_q: + hay = f"{prez['vehicul_nr']} {prez['vin']}".upper() + if vehicul_q not in hay: + continue + if data_de or data_pana: + # Extragem portiunea YYYY-MM-DD (US-001 fix). + d_prefix = _iso_date_prefix(prez["data_prestatie"]) + if d_prefix is None: + continue + if data_de and d_prefix < data_de: + continue + if data_pana and d_prefix > data_pana: + continue + view_all.append(v) + + total = len(view_all) + pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 + page = max(1, min(page, pages)) # clamp H2 + offset = (page - 1) * _PAGE_SIZE + view = view_all[offset:offset + _PAGE_SIZE] + + else: + # Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ) + total = conn.execute( + f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params + ).fetchone()[0] + + pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 + page = max(1, min(page, pages)) # clamp H2 + offset = (page - 1) * _PAGE_SIZE + + rows_db = conn.execute( + "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " + f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC " + "LIMIT ? OFFSET ?", + params + [_PAGE_SIZE, offset], + ).fetchall() + view = [_submission_row_view(r) for r in rows_db] + + page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0 + page_end = min(page * _PAGE_SIZE, total) return templates.TemplateResponse("_submissions.html", { "request": request, "rows": view, "filtru_activ": filtru_activ, "csrf_token": get_csrf_token(request), + # Paginare (US-004) + "total": total, + "page": page, + "pages": pages, + "page_start": page_start, + "page_end": page_end, + # Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2) + "f_status": status or "", + "f_vehicul": vehicul_q or "", + "f_data_de": data_de or "", + "f_data_pana": data_pana or "", }) finally: conn.close() @@ -768,6 +802,9 @@ def fragment_submissions( # Stari ne-trimise blocate pe care le putem corecta inline (US-010). _CORECTABILE = ("needs_data", "needs_mapping") +# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error +# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields). +_EDITABILE_OP = ("needs_data", "needs_mapping", "error") # Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada. _GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping") @@ -851,12 +888,31 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, eticheta = eticheta_stare(row["status"]) nemapate_inline: list[dict] = [] nomenclator: list[dict] = [] + # Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006) + _nomenclator_complet: list[dict] = [] if conn is not None and row["status"] == "needs_mapping": # Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown. - nomenclator = load_nomenclator(conn) - nemapate_inline = _nemapate_pentru_submission(row, nomenclator) - if not nemapate_inline: - nomenclator = [] # nu expunem dropdown-ul cand nu exista operatii de mapat + _nomenclator_complet = load_nomenclator(conn) + nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet) + nomenclator = _nomenclator_complet if nemapate_inline else [] + + # US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in + # formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet + # daca e deja incarcat (needs_mapping), altfel incarca fresh. + nomenclator_rar: list[dict] = [] + if conn is not None and row["status"] in _EDITABILE_OP: + nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn) + + # US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select) + cod_prestatie_curent = "" + try: + _pd = json.loads(row["payload_json"] or "{}") + _prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else [] + if _prestatii and isinstance(_prestatii[0], dict): + cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper() + except (ValueError, TypeError): + pass + ctx = { "request": request, "csrf_token": get_csrf_token(request), @@ -882,6 +938,9 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, # PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator) "nemapate_inline": nemapate_inline, "nomenclator": nomenclator, + # US-006: select cod_prestatie pentru stari editabile + "nomenclator_rar": nomenclator_rar, + "cod_prestatie_curent": cod_prestatie_curent, "corectie_msg": message, "corectie_error": error, "corectie_errors": corectie_errors or [], @@ -1011,6 +1070,31 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR if isinstance(val, str) and val.strip() != "": content[camp] = val.strip() + # US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii. + # Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou + # e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md + # invariant "build_key hashuieste cod_prestatie, idempotency.py:34"). + _cod_raw = form.get("cod_prestatie") + cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") + if cod_prestatie_form: + exists_nom = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,) + ).fetchone() + if not exists_nom: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, error=True, + message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie_form}. " + "Alege un cod valid din lista.", + ), + ) + prestatii_form = content.get("prestatii") + if isinstance(prestatii_form, list) and prestatii_form: + p0 = dict(prestatii_form[0]) + p0["cod_prestatie"] = cod_prestatie_form + content["prestatii"] = [p0] + list(prestatii_form[1:]) + # Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune # niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil # la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata. @@ -1133,14 +1217,114 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse: """Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard. - Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza - panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista. + US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`, + actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada + direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune + (404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu + starea noua + nudge `trimiteriChanged` pentru lista. """ account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) conn = get_connection() try: + # US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission + # standard, care nu actualizeaza cheia de idempotency). + _cod_raw = form.get("cod_prestatie") + cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") + + if cod_prestatie_form: + row = _fetch_submission_scoped(conn, account_id, submission_id) + if not row: + raise HTTPException(status_code=404, detail="trimitere inexistenta") + if row["status"] != "error": + # cod_prestatie acceptat DOAR pentru starea error prin /repune + raise HTTPException( + status_code=409, + detail="modificarea cod_prestatie prin repune e valida doar pentru starea error", + ) + # Valideaza cod-ul fata de nomenclator + exists_nom = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,) + ).fetchone() + if not exists_nom: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, + error=True, + message=f"Cod RAR necunoscut: {cod_prestatie_form}. Alege un cod valid.", + ), + ) + # Parseaza payload si injecteaza cod_prestatie + try: + content = json.loads(row["payload_json"]) if row["payload_json"] else {} + if not isinstance(content, dict): + content = {} + except (ValueError, TypeError): + content = {} + prestatii = content.get("prestatii") or [] + if isinstance(prestatii, list) and prestatii: + p0 = dict(prestatii[0]) + p0["cod_prestatie"] = cod_prestatie_form + # sterge cod_op_service/denumire daca exista (codul direct preia prioritate) + p0.pop("cod_op_service", None) + content["prestatii"] = [p0] + list(prestatii[1:]) + # Re-rezolva prestatii cu noul cod + 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 + text_rules = load_text_rules(conn, account_id) + resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) + content["prestatii"] = resolved + # Canonicalize + rebuild idempotency key + canon = canonicalize_row(content) + payload_json = json.dumps(content, ensure_ascii=False) + new_key = build_key(account_id, canon) + # Verifica coliziune (numai daca cheia s-a schimbat) + if new_key != row["idempotency_key"]: + dup = conn.execute( + "SELECT id FROM submissions WHERE idempotency_key=? AND id<>?", + (new_key, row["id"]), + ).fetchone() + if dup: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, + error=True, + message=f"Exista deja o trimitere identica (rand #{dup['id']}).", + ), + ) + try: + 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), + ) + conn.commit() + except sqlite3.IntegrityError: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, + error=True, + message="Exista deja o trimitere identica. Operatia a fost oprita.", + ), + ) + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + resp = templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row2, conn=conn, account_id=account_id, + message="Cod actualizat — randul a fost re-pus in coada.", + ), + ) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + + # Cale normala: fara cod_prestatie → delega la requeue_submission try: requeue_submission(conn, account_id, submission_id) except SubmissionNotFound: diff --git a/app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2 b/app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2 new file mode 100644 index 0000000..53fb6e5 Binary files /dev/null and b/app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2 differ diff --git a/app/web/static/fonts/IBMPlexMono-Regular-latin.woff2 b/app/web/static/fonts/IBMPlexMono-Regular-latin.woff2 new file mode 100644 index 0000000..a6c77d6 Binary files /dev/null and b/app/web/static/fonts/IBMPlexMono-Regular-latin.woff2 differ diff --git a/app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2 b/app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2 new file mode 100644 index 0000000..b6c0c44 Binary files /dev/null and b/app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2 differ diff --git a/app/web/static/fonts/IBMPlexSans-Bold-latin.woff2 b/app/web/static/fonts/IBMPlexSans-Bold-latin.woff2 new file mode 100644 index 0000000..da7d57f Binary files /dev/null and b/app/web/static/fonts/IBMPlexSans-Bold-latin.woff2 differ diff --git a/app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2 b/app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2 new file mode 100644 index 0000000..d24a8a3 Binary files /dev/null and b/app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2 differ diff --git a/app/web/static/fonts/IBMPlexSans-Medium-latin.woff2 b/app/web/static/fonts/IBMPlexSans-Medium-latin.woff2 new file mode 100644 index 0000000..adbbd4c Binary files /dev/null and b/app/web/static/fonts/IBMPlexSans-Medium-latin.woff2 differ diff --git a/app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2 b/app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2 new file mode 100644 index 0000000..24f5f3a Binary files /dev/null and b/app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2 differ diff --git a/app/web/static/fonts/IBMPlexSans-Regular-latin.woff2 b/app/web/static/fonts/IBMPlexSans-Regular-latin.woff2 new file mode 100644 index 0000000..93bcd64 Binary files /dev/null and b/app/web/static/fonts/IBMPlexSans-Regular-latin.woff2 differ diff --git a/app/web/static/romfast_logo.png b/app/web/static/romfast_logo.png new file mode 100644 index 0000000..23a0e33 Binary files /dev/null and b/app/web/static/romfast_logo.png differ diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index c2440e3..6cec9fa 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -28,21 +28,13 @@ hx-swap="innerHTML" hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> -- Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat. -
- -| Coloane | -Mapari (coloana → camp) | -Format data | -- |
|---|---|---|---|
| - {{ f.columns | length }} coloane - | -- {% for col, camp in f.mappings.items() %} - {{ col }} → {{ camp }}{% if not loop.last %}; {% endif %} - {% endfor %} - | -- - | -- - | -
+ Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat. +
+ +| Coloane | +Mapari (coloana → camp) | +Format data | ++ |
|---|---|---|---|
| + {{ f.columns | length }} coloane + | ++ {% for col, camp in f.mappings.items() %} + {{ col }} → {{ camp }}{% if not loop.last %}; {% endif %} + {% endfor %} + | ++ + | ++ + | +
{{ stare_subtext }}
{% endif %} - {# === R10 (2): bloc eroare blocanta cand exista === #} - {% if erori_3n %} + {# === R10 (2): bloc eroare blocanta — DOAR in read-only (US-008). + In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp` + (text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #} + {% if not editabil and erori_3n %}
+