Compare commits

..

7 Commits

Author SHA1 Message Date
Claude Agent
367739f5bf merge: confirmare valori needs_review bulk+rapid (PR3 / Issue B) 2026-07-03 13:26:57 +00:00
Claude Agent
5afd79634f merge: mesaj onest mediu RAR instanta (PR2 / Issue A) 2026-07-03 13:26:52 +00:00
Claude Agent
4a39083fb7 merge: paritate editor mapare import-preview (PR1) 2026-07-03 13:26:48 +00:00
Claude Agent
a881b824bf feat(import): confirmare valori needs_review in bloc + rapid din tabel (B1)
Randurile needs_review (flaguri de coercion: VIN citit ca numar, odometru
float, data ambigua) cereau confirmare umana DOAR prin modalul de editare,
un rand pe rand. Adauga doua cai care pastreaza omul in bucla (fara
auto-accept):

- Buton "Confirma toate valorile" (bulk): ruta noua
  POST /_import/{id}/confirma-toate-review marcheaza reviewed=1 pe TOATE
  randurile needs_review din batch cu un click. Scoped pe cont (404
  cross-account, 409 batch comis), o singura recompute + re-randare
  #import-section, dupa modelul web_mapare_operatii.
- Confirm rapid per-rand direct din tabel: buton in coloana Actiuni pe
  randurile needs_review, refoloseste ruta existenta /confirma-review
  (reviewed=1 pe un singur rand) cu hx-swap="none"; HX-Trigger
  reincarcaPreview reincarca sectiunea cu contoare/banner corecte.

Butonul bulk e randat in bannerul de discoverability (si in varianta OOB
din _preview_rand.html). Editarea unei valori reseteaza reviewed=0 ca
inainte (D#9, neschimbat).

Teste noi (tests/test_import_review.py): bulk marcheaza toate randurile,
guard committed 409, scoping 404 cross-account (fara efect pe randurile
altui cont), prezenta butonului rapid in tabel, confirm rapid per-rand
seteaza reviewed=1 fara a atinge alte randuri.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:25:48 +00:00
Claude Agent
aac9971f2b fix(web): mesaj onest despre mediul RAR al instantei (Issue A / A1)
Textul din bannerul de import (0 medii) si din antetul formularului de
credentiale nu spunea concret ce mediu foloseste instanta curenta. Vechiul
"Trimiterea va folosi configuratia globala" era jargon, iar "Pentru a activa
Testare sau Productie" nu clarifica relatia instanta<->mediu.

- Adauga globalul Jinja `mediu_instanta()` = eticheta umana a ancorei globale
  AUTOPASS_RAR_ENV (Testare/Productie), fallback sigur pe Testare.
- `_upload.html`: bannerul de 0 medii numeste concret mediul global al instantei
  pe care cad trimiterile pana la activarea unui mediu.
- `_cont.html`: nota onesta sub antetul "Credentiale RAR" — instanta ruleaza pe
  mediul global X, ambele medii se pot configura aici (fiecare validat separat),
  iar la 0 medii active trimiterile cad pe mediul global al instantei.

Fara selector nou si fara schimbari in logica de scriere a credentialelor
(A1, aliniat PRD 5.20: instanta = ancora de fallback pentru env).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:24:33 +00:00
Claude Agent
e11a5efa0f feat(web): paritate editor mapare import-preview cu pagina /mapari
Panoul inline "Operatii de mapat la cod RAR" din preview-ul de import folosea
doar sugestii fuzzy si nu arata sursa sugestiei. Acum are paritate 1:1 cu pagina
/mapari: aceeasi sugestie_principala (GOLD partajat > SILVER > embeddings k-NN)
si acelasi badge sursa (confirmat / similar / non-operatie).

- _collect_unmapped_ops primeste `conn`: ataseaza sugestie_principala +
  surse_sugestie via enrich_suggestions, cu ensure_embeddings_corpus o data
  inainte de bucla (replica pattern-ul din pending_unmapped). Init default pe
  fiecare entry (inclusiv conn=None) -> contract template identic.
  SUGGESTION-ONLY: nu atinge resolve_prestatii/load_mapping (#13).
- _web_compute_preview paseaza conn=conn la _collect_unmapped_ops.
- _preview_import.html: preselect din sugestie_principala > fuzzy>=60 + badge
  sursa (clase .sugg-sursa--{confirmat,similar,nul} deja existente in base.html).
- Test de paritate TARE: seed embeddings + GOLD/SILVER/NUL, batch import cu
  needs_mapping, verifica _web_compute_preview()["unmapped_ops"] ==
  pending_unmapped(conn, account) pe sugestie_principala + surse_sugestie, cate
  un caz per sursa (gold/silver/embedding/nul).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:23:28 +00:00
Claude Agent
ab27be1e46 chore: adauga reguli de routing gstack in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:11:56 +00:00
8 changed files with 644 additions and 23 deletions

View File

@@ -83,3 +83,22 @@ Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → en
## Mod non-interactiv
Vezi `/workspace/CLAUDE.md` (workspace-level): cand esti lansat cu `claude -p`, creeaza fisiere noi DOAR in `/workspace/.claude-work/<task>/`, nu in `/workspace`. Modificarile la fisiere existente se fac in locatia originala.
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec

View File

@@ -115,6 +115,21 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
templates.env.globals["parse_erori"] = parse_erori
templates.env.globals["eticheta_env"] = eticheta_env
def _mediu_instanta() -> str:
"""Eticheta umana a mediului GLOBAL al instantei care ruleaza (AUTOPASS_RAR_ENV).
E ancora de fallback (PRD 5.20): cand un cont nu are niciun mediu RAR configurat,
trimiterile cad pe acest mediu global. Expusa in template-uri ca mesaj onest despre
ce mediu foloseste instanta curenta. Nu arunca niciodata.
"""
env = get_settings().rar_env
return "Productie" if env == "prod" else "Testare"
# Expune mediul global al instantei (ancora fallback PRD 5.20) pentru mesaje oneste.
templates.env.globals["mediu_instanta"] = _mediu_instanta
_BLOCKED = ("error", "needs_data", "needs_mapping")
@@ -2694,12 +2709,21 @@ def post_sterge_format_coloane(
# 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.
def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict], conn=None) -> list[dict]:
"""Operatii distincte nemapate dintr-un preview de import (staging), cu sugestii fuzzy + enriched.
Echivalentul lui pending_unmapped() dar pe randuri de PREVIEW (import in staging,
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.
L14-S6 / paritate editor: cand `conn` e dat, ataseaza `sugestie_principala`
(GOLD partajat > SILVER > embeddings k-NN) si `surse_sugestie` din `enrich_suggestions`,
exact ca `pending_unmapped` — asa panoul inline al preview-ului are aceeasi sugestie
principala + badge sursa ca pagina /mapari. SUGGESTION-ONLY: nu atinge
`resolve_prestatii`/`load_mapping` (#13). Degradare gratioasa pe embeddings (#16b).
`sugestie_principala`/`surse_sugestie` se initializeaza pe FIECARE entry (chiar cand
conn=None) ca contractul catre template sa fie identic indiferent de call-site.
"""
agg: dict[str, dict[str, Any]] = {}
for row in preview_rows:
@@ -2715,9 +2739,22 @@ def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict]) ->
if not entry["denumire"] and item.get("denumire"):
entry["denumire"] = item.get("denumire")
entry["blocked"] += 1
# Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off).
if conn is not None:
ensure_embeddings_corpus(conn, nomenclator)
out: list[dict] = []
for entry in agg.values():
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
# Init default pe FIECARE entry -> contract template identic (conn=None inclus).
entry["sugestie_principala"] = None
entry["surse_sugestie"] = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None:
enriched = enrich_suggestions(conn, entry["denumire"])
entry["sugestie_principala"] = enriched["sugestie_principala"]
entry["surse_sugestie"] = enriched["surse"]
out.append(entry)
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
return out
@@ -2940,7 +2977,7 @@ def _web_compute_preview(
"summary": summary,
"total": len(preview_rows),
"filename": batch["filename"],
"unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator),
"unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator, conn=conn),
"nomenclator": nomenclator,
}
@@ -3611,6 +3648,63 @@ async def web_confirma_review(
conn.close()
@router.post("/_import/{import_id}/confirma-toate-review", response_class=HTMLResponse)
async def web_confirma_toate_review(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Confirma in bloc TOATE randurile needs_review din batch → reviewed=1 (B1).
Un singur click marcheaza reviewed=1 pe toate randurile cu resolved_status='needs_review'
din batch-ul curent (om in bucla: operatorul confirma explicit intreg lotul, fara
auto-accept). Refoloseste EXACT logica din /confirma-review (reviewed=1 = marcaj separat,
NU camp de continut), aplicata in masa. La recalcul (_web_compute_preview) randurile cu
reviewed=1 si fara erori reale devin ok.
CSRF + scoped sesiune (404 cross-account) + guard committed (409). O singura recompute
+ re-randare #import-section la final (identic cu web_mapare_operatii).
"""
account_id = require_login(request)
conn = get_connection()
try:
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Guard batch: 404 cross-account (nu confirmam existenta), 409 committed.
acct = account_or_default(account_id)
batch = conn.execute(
"SELECT id, status FROM import_batches WHERE id=? AND account_id=?",
(import_id, acct),
).fetchone()
if not batch:
raise HTTPException(status_code=404, detail="batch de import inexistent sau inaccesibil")
if batch["status"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis; confirmarea nu mai are efect")
# Marcheaza reviewed=1 pe toate randurile needs_review ale batch-ului (scoped pe batch,
# deja verificat ca apartine contului). Acelasi marcaj ca /confirma-review, in masa.
cur = conn.execute(
"UPDATE import_rows SET reviewed=1 "
"WHERE batch_id=? AND resolved_status='needs_review'",
(import_id,),
)
n_confirmate = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else 0
message = (
f"Confirmate {n_confirmate} randuri cu valori de verificat."
if n_confirmate
else "Niciun rand de confirmat."
)
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, **result
))
finally:
conn.close()
@router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse)
async def web_mapare_operatie(
request: Request,

View File

@@ -116,6 +116,13 @@
<div>
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 12px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
<p class="muted" style="font-size:12px; margin:0 0 12px; line-height:1.5;">
Aceasta instanta ruleaza pe mediul global <strong>{{ mediu_instanta() }}</strong>.
Poti configura mai jos ambele medii — Testare si Productie — fiecare validat separat
la salvare pe sistemul RAR corespunzator. Cand un cont nu are niciun mediu activ,
trimiterile cad pe mediul global al instantei ({{ mediu_instanta() }}).
</p>
{% if creds_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
{% endif %}

View File

@@ -85,7 +85,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{% 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 '' -%}
{# L14-S6: pre-selectare din sugestie_principala (GOLD/SILVER/embedding) > fuzzy>=60 #}
{%- set preselect = e.sugestie_principala.cod_prestatie if e.sugestie_principala else (top.cod_prestatie if (top and top.score >= 60) else '') -%}
<div class="maprow" style="align-items:flex-end; margin-bottom:10px;">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
@@ -94,9 +95,20 @@
{% if e.denumire and e.denumire != e.cod_op_service %}
<div class="muted">{{ e.denumire }}</div>
{% endif %}
{% if e.suggestions %}
{% if e.suggestions or e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
{% if e.sugestie_principala %}
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
<span class="sugg-sursa sugg-sursa--confirmat" title="cod confirmat de un operator">confirmat</span>
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
{% endif %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
@@ -132,9 +144,24 @@
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
<div>
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana confirmi valorile. Verifica-le (butonul <strong>Confirma valorile</strong>
de pe rand sau in modal) sau, daca lotul e in regula, confirma-le pe toate deodata.
</div>
{# B1: buton bulk — un click marcheaza reviewed=1 pe toate randurile needs_review.
hx-swap="none": raspunsul re-randeaza #import-section prin outerHTML pe tinta. #}
<button type="button"
hx-post="/_import/{{ import_id }}/confirma-toate-review"
hx-vals='{"csrf_token": "{{ csrf_token or '' }}"}'
hx-target="#import-section"
hx-swap="outerHTML"
hx-confirm="Confirmi valorile pentru toate cele {{ summary.get('needs_review', 0) }} randuri de verificat? Devin gata de trimis la RAR."
hx-disabled-elt="this"
style="margin-top:10px; min-height:40px; padding:8px 18px;
background:var(--ok, #2a7); color:#fff; border-color:transparent; font-size:13px;">
Confirma toate valorile ({{ summary.get('needs_review', 0) }})
</button>
</div>
{% endif %}
</div>

View File

@@ -62,14 +62,31 @@
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza"
style="min-height:36px; padding:6px 14px; font-size:13px;
background:transparent; border-color:var(--line); color:var(--ink);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
Editeaza
</button>
<div style="display:inline-flex; gap:6px; flex-wrap:wrap; justify-content:center;">
{# B1: confirm rapid per-rand direct din tabel (fara a deschide modalul).
Refoloseste ruta /confirma-review (reviewed=1 pe un singur rand). hx-swap="none":
raspunsul (empty div) NU se insereaza nicaieri; HX-Trigger reincarcaPreview
reincarca sectiunea (contoare + banner corecte). Editarea reseteaza reviewed=0. #}
{% if status == 'needs_review' %}
<button type="button" class="btn-confirma-rapid"
style="min-height:36px; padding:6px 14px; font-size:13px;
background:var(--ok, #2a7); color:#fff; border-color:transparent;"
hx-post="/_import/{{ import_id }}/rand/{{ row.row_index }}/confirma-review"
hx-vals='{"csrf_token": "{{ csrf_token or '' }}"}'
hx-swap="none" hx-disabled-elt="this"
aria-label="Confirma valorile randului {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
Confirma valorile
</button>
{% endif %}
<button type="button" class="btn-editeaza"
style="min-height:36px; padding:6px 14px; font-size:13px;
background:transparent; border-color:var(--line); color:var(--ink);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
Editeaza
</button>
</div>
{% endif %}
</td>
</tr>
@@ -93,9 +110,22 @@
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
<div>
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana confirmi valorile. Verifica-le (butonul <strong>Confirma valorile</strong>
de pe rand sau in modal) sau, daca lotul e in regula, confirma-le pe toate deodata.
</div>
<button type="button"
hx-post="/_import/{{ import_id }}/confirma-toate-review"
hx-vals='{"csrf_token": "{{ csrf_token or '' }}"}'
hx-target="#import-section"
hx-swap="outerHTML"
hx-confirm="Confirmi valorile pentru toate cele {{ summary.get('needs_review', 0) }} randuri de verificat? Devin gata de trimis la RAR."
hx-disabled-elt="this"
style="margin-top:10px; min-height:40px; padding:8px 18px;
background:var(--ok, #2a7); color:#fff; border-color:transparent; font-size:13px;">
Confirma toate valorile ({{ summary.get('needs_review', 0) }})
</button>
</div>
{% endif %}
</div>

View File

@@ -39,9 +39,10 @@
<div style="margin-bottom:10px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;" role="note">
<strong>Niciun mediu RAR configurat.</strong>
Trimiterea va folosi configuratia globala. Pentru a activa Testare sau Productie,
<a href="?tab=cont" style="color:var(--accent);">configureaza credentialele RAR</a>.
<strong>Niciun mediu RAR configurat pentru acest cont.</strong>
Pana activezi un mediu, trimiterile din aceasta instanta folosesc mediul global
<strong>{{ mediu_instanta() }}</strong>. Configureaza Testare si/sau Productie in
<a href="?tab=cont" style="color:var(--accent);">tab-ul Cont</a>.
</div>
{% elif medii_rar | length == 1 %}
{# Eticheta statica (un singur mediu disponibil) #}

View File

@@ -583,6 +583,228 @@ def test_confirma_review_form_nu_foloseste_hx_swap_none():
)
def _upload_and_preview_rows(client: TestClient, rows: list[dict]) -> int:
"""Upload CSV cu `rows` + salveaza mapare fara format_data -> preview.
Generalizarea lui `_upload_and_preview_needs_review` pentru mai multe randuri
(fiecare cu data ambigua -> needs_review). Intoarce import_id.
"""
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "", # fara format -> ambiguous -> needs_review
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
# Trei randuri, VIN-uri distincte (nu se dedupe ca duplicat), toate cu data ambigua.
_ROWS_MULTI_NEEDS_REVIEW = [
{"VIN": "WVWZZZ1KZAW000123", "Nr": "B001TST", "Data": "05.06.2026", "KM": "123456", "Operatie": "OP-1"},
{"VIN": "WVWZZZ1KZAW000456", "Nr": "B002TST", "Data": "07.08.2026", "KM": "223456", "Operatie": "OP-1"},
{"VIN": "WVWZZZ1KZAW000789", "Nr": "B003TST", "Data": "09.10.2026", "KM": "323456", "Operatie": "OP-1"},
]
def test_confirma_toate_review_marcheaza_toate(client):
"""B1 bulk: POST /_import/{id}/confirma-toate-review seteaza reviewed=1 pe TOATE
randurile needs_review din batch cu un singur click.
Verifica:
- Raspuns 200 cu preview re-randat (fragment #import-section)
- reviewed=1 in DB pentru toate cele 3 randuri
- dupa recalcul, randurile nu mai sunt needs_review (devin ok -> gata de trimis)
"""
_seed_op1()
iid = _upload_and_preview_rows(client, _ROWS_MULTI_NEEDS_REVIEW)
# Toate randurile pornesc needs_review (reviewed=0)
for i in range(3):
assert _get_reviewed(iid, i) == 0, f"randul {i} trebuie sa fie reviewed=0 initial"
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/confirma-toate-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
# Raspunsul re-randeaza sectiunea de preview
assert "import-section" in r.text, "bulk trebuie sa re-randeze #import-section"
# Toate randurile confirmate in DB
for i in range(3):
assert _get_reviewed(iid, i) == 1, \
f"randul {i} trebuie sa fie reviewed=1 dupa confirmarea in bloc"
# Recalcul: niciun rand nu mai e needs_review (toate ok)
r2 = client.get(f"/_import/{iid}/preview")
assert r2.status_code == 200, r2.text
assert 'data-status="needs_review"' not in r2.text, \
"dupa confirmarea in bloc, niciun rand nu mai trebuie sa fie needs_review"
def test_confirma_toate_review_guard_committed_409(client):
"""POST confirma-toate-review pe batch deja comis -> 409."""
_seed_op1()
iid = _upload_and_preview_rows(client, _ROWS_MULTI_NEEDS_REVIEW)
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,))
conn.commit()
finally:
conn.close()
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/confirma-toate-review", data={"csrf_token": csrf})
assert r.status_code == 409, \
f"confirma-toate-review pe batch committed trebuie sa returneze 409, got {r.status_code}"
def test_confirma_toate_review_scoped_404_alt_cont():
"""POST confirma-toate-review pe batch-ul altui cont -> 404, iar randurile
contului A raman needs_review (reviewed=0): bulk-ul altui cont NU le atinge."""
tmp = tempfile.mkdtemp()
env_patch = {
"AUTOPASS_DB_PATH": os.path.join(tmp, "scope_bulk.db"),
"AUTOPASS_WEB_AUTH_REQUIRED": "true",
}
for k, v in env_patch.items():
os.environ[k] = v
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
try:
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
conn = get_connection()
try:
acct1 = create_account(conn, "Firma A", active=True)
create_user(conn, acct1, "userA@test.com", "parola123secure")
acct2 = create_account(conn, "Firma B", active=True)
create_user(conn, acct2, "userB@test.com", "parola123secure")
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('R-FRANE','Reparatie frane')"
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)", (acct1,)
)
conn.commit()
finally:
conn.close()
def _login(client, email, pwd="parola123secure"):
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
client.post("/login", data={"email": email, "parola": pwd, "csrf_token": m.group(1)})
def _csrf():
r = c.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
# userA creeaza batch cu randuri needs_review
_login(c, "userA@test.com")
rows = _ROWS_MULTI_NEEDS_REVIEW
csrf = _csrf()
r = c.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(_csv_bytes(rows)), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _csrf()
c.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames, "canon": canons, "format_data": "", "csrf_token": csrf2,
})
# userB incearca bulk pe batch-ul lui A -> 404
_login(c, "userB@test.com")
csrf3 = _csrf()
r2 = c.post(f"/_import/{iid}/confirma-toate-review", data={"csrf_token": csrf3})
assert r2.status_code == 404, \
f"confirma-toate-review cross-account trebuie sa returneze 404, got {r2.status_code}"
# Randurile lui A raman needs_review (reviewed=0): nu au fost atinse.
conn = get_connection()
try:
n = conn.execute(
"SELECT COUNT(*) AS c FROM import_rows WHERE batch_id=? AND reviewed=1",
(iid,),
).fetchone()["c"]
assert n == 0, "bulk-ul altui cont NU trebuie sa marcheze reviewed pe randurile lui A"
finally:
conn.close()
finally:
for k in env_patch:
if k in os.environ:
del os.environ[k]
ratelimit._hits.clear()
get_settings.cache_clear()
reset_cache()
def test_confirm_rapid_buton_prezent_in_tabel(client):
"""B1 quick: randul needs_review are butonul de confirm rapid direct in tabel,
care POST-eaza pe ruta /confirma-review (fara a deschide modalul)."""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
assert "btn-confirma-rapid" in html, \
"randul needs_review trebuie sa aiba butonul de confirm rapid in tabel"
assert f"/_import/{iid}/rand/0/confirma-review" in html, \
"butonul de confirm rapid trebuie sa POST-eze pe ruta /confirma-review"
def test_confirm_rapid_per_rand_seteaza_reviewed(client):
"""B1 quick: POST /confirma-review din tabel (fara modal) seteaza reviewed=1 pe un
singur rand si NU atinge celelalte randuri needs_review din batch."""
_seed_op1()
iid = _upload_and_preview_rows(client, _ROWS_MULTI_NEEDS_REVIEW)
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/1/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
assert _get_reviewed(iid, 1) == 1, "randul confirmat rapid trebuie sa fie reviewed=1"
assert _get_reviewed(iid, 0) == 0, "confirmul per-rand NU trebuie sa atinga alte randuri"
assert _get_reviewed(iid, 2) == 0, "confirmul per-rand NU trebuie sa atinga alte randuri"
def test_confirma_review_cere_reincarcarea_preview(client):
"""Contractul nou (dogfood 5.13): confirma-review NU mai depinde de scriptul updateN
din payload (care, cu OOB pe <tr> rupt, lasa randul stale). Acum cere reincarcaPreview,

View File

@@ -0,0 +1,221 @@
"""Paritate editor mapare: panoul inline din preview-ul de import == pagina /mapari.
Panoul "Operatii de mapat la cod RAR" din preview (`_collect_unmapped_ops` via
`_web_compute_preview`) trebuie sa produca ACEEASI `sugestie_principala` +
`surse_sugestie` ca `pending_unmapped` (functia care randeaza /mapari), pentru
aceeasi denumire de operatie. Fara asta, cele doua editoare diverg (preview arata
doar fuzzy, /mapari arata GOLD partajat > SILVER > embeddings k-NN + badge sursa).
Test TARE (Eng finding F6): NU reproba doar determinismul lui enrich (a chema enrich
de doua ori), ci probeaza WIRING-ul real — `conn` pasat din `_web_compute_preview`,
corpusul indexat o data, campurile atasate — construind un batch de import cu randuri
needs_mapping si comparand rezultatul cu `pending_unmapped`, cate un caz per sursa
(gold / silver / embedding / nul).
Suggestion-only (#13): enrichment NU intra in resolve_prestatii/load_mapping.
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Cazuri: cate o operatie per sursa de sugestie. #
# (op, denumire, cod_asteptat, sursa_asteptata) #
# --------------------------------------------------------------------------- #
_CAZ_GOLD = ("OP-GOLD", "Revizie gold speciala", "OE-1", "gold_partajat")
_CAZ_SILVER = ("OP-SILVER", "Reparatie motor silver", "OE-2", "silver")
_CAZ_EMB = ("OP-EMB", "Diagnoza semantica embedding", "OE-3", "embedding")
_CAZ_NUL = ("OP-ITP", "ITP CT 99 XYZ", None, None) # pre-filtru NUL -> fara cod
_CAZURI = [_CAZ_GOLD, _CAZ_SILVER, _CAZ_EMB, _CAZ_NUL]
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "paritate_preview.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
# Embeddings ON ca sursa "embedding" sa fie exercitata; modulul e mock-uit mai jos
# (fara lazy-load al modelului ~230MB).
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
reset_cache()
@pytest.fixture()
def mock_emb(monkeypatch):
"""Mock modulul embeddings: has_corpus True, suggest_nearest da OE-3 doar pt textul
care contine EMBEDDING, index_corpus/corpus_signature inofensive (fara model real)."""
import app.embeddings as emb
def _suggest(text, top_k=1):
# text = denumire NORMALIZATA (upper, fara diacritice) — enrich normalizeaza.
if "EMBEDDING" in (text or ""):
return [{"cod": "OE-3", "is_nul": False, "similaritate": 0.99}]
return []
monkeypatch.setattr(emb, "has_corpus", lambda: True)
monkeypatch.setattr(emb, "suggest_nearest", _suggest)
monkeypatch.setattr(emb, "corpus_signature", lambda: "")
monkeypatch.setattr(emb, "index_corpus", lambda items, signature=None: None)
return emb
@pytest.fixture()
def client(env):
from app.main import app
with TestClient(app) as c:
yield c
def _csv_bytes(rows: list[dict]) -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_surse(conn):
"""Semeaza nomenclator + GOLD + SILVER pentru cazurile de test."""
from app.shared_store import record_human_validation, seed_suggestions
conn.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-1", "REVIZIE"), ("OE-2", "REPARATIE MOTOR"), ("OE-3", "DIAGNOZA")],
)
# GOLD partajat (shared_mappings) — NU intra in operations_mapping, deci op ramane needs_mapping.
record_human_validation(conn, _CAZ_GOLD[1], _CAZ_GOLD[2])
# SILVER (mapping_suggestions).
seed_suggestions(conn, [
{"denumire": _CAZ_SILVER[1], "cod_prestatie": _CAZ_SILVER[2], "source": "llm", "confidence": 0.9},
])
conn.commit()
def _insert_submissions_needs_mapping(conn):
"""Insereaza cate un submission needs_mapping per caz, cu ACEEASI (op, denumire)
ca randurile de import — ca `pending_unmapped` sa vada aceleasi operatii."""
for i, (op, den, _cod, _sursa) in enumerate(_CAZURI):
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, ?)",
(
json.dumps({
"vin": f"WVWZZZ1KZAW00{i:04d}",
"prestatii": [{"cod_op_service": op, "denumire": den}],
}),
f"paritate-sub-{i}",
),
)
conn.commit()
def _upload_batch(client: TestClient) -> int:
"""Upload CSV cu cele 4 operatii nemapate + salveaza maparea de coloane. -> import_id."""
rows = [
{
"VIN": f"WVWZZZ1KZAW01{i:04d}",
"Nr": f"B{i:03d}TST",
"Data": "2026-06-15",
"KM": str(100000 + i),
"Operatie": op,
"Denumire": den,
}
for i, (op, den, _c, _s) in enumerate(_CAZURI)
]
data = _csv_bytes(rows)
r = client.post("/_import/upload", files={"file": ("t.csv", io.BytesIO(data), "text/csv")})
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/", r.text)
assert m, r.text[:400]
iid = int(m.group(1))
if f"/_import/{iid}/mapare-coloane" in r.text:
r2 = client.post(
f"/_import/{iid}/mapare-coloane",
data={
"colname": ["VIN", "Nr", "Data", "KM", "Operatie", "Denumire"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final",
"operatie", "denumire_op"],
"format_data": "YYYY-MM-DD",
},
)
assert r2.status_code == 200, r2.text
return iid
def test_preview_unmapped_ops_paritate_cu_pending_unmapped(client, mock_emb):
"""`_web_compute_preview(...)["unmapped_ops"]` == `pending_unmapped(conn, account)`
pe `sugestie_principala` + `surse_sugestie`, per sursa (gold/silver/embedding/nul)."""
from app.db import get_connection
from app.mapping import pending_unmapped
from app.web.routes import _web_compute_preview
conn = get_connection()
try:
_seed_surse(conn)
iid = _upload_batch(client)
_insert_submissions_needs_mapping(conn)
# Calea PREVIEW (panou inline import) — foloseste conn (wiring nou).
preview = _web_compute_preview(conn, iid, 1)
assert isinstance(preview, dict), preview
preview_ops = {e["cod_op_service"]: e for e in preview["unmapped_ops"]}
# Calea /mapari (functia canonica de randare a editorului).
pending_ops = {e["cod_op_service"]: e for e in pending_unmapped(conn, 1)}
# Ambele cai trebuie sa vada exact aceleasi operatii.
assert set(preview_ops) == set(pending_ops) == {c[0] for c in _CAZURI}
# Paritate 1:1 pe sugestia principala + sursele, per operatie.
for op in preview_ops:
assert preview_ops[op]["sugestie_principala"] == pending_ops[op]["sugestie_principala"], op
assert preview_ops[op]["surse_sugestie"] == pending_ops[op]["surse_sugestie"], op
# Corectitudine per sursa (nu doar egalitate reciproca): fiecare caz da ce trebuie.
for op, _den, cod, sursa in _CAZURI:
sp = preview_ops[op]["sugestie_principala"]
surse = preview_ops[op]["surse_sugestie"]
if sursa is None:
# NUL: fara cod, badge non-operatie.
assert sp is None, op
assert surse["nul"] is True, op
else:
assert sp == {"cod_prestatie": cod, "sursa": sursa}, op
assert surse[sursa] == cod, op
finally:
conn.close()
def test_collect_unmapped_ops_conn_none_contract_template(env):
"""Fara conn, `_collect_unmapped_ops` init-eaza totusi `sugestie_principala`=None +
`surse_sugestie` default -> contractul catre template ramane identic (fara KeyError)."""
from app.web.routes import _collect_unmapped_ops
preview_rows = [{
"resolved_status": "needs_mapping",
"resolved": {"prestatii": [{"cod_op_service": "OP-X", "denumire": "Ceva"}]},
}]
out = _collect_unmapped_ops(preview_rows, [], conn=None)
assert len(out) == 1
e = out[0]
assert e["sugestie_principala"] is None
assert e["surse_sugestie"] == {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}