Compare commits
7 Commits
d3ebf4762d
...
367739f5bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
367739f5bf | ||
|
|
5afd79634f | ||
|
|
4a39083fb7 | ||
|
|
a881b824bf | ||
|
|
aac9971f2b | ||
|
|
e11a5efa0f | ||
|
|
ab27be1e46 |
19
CLAUDE.md
19
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;">
|
||||
<div>
|
||||
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>.
|
||||
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>
|
||||
|
||||
@@ -62,6 +62,22 @@
|
||||
<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') %}
|
||||
<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);"
|
||||
@@ -70,6 +86,7 @@
|
||||
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;">
|
||||
<div>
|
||||
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>.
|
||||
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>
|
||||
|
||||
@@ -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) #}
|
||||
|
||||
@@ -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,
|
||||
|
||||
221
tests/test_web_preview_paritate_mapari.py
Normal file
221
tests/test_web_preview_paritate_mapari.py
Normal 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}
|
||||
Reference in New Issue
Block a user