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 %} +
+ 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 '' -%} + + {% endfor %} +