diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index 59e0e76..e4182da 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -63,7 +63,8 @@ _CANONICAL_SYNONYMS: dict[str, list[str]] = { "data_prestatie": ["Data prestatie", "Data", "Date", "Data service", "Data lucrare"], "odometru_final": ["Odometru final", "Odometru", "KM", "Kilometri", "Km final", "Citire contor"], "odometru_initial": ["Odometru initial", "KM initial", "Km start"], - "operatie": ["Operatie", "Denumire prestatie", "Prestatie", "Lucrare", "Tip lucrare", "Cod prestatie", "Cod op"], + "operatie": ["Operatie", "Cod prestatie", "Prestatie", "Lucrare", "Tip lucrare", "Cod op"], + "denumire_op": ["Denumire operatie", "Denumire", "Descriere", "Denumire prestatie", "Nume operatie"], "obs": ["Observatii", "Obs", "Mentiuni", "Note"], } @@ -163,11 +164,15 @@ def _resolve_row_for_preview( if is_amb: is_ambiguous_date = True - # Operatia: daca camp canonic e "operatie", construieste prestatii + # Operatia: daca camp canonic e "operatie", construieste prestatii. + # denumire_op (coloana descriptiva, ex. "Reparatie Motor") alimenteaza + # `denumire` -> sugestia fuzzy din editorul de mapari devine utila; fara ea, + # denumire = codul opac (ex. "OP-MOTOR") si fuzzy nu are pe ce sa lucreze. operatie_val = mapped.pop("operatie", None) + denumire_val = mapped.pop("denumire_op", None) if operatie_val and "prestatii" not in mapped: - # Construieste un item de prestatie din operatie - mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}] + denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) + mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] # Canonicalizare (T9): normalizeaza VIN/nr/odometru canon = canonicalize_row(mapped) @@ -528,8 +533,22 @@ def save_column_mapping( if not batch: raise HTTPException(status_code=404, detail="batch de import inexistent") - # Recalculeaza semnatura din coloanele fisierului (cheile maparii) + # Semnatura = antetul COMPLET al fisierului (toate coloanele din batch), nu + # doar campurile mapate. Altfel, daca clientul ignora o coloana, semnatura + # difera de cea calculata la preview (col_names = antet complet) si maparea + # retinuta nu mai e gasita. Citim antetul din primul rand al batch-ului. + first_row = conn.execute( + "SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1", + (import_id,), + ).fetchone() columns = list(req.json_mapare.keys()) + if first_row: + try: + rd = decrypt_creds(first_row["raw_json"]) or {} + if rd: + columns = list(rd.keys()) + except Exception: + pass sig = _signature(columns) conn.execute( @@ -925,10 +944,12 @@ def commit_import( if iso_date: mapped["data_prestatie"] = iso_date - # Operatia -> prestatii + # Operatia -> prestatii (denumire_op alimenteaza denumirea reala) operatie_val = mapped.pop("operatie", None) + denumire_val = mapped.pop("denumire_op", None) if operatie_val and "prestatii" not in mapped: - mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}] + denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) + mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) prestatii = mapped.get("prestatii") or [] diff --git a/app/web/routes.py b/app/web/routes.py index dd04c2e..c4740c6 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -48,6 +48,7 @@ from ..mapping import ( reresolve_account, resolve_prestatii, save_mapping, + suggest_codes, ) # Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5) @@ -253,6 +254,35 @@ def post_mapare( # Toate rutele /_import/* returneaza fragmente HTML (target #import-section). # # =========================================================================== # +def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict]) -> list[dict]: + """Operatii distincte nemapate dintr-un preview de import (staging), cu sugestii fuzzy. + + Echivalentul lui pending_unmapped() dar pe randuri de PREVIEW (import in staging, + inca neexistente ca submissions). Aduna doar prestatiile fara cod_prestatie + (cele cu auto_send=0 au deja cod -> nu apar aici). Sortare: cele mai blocate intai. + """ + agg: dict[str, dict[str, Any]] = {} + for row in preview_rows: + if row.get("resolved_status") != "needs_mapping": + continue + for item in (row.get("resolved", {}).get("prestatii") or []): + if not isinstance(item, dict) or item.get("cod_prestatie"): + continue + op = (item.get("cod_op_service") or "").strip() + if not op: + continue + entry = agg.setdefault(op, {"cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0}) + if not entry["denumire"] and item.get("denumire"): + entry["denumire"] = item.get("denumire") + entry["blocked"] += 1 + out: list[dict] = [] + for entry in agg.values(): + entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5) + out.append(entry) + out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"])) + return out + + def _web_compute_preview( conn, import_id: int, @@ -416,11 +446,14 @@ def _web_compute_preview( except Exception: conn.execute("ROLLBACK") + nomenclator = load_nomenclator(conn) return { "rows": preview_rows, "summary": summary, "total": len(preview_rows), "filename": batch["filename"], + "unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator), + "nomenclator": nomenclator, } @@ -602,7 +635,11 @@ async def web_save_mapare_coloane( request, error="Batch de import inexistent sau expirat." )) - sig = _signature(list(json_mapare.keys())) + # Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele + # ignorate), nu doar campurile mapate. Altfel ignorarea unei coloane schimba + # semnatura si maparea nu mai e gasita la preview/re-upload (col_names = antet + # complet peste tot). `colnames` vine din form = toate coloanele randate. + sig = _signature(colnames or list(json_mapare.keys())) # Salveaza maparea (upsert) conn.execute( @@ -650,6 +687,49 @@ def web_preview_import( conn.close() +@router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse) +async def web_mapare_operatie( + request: Request, + import_id: int, +) -> HTMLResponse: + """Mapeaza o operatie nemapata din preview-ul de import la un cod RAR, in flux. + + Salveaza maparea (persistenta, operations_mapping) si re-randeaza preview-ul: + _web_compute_preview recalculeaza cu noua mapare si re-scrie resolved_status in + import_rows, deci randurile afectate trec din needs_mapping in ok fara re-upload. + """ + account_id = require_login(request) + conn = get_connection() + try: + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + cod_op_service = str(form.get("cod_op_service") or "").strip() + cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper() + auto_send = bool(form.get("auto_send")) + + def _render(message: str | None = None, error: bool = False) -> HTMLResponse: + result = _web_compute_preview(conn, import_id, account_id) + if isinstance(result, str): + return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, import_id=import_id, message=message, error=error, **result + )) + + if not cod_op_service or not cod_prestatie: + return _render("Alege un cod RAR pentru operatie.", error=True) + + exists = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,) + ).fetchone() + if not exists: + return _render(f"Cod RAR necunoscut: {cod_prestatie}", error=True) + + save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send) + return _render(f"Mapat {cod_op_service} -> {cod_prestatie}.") + finally: + conn.close() + + @router.get("/_import/reset", response_class=HTMLResponse) def web_import_reset(request: Request) -> HTMLResponse: """Reseteaza sectiunea de import la starea initiala (drop zone gol).""" @@ -834,10 +914,12 @@ async def web_confirma_import( mapped["data_prestatie"] = iso_date break - # Operatia → prestatii + # Operatia → prestatii (denumire_op alimenteaza denumirea reala) operatie_val = mapped.pop("operatie", None) + denumire_val = mapped.pop("denumire_op", None) if operatie_val and "prestatii" not in mapped: - mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": str(operatie_val)}] + denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) + mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] # Rezolva prestatii prestatii = mapped.get("prestatii") or [] diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index acfb247..e50e39c 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -52,6 +52,59 @@ {% endfor %} + + {% if unmapped_ops %} +
+

Operatii de mapat la cod RAR

+

+ Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e + preselectata) si salveaza — randurile blocate trec automat in + ok si maparea se retine pentru fisierele viitoare. +

+ {% 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 '' -%} +
+ + +
+
{{ e.cod_op_service }} + {{ e.blocked }} randuri
+ {% if e.denumire and e.denumire != e.cod_op_service %} +
{{ e.denumire }}
+ {% endif %} + {% if e.suggestions %} +
+ sugestii: + {% for s in e.suggestions[:3] %} + {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} +
+
+ +
+
+ +
+
+ +
+
+ {% endfor %} +
+ {% endif %} +
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