Compare commits
3 Commits
f149b24f96
...
ccd26115f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd26115f8 | ||
|
|
a40b20b3b4 | ||
|
|
4e2b6102a4 |
@@ -63,7 +63,8 @@ _CANONICAL_SYNONYMS: dict[str, list[str]] = {
|
|||||||
"data_prestatie": ["Data prestatie", "Data", "Date", "Data service", "Data lucrare"],
|
"data_prestatie": ["Data prestatie", "Data", "Date", "Data service", "Data lucrare"],
|
||||||
"odometru_final": ["Odometru final", "Odometru", "KM", "Kilometri", "Km final", "Citire contor"],
|
"odometru_final": ["Odometru final", "Odometru", "KM", "Kilometri", "Km final", "Citire contor"],
|
||||||
"odometru_initial": ["Odometru initial", "KM initial", "Km start"],
|
"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"],
|
"obs": ["Observatii", "Obs", "Mentiuni", "Note"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +164,15 @@ def _resolve_row_for_preview(
|
|||||||
if is_amb:
|
if is_amb:
|
||||||
is_ambiguous_date = True
|
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)
|
operatie_val = mapped.pop("operatie", None)
|
||||||
|
denumire_val = mapped.pop("denumire_op", None)
|
||||||
if operatie_val and "prestatii" not in mapped:
|
if operatie_val and "prestatii" not in mapped:
|
||||||
# Construieste un item de prestatie din operatie
|
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}]
|
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||||
|
|
||||||
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
|
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
|
||||||
canon = canonicalize_row(mapped)
|
canon = canonicalize_row(mapped)
|
||||||
@@ -528,8 +533,22 @@ def save_column_mapping(
|
|||||||
if not batch:
|
if not batch:
|
||||||
raise HTTPException(status_code=404, detail="batch de import inexistent")
|
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())
|
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)
|
sig = _signature(columns)
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -925,10 +944,12 @@ def commit_import(
|
|||||||
if iso_date:
|
if iso_date:
|
||||||
mapped["data_prestatie"] = iso_date
|
mapped["data_prestatie"] = iso_date
|
||||||
|
|
||||||
# Operatia -> prestatii
|
# Operatia -> prestatii (denumire_op alimenteaza denumirea reala)
|
||||||
operatie_val = mapped.pop("operatie", None)
|
operatie_val = mapped.pop("operatie", None)
|
||||||
|
denumire_val = mapped.pop("denumire_op", None)
|
||||||
if operatie_val and "prestatii" not in mapped:
|
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)
|
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
||||||
prestatii = mapped.get("prestatii") or []
|
prestatii = mapped.get("prestatii") or []
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from ..mapping import (
|
|||||||
reresolve_account,
|
reresolve_account,
|
||||||
resolve_prestatii,
|
resolve_prestatii,
|
||||||
save_mapping,
|
save_mapping,
|
||||||
|
suggest_codes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5)
|
# 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). #
|
# 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(
|
def _web_compute_preview(
|
||||||
conn,
|
conn,
|
||||||
import_id: int,
|
import_id: int,
|
||||||
@@ -416,11 +446,14 @@ def _web_compute_preview(
|
|||||||
except Exception:
|
except Exception:
|
||||||
conn.execute("ROLLBACK")
|
conn.execute("ROLLBACK")
|
||||||
|
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
return {
|
return {
|
||||||
"rows": preview_rows,
|
"rows": preview_rows,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"total": len(preview_rows),
|
"total": len(preview_rows),
|
||||||
"filename": batch["filename"],
|
"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."
|
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)
|
# Salveaza maparea (upsert)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -650,6 +687,49 @@ def web_preview_import(
|
|||||||
conn.close()
|
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)
|
@router.get("/_import/reset", response_class=HTMLResponse)
|
||||||
def web_import_reset(request: Request) -> HTMLResponse:
|
def web_import_reset(request: Request) -> HTMLResponse:
|
||||||
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
||||||
@@ -834,10 +914,12 @@ async def web_confirma_import(
|
|||||||
mapped["data_prestatie"] = iso_date
|
mapped["data_prestatie"] = iso_date
|
||||||
break
|
break
|
||||||
|
|
||||||
# Operatia → prestatii
|
# Operatia → prestatii (denumire_op alimenteaza denumirea reala)
|
||||||
operatie_val = mapped.pop("operatie", None)
|
operatie_val = mapped.pop("operatie", None)
|
||||||
|
denumire_val = mapped.pop("denumire_op", None)
|
||||||
if operatie_val and "prestatii" not in mapped:
|
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
|
# Rezolva prestatii
|
||||||
prestatii = mapped.get("prestatii") or []
|
prestatii = mapped.get("prestatii") or []
|
||||||
|
|||||||
@@ -52,6 +52,59 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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) -->
|
<!-- Tabel preview + bara confirmare (un singur form) -->
|
||||||
<form id="confirm-form"
|
<form id="confirm-form"
|
||||||
hx-post="/_import/{{ import_id }}/confirma"
|
hx-post="/_import/{{ import_id }}/confirma"
|
||||||
|
|||||||
@@ -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.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.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.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)
|
### Etapa 4 — Viitor (Treapta 3)
|
||||||
|
|
||||||
|
|||||||
273
docs/prd/prd-3.4-ux-dashboard-web.md
Normal file
273
docs/prd/prd-3.4-ux-dashboard-web.md
Normal 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
6
tests/fixtures/test_data.csv
vendored
Normal 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
|
||||||
|
6
tests/fixtures/test_data_mapping.csv
vendored
Normal file
6
tests/fixtures/test_data_mapping.csv
vendored
Normal 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
|
||||||
|
248
tests/test_import_mapare_operatie.py
Normal file
248
tests/test_import_mapare_operatie.py
Normal 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
|
||||||
Reference in New Issue
Block a user