Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Agent
ccd26115f8 test(fixtures): muta CSV-urile de import exemplu in tests/fixtures/
Doua formate exemplu pentru import: test_data.csv (cod_prestatie RAR direct)
si test_data_mapping.csv (cod_op_service + denumire, necesita mapare operatie).
Folosite la testarea manuala a fluxului de import web.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:01:47 +00:00
Claude Agent
a40b20b3b4 feat(import): mapare operatie->cod RAR inline in preview + camp denumire_op
Inchide deadlock-ul din canalul de import web: operatiile nemapate dintr-un
batch in staging nu aveau unde sa fie mapate din UI. Editorul "Mapari de
rezolvat" citea doar din submissions comise, iar commit-ul arunca randurile
needs_mapping -> utilizatorul ramanea blocat fara a putea trimite.

- camp canonic nou `denumire_op`: coloana descriptiva (ex. "Reparatie Motor")
  alimenteaza denumirea operatiei, deci sugestia fuzzy devine utila (inainte
  denumire = codul opac). Aplicat in cele 3 locuri de resolve (preview, commit
  web, commit API).
- panou inline "Operatii de mapat la cod RAR" in preview: fiecare operatie
  nemapata cu sugestie preselectata + dropdown + auto-send + salveaza.
- ruta POST /_import/{id}/mapare-operatie: salveaza maparea (persistenta,
  operations_mapping) si re-randeaza preview-ul; randurile trec din
  needs_mapping in ok fara re-upload, maparea se retine pentru fisiere viitoare.
- fix bug pre-existent de semnatura coloane: semnatura se calcula din campurile
  mapate (json_mapare.keys), nu din antetul complet -> ignorarea unei coloane
  schimba semnatura si maparea retinuta nu mai era gasita la preview/re-upload.
  Acum mereu din antetul complet (web + API), consecvent cu preview/commit.

Teste noi: tests/test_import_mapare_operatie.py (6). Suita: 400 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:01:14 +00:00
Claude Agent
4e2b6102a4 docs(prd): PRD 3.4 interfata web ergonomica (tab-uri + wizard + microcopy)
Reorganizare dashboard pe trei principii, doar stratul de prezentare
(Jinja2+HTMX, zero build), fara atingerea worker/mapare/idempotenta:
- tab-uri sus (Acasa/Import/Coada/Mapari/Cont/Nomenclator), un panou activ,
  fragmente lazy, deep-link ?tab= randat server-side, a11y tablist/aria
- import ca stepper 4 pasi (Incarca/Potriveste/Verifica/Confirma)
- ghid de pornire auto-bifat + empty states (US-005)
- microcopy uman intr-un singur loc (labels.py): 'Trimitere automata: activa'
  in loc de 'worker viu'

Intrebari deschise rezolvate (6 tab-uri, ?tab=, pas 2 auto-bifat). Plan-reviews
CEO/Eng/Design aplicate in PRD (a11y, empty states, guard HTMX/CSRF la granita
US-003<->US-004, randare server-side). Stare: aprobat. Rand 3.4 TODO in ROADMAP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:59:27 +00:00
8 changed files with 700 additions and 10 deletions

View File

@@ -63,7 +63,8 @@ _CANONICAL_SYNONYMS: dict[str, list[str]] = {
"data_prestatie": ["Data prestatie", "Data", "Date", "Data service", "Data lucrare"],
"odometru_final": ["Odometru final", "Odometru", "KM", "Kilometri", "Km final", "Citire contor"],
"odometru_initial": ["Odometru initial", "KM initial", "Km start"],
"operatie": ["Operatie", "Denumire prestatie", "Prestatie", "Lucrare", "Tip lucrare", "Cod prestatie", "Cod op"],
"operatie": ["Operatie", "Cod prestatie", "Prestatie", "Lucrare", "Tip lucrare", "Cod op"],
"denumire_op": ["Denumire operatie", "Denumire", "Descriere", "Denumire prestatie", "Nume operatie"],
"obs": ["Observatii", "Obs", "Mentiuni", "Note"],
}
@@ -163,11 +164,15 @@ def _resolve_row_for_preview(
if is_amb:
is_ambiguous_date = True
# Operatia: daca camp canonic e "operatie", construieste prestatii
# Operatia: daca camp canonic e "operatie", construieste prestatii.
# denumire_op (coloana descriptiva, ex. "Reparatie Motor") alimenteaza
# `denumire` -> sugestia fuzzy din editorul de mapari devine utila; fara ea,
# denumire = codul opac (ex. "OP-MOTOR") si fuzzy nu are pe ce sa lucreze.
operatie_val = mapped.pop("operatie", None)
denumire_val = mapped.pop("denumire_op", None)
if operatie_val and "prestatii" not in mapped:
# Construieste un item de prestatie din operatie
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}]
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
canon = canonicalize_row(mapped)
@@ -528,8 +533,22 @@ def save_column_mapping(
if not batch:
raise HTTPException(status_code=404, detail="batch de import inexistent")
# Recalculeaza semnatura din coloanele fisierului (cheile maparii)
# Semnatura = antetul COMPLET al fisierului (toate coloanele din batch), nu
# doar campurile mapate. Altfel, daca clientul ignora o coloana, semnatura
# difera de cea calculata la preview (col_names = antet complet) si maparea
# retinuta nu mai e gasita. Citim antetul din primul rand al batch-ului.
first_row = conn.execute(
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
(import_id,),
).fetchone()
columns = list(req.json_mapare.keys())
if first_row:
try:
rd = decrypt_creds(first_row["raw_json"]) or {}
if rd:
columns = list(rd.keys())
except Exception:
pass
sig = _signature(columns)
conn.execute(
@@ -925,10 +944,12 @@ def commit_import(
if iso_date:
mapped["data_prestatie"] = iso_date
# Operatia -> prestatii
# Operatia -> prestatii (denumire_op alimenteaza denumirea reala)
operatie_val = mapped.pop("operatie", None)
denumire_val = mapped.pop("denumire_op", None)
if operatie_val and "prestatii" not in mapped:
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}]
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or []

View File

@@ -48,6 +48,7 @@ from ..mapping import (
reresolve_account,
resolve_prestatii,
save_mapping,
suggest_codes,
)
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5)
@@ -253,6 +254,35 @@ def post_mapare(
# Toate rutele /_import/* returneaza fragmente HTML (target #import-section). #
# =========================================================================== #
def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict]) -> list[dict]:
"""Operatii distincte nemapate dintr-un preview de import (staging), cu sugestii fuzzy.
Echivalentul lui pending_unmapped() dar pe randuri de PREVIEW (import in staging,
inca neexistente ca submissions). Aduna doar prestatiile fara cod_prestatie
(cele cu auto_send=0 au deja cod -> nu apar aici). Sortare: cele mai blocate intai.
"""
agg: dict[str, dict[str, Any]] = {}
for row in preview_rows:
if row.get("resolved_status") != "needs_mapping":
continue
for item in (row.get("resolved", {}).get("prestatii") or []):
if not isinstance(item, dict) or item.get("cod_prestatie"):
continue
op = (item.get("cod_op_service") or "").strip()
if not op:
continue
entry = agg.setdefault(op, {"cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0})
if not entry["denumire"] and item.get("denumire"):
entry["denumire"] = item.get("denumire")
entry["blocked"] += 1
out: list[dict] = []
for entry in agg.values():
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
out.append(entry)
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
return out
def _web_compute_preview(
conn,
import_id: int,
@@ -416,11 +446,14 @@ def _web_compute_preview(
except Exception:
conn.execute("ROLLBACK")
nomenclator = load_nomenclator(conn)
return {
"rows": preview_rows,
"summary": summary,
"total": len(preview_rows),
"filename": batch["filename"],
"unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator),
"nomenclator": nomenclator,
}
@@ -602,7 +635,11 @@ async def web_save_mapare_coloane(
request, error="Batch de import inexistent sau expirat."
))
sig = _signature(list(json_mapare.keys()))
# Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele
# ignorate), nu doar campurile mapate. Altfel ignorarea unei coloane schimba
# semnatura si maparea nu mai e gasita la preview/re-upload (col_names = antet
# complet peste tot). `colnames` vine din form = toate coloanele randate.
sig = _signature(colnames or list(json_mapare.keys()))
# Salveaza maparea (upsert)
conn.execute(
@@ -650,6 +687,49 @@ def web_preview_import(
conn.close()
@router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse)
async def web_mapare_operatie(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Mapeaza o operatie nemapata din preview-ul de import la un cod RAR, in flux.
Salveaza maparea (persistenta, operations_mapping) si re-randeaza preview-ul:
_web_compute_preview recalculeaza cu noua mapare si re-scrie resolved_status in
import_rows, deci randurile afectate trec din needs_mapping in ok fara re-upload.
"""
account_id = require_login(request)
conn = get_connection()
try:
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
cod_op_service = str(form.get("cod_op_service") or "").strip()
cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper()
auto_send = bool(form.get("auto_send"))
def _render(message: str | None = None, error: bool = False) -> HTMLResponse:
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=message, error=error, **result
))
if not cod_op_service or not cod_prestatie:
return _render("Alege un cod RAR pentru operatie.", error=True)
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
).fetchone()
if not exists:
return _render(f"Cod RAR necunoscut: {cod_prestatie}", error=True)
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send)
return _render(f"Mapat {cod_op_service} -> {cod_prestatie}.")
finally:
conn.close()
@router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
@@ -834,10 +914,12 @@ async def web_confirma_import(
mapped["data_prestatie"] = iso_date
break
# Operatia → prestatii
# Operatia → prestatii (denumire_op alimenteaza denumirea reala)
operatie_val = mapped.pop("operatie", None)
denumire_val = mapped.pop("denumire_op", None)
if operatie_val and "prestatii" not in mapped:
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}]
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Rezolva prestatii
prestatii = mapped.get("prestatii") or []

View File

@@ -52,6 +52,59 @@
{% endfor %}
</div>
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
{% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:#241a1a; margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e
preselectata) si salveaza — randurile blocate trec automat in
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
</p>
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<form class="maprow" hx-post="/_import/{{ import_id }}/mapare-operatie"
hx-target="#import-section" hx-swap="outerHTML"
style="align-items:flex-end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
{% if e.denumire and e.denumire != e.cod_op_service %}
<div class="muted">{{ e.denumire }}</div>
{% endif %}
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true" checked> auto-send</label>
</div>
<div class="mapcol">
<button type="submit" style="min-height:44px;">Salveaza</button>
</div>
</form>
{% endfor %}
</div>
{% endif %}
<!-- Tabel preview + bara confirmare (un singur form) -->
<form id="confirm-form"
hx-post="/_import/{{ import_id }}/confirma"

View File

@@ -78,6 +78,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
| 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) |
| 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | TODO | | Reorganizare dashboard: tab-uri sus (Acasa/Import/Mapari/Cont/Nomenclator), import ca stepper 4 pasi, ghid de pornire auto-bifat, etichete umane (`labels.py`) in loc de "worker viu". Doar stratul de prezentare (Jinja2+HTMX), fara backend de trimitere. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) |
### Etapa 4 — Viitor (Treapta 3)

View File

@@ -0,0 +1,273 @@
# PRD 3.4 — Interfata web ergonomica (tab-uri + wizard + microcopy uman)
**Stare**: aprobat
> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
> Aceasta e o livrabila de **UI/UX** — atinge interfata web (Jinja2 + HTMX, zero build), nu logica de
> trimitere catre RAR. Nu modifica `worker/`, `mapping.py`, `idempotency.py`, masina de stari.
## 1. Obiectiv
Dashboard-ul curent (`app/web/templates/dashboard.html`) ingramadeste toate sectiunile intr-un
singur scroll lung: import, status, mapari, cont, coada, nomenclator. Utilizatorul nou nu stie ce
pas urmeaza, iar etichetele sunt scrise pentru dezvoltator ("Worker viu", "RAR ok"), nu pentru
operatorul de service.
Livrabila reorganizeaza interfata pe **trei principii**, fara a schimba comportamentul backend:
1. **Tab-uri sus** — un singur panou activ la un moment dat (Acasa, Import, Mapari, Cont, Nomenclator);
restul incarcate lazy prin HTMX. Bara de status ramane mereu vizibila, indiferent de tab.
2. **Flux tip wizard** — pasii sunt numerotati si auto-explicativi; utilizatorul nu ghiceste ce
are de facut. Importul devine un stepper explicit (Incarca → Mapeaza → Verifica → Confirma), iar
pagina "Acasa" arata un ghid de pornire care bifeaza singur ce e deja configurat.
3. **Microcopy uman** — etichete scrise pentru oameni, nu pentru cod: "Trimitere automata catre RAR:
activa" in loc de "Worker viu". Vezi tabelul din §3 (US-001).
Decizie de layout confirmata cu utilizatorul (AskUserQuestion): **tab-uri sus**, cu cerinta explicita
de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pentru oameni".
## 2. Non-Goals (anti scope-creep)
- **Fara schimbari de backend de trimitere**: worker, mapare op→cod, idempotenta, reconciliere,
masina de stari submissions raman neatinse. Doar stratul de prezentare web.
- **Fara framework JS / build step**: ramane Jinja2 + HTMX + CSS in `base.html`. Fara React/Vue/Tailwind,
fara bundler. Eventualul JS e vanilla inline, minim (deja exista pattern: clipboard in `_cont.html`).
- **Fara endpoint-uri API noi `/v1/*`** si fara schimbari de schema SQL. Tab-urile folosesc fragmentele
HTMX existente (`/_fragments/*`) plus, la nevoie, fragmente de prezentare noi sub `/_fragments/`.
- **Fara rescriere a fluxului de import** (parsare, mapare coloane, preview, commit raman ca logica) —
doar se imbraca intr-un stepper vizual.
- **Fara redesign al paginilor `login.html` / `signup.html` / `admin.html`** dincolo de aplicarea
acelorasi etichete/clase daca e trivial. Focusul e dashboard-ul autentificat.
- **Fara i18n / multi-limba**: textele raman in romana, hardcodate (ca tot proiectul).
- **Fara tema light / toggle de tema**: pastram paleta dark din `base.html`.
## 3. Stories atomice
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
> comportament = 2 stories. Toate atingerile sunt in `app/web/` (templates + routes + un modul de
> etichete). Verificare E2E = browser HTMX pe `http://localhost:8000/` (Playwright MCP sau `/browse`).
> **Regula de aur**: fluxul import → commit → worker → FINALIZATA la RAR test NU are voie sa se strice.
### US-001: Microcopy uman pentru status si stari (modul de etichete + teste)
**Ca** operator de service **vreau** etichete pe care le inteleg fara sa fiu programator
**pentru ca** sa stiu dintr-o privire ce face sistemul si ce am eu de facut.
- **Depinde de**: —
- **Fisiere**: `app/web/labels.py` (nou), `tests/test_web_labels.py` (nou) (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_labels.py``test_eticheta_worker_viu`, `test_eticheta_worker_mort`,
`test_eticheta_stare_submission`, `test_toate_starile_au_eticheta` (nicio stare din `schema.sql` fara mapare).
- **Continut**: un singur loc (`labels.py`) care traduce starile tehnice in text uman + clasa CSS de
culoare. Tabelul de adevar (caption-uri propuse, de finalizat cu utilizatorul daca difera):
| Tehnic (azi) | Eticheta umana propusa | Sub-text / tooltip |
|---|---|---|
| Worker `viu` | **Trimitere automata: activa** | Sistemul verifica coada si trimite la RAR la fiecare cateva secunde. |
| Worker `mort` | **Trimitere automata: oprita** | Nimic nu pleaca spre RAR pana reporneste. Anunta administratorul. |
| RAR `ok` | **Legatura cu RAR: functionala** | Portalul AUTOPASS raspunde. |
| RAR `indisponibil` | **Legatura cu RAR: indisponibila** | Portalul RAR nu raspunde acum; coada se reia automat cand revine. |
| `Ultimul login RAR` | **Ultima autentificare la RAR** | — |
| `In coada` / `queued` | **In asteptare sa fie trimise** | — |
| `Trimise` / `sent` | **Declarate la RAR (finalizate)** | Confirmate cu numar de prezentare; nu se mai pot modifica. |
| `Blocate` | **Necesita atentia ta** | Defalcare pe motiv (lipsa cod / date incomplete / eroare). |
| `sending` | **Se trimite acum** | — |
| `needs_mapping` | **Lipseste codul prestatiei** | Alege codul RAR in tab-ul Mapari. |
| `needs_data` | **Date incomplete (respinse de RAR)** | Corecteaza randul si reimporta. |
| `error` | **Eroare la trimitere** | Vezi detaliul randului; se reincearca automat sau necesita corectie. |
- **Acceptance criteria**:
- [ ] `labels.py` expune o functie/dict care, pentru fiecare stare din `schema.sql`, da `(text, css_class)`.
- [ ] Nicio stare de submission existenta nu ramane fara eticheta (test parametrizat care iese rosu daca se adauga o stare noua nemapata).
- [ ] Functiile sunt pure (fara DB, fara request) — usor de testat unitar.
- **Verificare E2E**: indirect, prin US-002/US-003 (etichetele apar in UI).
### US-002: Bara de status persistenta cu etichete umane (fragment)
**Ca** operator **vreau** sa vad mereu, sus, starea sistemului in cuvinte clare
**pentru ca** sa am incredere ca trimiterile chiar pleaca, fara sa stiu ce e un "worker".
- **Depinde de**: US-001
- **Fisiere**: `app/web/templates/_status.html` (nou), `app/web/routes.py` (endpoint `/_fragments/status`),
`tests/test_web_status_fragment.py` (nou) (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_status_fragment.py``test_status_fragment_text_uman`
(contine "Trimitere automata", nu "worker viu"), `test_status_blocate_defalcare`,
`test_status_se_reincarca_htmx` (are `hx-trigger` periodic).
- **Continut**: extrage blocul de status din `dashboard.html` intr-un fragment dedicat care foloseste
`labels.py`. Ramane sticky/vizibil sus indiferent de tab-ul activ. Defalca "Necesita atentia ta"
pe motive. Pastreaza poll-ul HTMX (`every 15s`) deja existent pentru banner.
- **Acceptance criteria**:
- [ ] `/_fragments/status` randeaza bara cu etichetele din US-001 (scoped pe cont, ca restul UI).
- [ ] Bara ramane vizibila sus cand se schimba tab-ul (nu e inghitita de panoul activ).
- [ ] Cand exista submissions blocate, bara arata defalcarea pe motiv, nu doar un numar.
- **Verificare E2E**: browser — incarca `/`, bara de status arata text uman; opreste workerul →
"Trimitere automata: oprita".
### US-003: Navigare cu tab-uri (shell dashboard)
**Ca** operator **vreau** sectiuni separate pe tab-uri, nu un scroll lung
**pentru ca** sa gasesc rapid ce caut fara sa fiu coplesit.
- **Depinde de**: US-002
- **Fisiere**: `app/web/templates/dashboard.html` (restructurare), `app/web/templates/_tabs.html` (nou,
optional), `app/web/routes.py` (ruta `/` + suport deep-link tab), `tests/test_web_tabs.py` (nou) (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_tabs.py``test_dashboard_are_tabbar`,
`test_tab_implicit_acasa`, `test_deeplink_tab_import` (`/?tab=import` randeaza panoul Import
server-side la full load), `test_tab_activ_randat_server_side` (panoul activ e in HTML-ul initial,
nu doar cerut prin HTMX dupa load), `test_fragmentele_inactive_lazy` (panourile inactive nu se cer
la load), `test_tabbar_aria` (atribute `role=tablist`/`tab`/`tabpanel` + `aria-selected` prezente).
- **Continut**: bara de tab-uri sub header: **Acasa · Import · Coada · Mapari · Cont · Nomenclator**
(+ "Panou admin" / "Iesi" pastrate in coltul din dreapta sus). Panoul de submissions existent muta
din scroll in tab-ul **Coada**. Un singur panou activ; tab-ul isi incarca fragmentul prin HTMX la
activare (lazy), nu toate la load. **Randare server-side a panoului activ** la full load (din `?tab=`):
fara palpaire la refresh si degradare gratioasa daca JS lipseste — utilizatorul vede macar panoul
curent. Bara de status (US-002) sta deasupra tab-bar-ului, mereu vizibila. Tab activ marcat vizual.
**Accesibilitate reala**: `role="tablist"` pe bara, `role="tab"` + `aria-selected` pe fiecare tab,
`role="tabpanel"` pe panou, navigare cu sageti intre tab-uri (JS vanilla minim). Mobil: tab-bar se
ruleaza orizontal / se sparge curat (fara meniu hamburger — pastram simplu).
- **Acceptance criteria**:
- [ ] Tab-bar cu cele 6 tab-uri (Acasa · Import · Coada · Mapari · Cont · Nomenclator); "Acasa" implicit la prima incarcare.
- [ ] Un singur panou randat la un moment dat; celelalte fragmente NU se cer pana la activarea tab-ului.
- [ ] Panoul activ (inclusiv din `?tab=`) e randat **server-side** la full load — fara palpaire la refresh, vizibil si fara JS.
- [ ] Accesibilitate: `role=tablist/tab/tabpanel`, `aria-selected` pe tab-ul activ, navigare cu sageti (nu doar focus vizibil).
- [ ] Refresh pe un tab non-implicit revine pe acelasi tab (deep-link prin query string `?tab=`).
- [ ] Toate functiile existente raman accesibile dintr-un tab (nimic pierdut fata de pagina veche).
- **Verificare E2E**: browser — click pe fiecare tab incarca panoul corect; refresh pe `?tab=import`
ramane pe Import; navigare cu sageti intre tab-uri functioneaza (citior de ecran anunta tab-ul activ).
### US-004: Importul ca wizard cu pasi numerotati (stepper)
**Ca** operator care incarca un fisier **vreau** sa vad clar in ce pas sunt si ce urmeaza
**pentru ca** sa nu ma blochez intrebandu-ma "si acum ce fac?".
- **Depinde de**: US-003
- **Fisiere**: `app/web/templates/_stepper.html` (nou, include partajat),
`app/web/templates/_upload.html`, `_mapcoloane.html`, `_preview_import.html` (adauga antet stepper),
`tests/test_web_import_stepper.py` (nou) (~5 fisiere)
- **Test intai (RED)**: `tests/test_web_import_stepper.py``test_stepper_pas1_la_upload`,
`test_stepper_pas2_la_mapare`, `test_stepper_pas3_la_preview`, `test_stepper_marcheaza_pasii_facuti`,
`test_import_hx_target_in_tab` (fragmentele de import au `hx-target` care se rezolva in panoul de tab,
nu pe vechiul container de pagina), `test_import_forms_pastreaza_csrf` (formularele mutate isi pastreaza
`csrf_token` valid).
- **Continut**: un antet de stepper reutilizabil cu 4 pasi vizibili in toate fragmentele de import:
**1. Incarca fisier → 2. Potriveste coloanele → 3. Verifica → 4. Confirma trimiterea**. Pasul curent
evidentiat, pasii facuti bifati, pasii viitori estompati. Fiecare ecran are un titlu-actiune si o
fraza de ajutor ("Trage un fisier xlsx/csv aici" / "Spune-ne ce coloana e ce" / "Verifica inainte sa
trimiti"). Stepper-ul e PUR vizual — nu schimba endpoint-urile sau ordinea logica existenta.
**Granita cu US-003 (risc tehnic principal)**: cand fragmentele de import se randeaza in tab-ul Import,
`hx-target`/`hx-swap` din upload→mapare→preview trebuie sa tinteasca un container din interiorul
panoului de tab (nu vechiul container de la radacina paginii), iar `csrf_token` din formularele de
import trebuie sa ramana corect. Verificat prin testele de mai sus + regula de aur.
- **Acceptance criteria**:
- [ ] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat.
- [ ] Pasii deja parcursi sunt marcati ca facuti; cei viitori sunt estompati.
- [ ] Fiecare pas are un titlu-actiune + o fraza scurta de ajutor (microcopy din US-001 unde se aplica).
- [ ] `hx-target` din fragmentele de import se rezolva in panoul de tab; `csrf_token` pastrat in formulare.
- [ ] Fluxul de import functioneaza identic (upload → mapare → preview → confirma) — fara regresie.
- **Verificare E2E**: browser — urca `test_data.csv`, parcurge cei 4 pasi; stepper-ul avanseaza corect;
commit → randuri in coada → worker → FINALIZATA la RAR test (regula de aur).
### US-005: Pagina "Acasa" cu ghid de pornire (checklist auto-bifat)
**Ca** utilizator nou **vreau** sa mi se spuna exact ce am de configurat ca sa pot trimite
**pentru ca** sa ajung la prima declaratie reusita fara sa ghicesc pasii.
- **Depinde de**: US-003
- **Fisiere**: `app/web/templates/_acasa.html` (nou), `app/web/routes.py` (context: are_creds, are_cheie_folosita,
are_trimiteri), `tests/test_web_onboarding.py` (nou) (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_onboarding.py``test_checklist_pas_creds_neconfigurat`,
`test_checklist_pas_creds_bifat_cand_exista`, `test_checklist_ascuns_cand_totul_gata`,
`test_linkuri_ghid_duc_la_taburi`, `test_empty_state_coada_gol` (tab Coada fara submissions arata
indemn catre Import, nu un tabel gol), `test_empty_state_mapari_gol`.
- **Continut**: tab-ul "Acasa" (implicit) arata un card "Primii pasi" cu o lista bifabila:
1. **Conecteaza-ti contul RAR** (email + parola portal AUTOPASS) — link la tab Cont.
2. **(Optional) Ia-ti cheia API** daca trimiti din soft propriu — link la tab Cont.
3. **Importa primul fisier** — link la tab Import.
Fiecare pas se bifeaza automat cand e indeplinit (creds configurate → pas 1 bifat; exista cel putin
o trimitere → pas 3 bifat). Cand toti pasii esentiali sunt gata, ghidul se colapseaza intr-o linie
discreta ("Totul e configurat — vezi coada"), ca sa nu deranjeze utilizatorul experimentat. Sub ghid,
pe acelasi tab, un rezumat scurt + scurtaturi (coada recenta / actiuni rapide).
- **Acceptance criteria**:
- [ ] Pasul "Conecteaza contul RAR" e nebifat fara creds, bifat cand `are_creds` e adevarat.
- [ ] Pasul "Importa primul fisier" se bifeaza cand contul are cel putin un submission.
- [ ] Cand toti pasii esentiali sunt gata, ghidul e colapsat/discret (nu ocupa tot ecranul).
- [ ] Link-urile din ghid duc la tab-ul corect (Cont / Import).
- [ ] **Empty states prietenoase**: tab Coada gol → "Nicio trimitere inca — incepe cu Import" (link la
tab Import); tab Mapari gol → mesaj scurt + indemn, nu un tabel/lista goala fara context.
- **Verificare E2E**: browser — cont nou (fara creds): ghid vizibil cu pasi nebifati + tab Coada arata
empty state cu indemn la Import; dupa setarea credentialelor si un import, pasii se bifeaza si ghidul se restrange.
## 4. Riscuri
- **Regresie pe fluxul de import** (cel mai mare risc): stepper-ul (US-004) atinge cele 3 fragmente
de import. Mitigare: stepper PUR vizual, endpoint-urile si ordinea logica raman; regula de aur in
fiecare VERIFY (import → FINALIZATA la RAR test).
- **Lazy-load gresit**: daca tab-urile cer toate fragmentele la load, dispare castigul de aglomerare
si creste load-ul. Mitigare: test explicit `test_fragmentele_inactive_lazy` (US-003).
- **Scope pe cont pierdut la mutarea in fragmente noi**: fragmentele existente sunt scoped pe sesiune
(regula NULL→cont 1, anti-leak C6 din 3.3a). Orice fragment nou (status, acasa) trebuie sa pastreze
acelasi scope. Mitigare: testele de fragment verifica izolarea pe cont; VERIFY reia sweep-ul anti-leak.
- **CSRF / form-uri**: tab Cont muta form-urile de rotire cheie / creds RAR — trebuie pastrate
`csrf_token` si `hx-target` corecte. Mitigare: reutilizam `_cont.html` ca atare in tab.
- **Microcopy "definitiv"**: etichetele din §3 sunt propuneri; daca utilizatorul vrea alt ton, se
ajusteaza in `labels.py` (un singur loc). Risc mic.
## 5. Intrebari deschise — REZOLVATE
> Rezolvate cu utilizatorul inainte de executie (poarta de aprobare PRD). Deciziile de mai jos sunt
> obligatorii pentru EXECUTE.
1. **Tab-uri** — DECIS: **6 tab-uri**: **Acasa · Import · Coada · Mapari · Cont · Nomenclator**.
Fata de propunerea initiala (5), "Coada submissions" devine tab propriu (e ecranul operational
principal, nu se ascunde sub Acasa). Nomenclatorul ramane tab separat (referinta consultata rar,
dar cautata explicit). Comasarea Nomenclator-sub-Mapari respinsa: sunt actiuni diferite (editezi
mapari vs. doar consulti coduri).
2. **Tab implicit** — DECIS: **Acasa** pentru toti, dar ghidul de pornire (US-005) se **restrange
automat** cand contul e configurat (creds RAR setate + cel putin o trimitere), lasand un rezumat
scurt + scurtatura la Coada. Utilizatorul experimentat vede de fapt un mini-rezumat, nu un wizard.
3. **Etichete (§3 US-001)** — DECIS: se **adopta tabelul din US-001** ca atare. Ton: descriptiv-functional
("Trimitere automata: activa"), nu colocvial ("Coada se trimite singura"). Toate textele intr-un
singur loc (`labels.py`) — ajustabile ulterior fara atingerea template-urilor.
4. **Persistenta tab activ** — DECIS: **query string** (`/?tab=import`). Supravietuieste refresh-ului,
e testabil server-side (criteriul din US-003), si permite link-uri directe din ghid (US-005). Hash-ul
respins: nu ajunge la server, deci netestabil in `test_deeplink_tab_import`.
5. **Stepper import** — DECIS: **4 pasi ficsi** (Incarca · Potriveste coloanele · Verifica · Confirma).
Cand semnatura de coloane e deja memorata, **pasul 2 apare bifat automat** si fluxul sare vizual la
pasul 3, dar pasul 2 ramane afisat in stepper (estompat/bifat) pentru orientare — utilizatorul vede
ca maparea a fost recunoscuta, nu ca a disparut un pas.
> Impact asupra stories: US-003 listeaza acum **6 tab-uri** (adaugat "Coada" ca tab propriu — panoul de
> submissions existent muta din scroll in tab-ul Coada). Restul stories raman neschimbate.
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] ← modul pur de etichete, fisiere distincte → poate porni singur
Val 2: [US-002] ← bara de status, foloseste US-001
Val 3: [US-003] ← shell-ul de tab-uri, are nevoie de bara de status (US-002)
Val 4: [US-004] [US-005] ← ambele depind de shell-ul de tab-uri (US-003); fisiere distincte
(US-004 = fragmentele de import; US-005 = _acasa.html) → paralel
```
> US-004 si US-005 ating fisiere disjuncte (import vs acasa) — pot rula in paralel (max 2 teammates).
> Atentie: US-003 si US-004 ating amandoua zona de import in `dashboard.html`/include-uri — NU paralel
> cu US-003; de aceea US-004 e in valul urmator.
## 7. Plan-reviews aplicate (CEO / Eng / Design)
> Aplicate IN PRD inainte de cod (ROADMAP §5.3). Constatari pliate cu acordul utilizatorului.
- **CEO (valoare/scope)**: premisa validata — frecarea de activare pe Treapta 2 (service-uri non-ROAAUTO)
e wedge-ul real; US-005 (ghid de pornire) are cel mai mare ROI. Scope sanatos (5 stories, doar strat
de prezentare), fara expansiune. Nota de secventiere: daca timpul scade, US-001/002 + US-005 (microcopy
+ activare) au prioritate peste estetica de tab-uri (US-003).
- **Eng (fezabilitate/teste)**: cel mai mare risc tehnic = granita US-003↔US-004 (hx-target/CSRF din
fragmentele de import mutate in tab) → AC + teste dedicate adaugate in US-004. Adaugat: randare
server-side a panoului activ (degradare gratioasa fara JS) in US-003. Constrangere de testare:
TestClient nu executa JS — testele unitare verifica atribute + stare initiala randata server-side;
interactivitatea (swap, navigare cu sageti) cade pe E2E Playwright.
- **Design (UI/UX)**: accesibilitate reala a tab-urilor (`role=tablist/tab/tabpanel` + `aria-selected`
+ navigare cu sageti) in loc de "focus vizibil" — pliat in US-003. Empty states prietenoase (Coada/Mapari
goale) — pliat in US-005. Tabelul de microcopy validat.
---
## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E browser pe `http://localhost:8000/`,
> plus regula de aur: import → worker → FINALIZATA la RAR test). Lipseste pana la VERIFY.

6
tests/fixtures/test_data.csv vendored Normal file
View File

@@ -0,0 +1,6 @@
vin,nr_inmatriculare,data_prestatie,odometru_final,odometru_initial,cod_prestatie
1G1FB1S52D1234567,AB123CD,2026-06-10,45000,40000,R-REP
WVWZZZ3CZ9E123456,TM789BC,2026-05-15,82500,,VP-DAY
2B1FB1S39C2345678,CJ456DE,2026-04-20,125000,120000,I-ODO
JTHBP5C20D5123456,OT567FG,2026-03-08,38000,,ITP-REP
5TDJZRFH2LS123456,VN678HI,2026-02-14,156000,150000,R-ODO
1 vin nr_inmatriculare data_prestatie odometru_final odometru_initial cod_prestatie
2 1G1FB1S52D1234567 AB123CD 2026-06-10 45000 40000 R-REP
3 WVWZZZ3CZ9E123456 TM789BC 2026-05-15 82500 VP-DAY
4 2B1FB1S39C2345678 CJ456DE 2026-04-20 125000 120000 I-ODO
5 JTHBP5C20D5123456 OT567FG 2026-03-08 38000 ITP-REP
6 5TDJZRFH2LS123456 VN678HI 2026-02-14 156000 150000 R-ODO

6
tests/fixtures/test_data_mapping.csv vendored Normal file
View File

@@ -0,0 +1,6 @@
vin,nr_inmatriculare,data_prestatie,odometru_final,cod_op_service,denumire
1G1FB1S52D1234567,AB123CD,2026-06-10,45000,OP-MOTOR,Reparatie Motor
WVWZZZ3CZ9E123456,TM789BC,2026-05-15,82500,OP-ITP,Inspecție Tehnică Periodică
2B1FB1S39C2345678,CJ456DE,2026-04-20,125000,OP-TURBO,Reparatie Turbo
JTHBP5C20D5123456,OT567FG,2026-03-08,38000,OP-ULEI,Schimb Ulei
5TDJZRFH2LS123456,VN678HI,2026-02-14,156000,OP-PNEU,Inlocuire Anvelope
1 vin nr_inmatriculare data_prestatie odometru_final cod_op_service denumire
2 1G1FB1S52D1234567 AB123CD 2026-06-10 45000 OP-MOTOR Reparatie Motor
3 WVWZZZ3CZ9E123456 TM789BC 2026-05-15 82500 OP-ITP Inspecție Tehnică Periodică
4 2B1FB1S39C2345678 CJ456DE 2026-04-20 125000 OP-TURBO Reparatie Turbo
5 JTHBP5C20D5123456 OT567FG 2026-03-08 38000 OP-ULEI Schimb Ulei
6 5TDJZRFH2LS123456 VN678HI 2026-02-14 156000 OP-PNEU Inlocuire Anvelope

View File

@@ -0,0 +1,248 @@
"""Teste flux mapare operatie inline in preview (import web in staging).
Acopera gap-ul: operatiile nemapate dintr-un import in staging nu aveau unde sa
fie mapate din UI (editorul "Mapari de rezolvat" citea doar din submissions
comise, iar commit-ul arunca randurile needs_mapping). Acum:
- camp canonic nou `denumire_op`: coloana descriptiva alimenteaza denumirea
operatiei -> sugestia fuzzy devine utila (nu codul opac);
- preview-ul expune `unmapped_ops` + panou inline de mapare;
- POST /_import/{id}/mapare-operatie salveaza maparea (persistenta) si
re-rezolva preview-ul -> randurile trec din needs_mapping in ok, fara re-upload.
"""
from __future__ import annotations
import csv as csv_mod
import io
import os
import re
import tempfile
import pytest
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
# Mod dev: fallback cont 1, fara login/CSRF (ca in test_import_ui).
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
get_settings.cache_clear()
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Cod operatie", "Denumire"]
# Cod intern opac + descriere lizibila care se potriveste cu nomenclatorul RAR
# (OE-3 = "REVIZIE PERIODICA"; fuzzy "Revizie periodica" -> OE-3 la 100%).
_ROWS = [
["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW002222", "CJ200AB", "2026-05-20", "98765", "OP-REV", "Revizie periodica"],
]
_CANON = ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie", "denumire_op"]
def _csv_bytes(header, rows, sep=";") -> bytes:
buf = io.StringIO()
w = csv_mod.writer(buf, delimiter=sep)
w.writerow(header)
for r in rows:
w.writerow(r)
return buf.getvalue().encode("utf-8")
def _upload(client, header=_HEADER, rows=None) -> int:
rows = _ROWS if rows is None else rows
r = client.post(
"/_import/upload",
files={"file": ("t.csv", _csv_bytes(header, rows), "text/csv")},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"form mapare-coloane lipsa: {r.text[:300]}"
return int(m.group(1))
def _map_columns(client, import_id, canon=None):
return client.post(
f"/_import/{import_id}/mapare-coloane",
data={
"colname": _HEADER,
"canon": canon or _CANON,
"format_data": "YYYY-MM-DD",
},
)
# --------------------------------------------------------------------------- #
# 1. denumire_op alimenteaza denumirea -> sugestie fuzzy utila #
# --------------------------------------------------------------------------- #
def test_denumire_op_alimenteaza_denumirea_si_sugestia(client):
import_id = _upload(client)
_map_columns(client, import_id)
from app.db import get_connection
from app.web.routes import _web_compute_preview
conn = get_connection()
try:
result = _web_compute_preview(conn, import_id, account_id=1)
finally:
conn.close()
assert not isinstance(result, str), result
ops = result["unmapped_ops"]
assert len(ops) == 1, ops
op = ops[0]
assert op["cod_op_service"] == "OP-REV"
# Cheia: denumirea e descrierea reala, NU codul opac.
assert op["denumire"] == "Revizie periodica"
assert op["blocked"] == 2
# Sugestia fuzzy gaseste OE-3 (REVIZIE PERIODICA) sus, cu scor real.
assert op["suggestions"], "fara sugestii"
top = op["suggestions"][0]
assert top["cod_prestatie"] == "OE-3"
assert top["score"] >= 60
def test_fara_denumire_op_denumirea_e_codul(client):
"""Control: daca NU mapezi coloana descriptiva, denumirea ramane codul opac."""
import_id = _upload(client)
# operatie mapat, descrierea ignorata
_map_columns(client, import_id, canon=[
"vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie", "",
])
from app.db import get_connection
from app.web.routes import _web_compute_preview
conn = get_connection()
try:
result = _web_compute_preview(conn, import_id, account_id=1)
finally:
conn.close()
op = result["unmapped_ops"][0]
assert op["denumire"] == "OP-REV" # fara denumire_op -> codul
# --------------------------------------------------------------------------- #
# 2. Preview expune panoul inline #
# --------------------------------------------------------------------------- #
def test_preview_arata_panoul_de_mapare(client):
import_id = _upload(client)
r = _map_columns(client, import_id)
assert r.status_code == 200
assert "Operatii de mapat la cod RAR" in r.text
assert "OP-REV" in r.text
assert "/_import/%d/mapare-operatie" % import_id in r.text
# --------------------------------------------------------------------------- #
# 3. POST mapare-operatie deblocheaza randurile (needs_mapping -> ok) #
# --------------------------------------------------------------------------- #
def test_mapare_operatie_deblocheaza_randurile(client):
import_id = _upload(client)
r = _map_columns(client, import_id)
assert "needs_mapping" in r.text
# Inainte: 2 needs_mapping, 0 ok
from app.db import get_connection
conn = get_connection()
try:
b = conn.execute(
"SELECT ok, needs_mapping FROM import_batches WHERE id=?", (import_id,)
).fetchone()
assert (b["ok"], b["needs_mapping"]) == (0, 2)
finally:
conn.close()
# Mapeaza OP-REV -> OE-3 (auto_send)
rm = client.post(f"/_import/{import_id}/mapare-operatie", data={
"cod_op_service": "OP-REV",
"cod_prestatie": "OE-3",
"auto_send": "true",
})
assert rm.status_code == 200, rm.text
# Preview re-randat: randurile sunt acum ok, panoul a disparut
assert "2 gata de trimis" in rm.text or "s-ok" in rm.text
assert f"/_import/{import_id}/mapare-operatie" not in rm.text
conn = get_connection()
try:
b = conn.execute(
"SELECT ok, needs_mapping FROM import_batches WHERE id=?", (import_id,)
).fetchone()
assert (b["ok"], b["needs_mapping"]) == (2, 0), dict(b)
# Maparea s-a persistat (operations_mapping)
m = conn.execute(
"SELECT cod_prestatie, auto_send FROM operations_mapping "
"WHERE account_id=1 AND cod_op_service='OP-REV'"
).fetchone()
assert m is not None and m["cod_prestatie"] == "OE-3" and m["auto_send"] == 1
# import_rows reflecta noua stare (commit-ul citeste de aici)
statuses = {
row["resolved_status"]
for row in conn.execute(
"SELECT resolved_status FROM import_rows WHERE batch_id=?", (import_id,)
)
}
assert statuses == {"ok"}, statuses
finally:
conn.close()
def test_mapare_operatie_cod_necunoscut_nu_salveaza(client):
import_id = _upload(client)
_map_columns(client, import_id)
rm = client.post(f"/_import/{import_id}/mapare-operatie", data={
"cod_op_service": "OP-REV",
"cod_prestatie": "NUEXISTA",
"auto_send": "true",
})
assert rm.status_code == 200
assert "necunoscut" in rm.text.lower()
# Inca needs_mapping, nimic salvat
assert "Operatii de mapat la cod RAR" in rm.text
from app.db import get_connection
conn = get_connection()
try:
m = conn.execute(
"SELECT 1 FROM operations_mapping WHERE cod_op_service='OP-REV'"
).fetchone()
assert m is None
finally:
conn.close()
# --------------------------------------------------------------------------- #
# 4. A doua incarcare: maparea retinuta -> direct ok (zero config) #
# --------------------------------------------------------------------------- #
def test_a_doua_incarcare_foloseste_maparea_retinuta(client):
# Prima incarcare + mapare coloane + mapare operatie
import_id = _upload(client)
_map_columns(client, import_id)
client.post(f"/_import/{import_id}/mapare-operatie", data={
"cod_op_service": "OP-REV", "cod_prestatie": "OE-3", "auto_send": "true",
})
# A doua incarcare acelasi antet -> preview direct, fara operatii de mapat
r = client.post(
"/_import/upload",
files={"file": ("t2.csv", _csv_bytes(_HEADER, _ROWS), "text/csv")},
)
assert r.status_code == 200, r.text
assert "/mapare-operatie" not in r.text
assert "gata de trimis" in r.text