""" Invoice Error Finder API router. """ import json import logging from datetime import date from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from app.core.auth_dependencies import require_permission from app.core.database import execute_query, execute_query_single from app.modules.invoice_error_finder.services.economic_import_service import EconomicImportService from app.modules.invoice_error_finder.services.simply_import_service import SimplyImportService from app.modules.invoice_error_finder.services.detection_service import DetectionService logger = logging.getLogger(__name__) router = APIRouter() ALLOWED_ISSUE_STATUSES = { "open", "investigating", "approved_change", "error_found", "ready_to_invoice", "invoiced", "ignored", } ISSUE_STATUS_LABELS = { "open": "Åben", "investigating": "Under undersøgelse", "approved_change": "Godkendt ændring", "error_found": "Fejl fundet", "ready_to_invoice": "Klar til fakturering", "invoiced": "Faktureret", "ignored": "Ignoreret", } ISSUE_TYPE_LABELS = { "missing_line": "Manglende varelinje", "open_order_not_invoiced": "Åben salgsordre ikke faktureret", "quantity_drop": "Antalsfald", "price_change": "Prisændring", "new_item_never_invoiced": "Ny vare aldrig faktureret", } class IssueStatusUpdate(BaseModel): status: str notes: Optional[str] = None assigned_user_id: Optional[int] = None class CreateSagRequest(BaseModel): titel: str = Field(..., min_length=1) beskrivelse: Optional[str] = "" status: Optional[str] = "åben" class LinkSagRequest(BaseModel): sag_id: int class CreateOrdreDraftRequest(BaseModel): description: Optional[str] = None def _get_user_id(request: Request) -> Optional[int]: value = getattr(request.state, "user_id", None) if value is not None: try: return int(value) except (TypeError, ValueError): return None return None @router.post("/import/economic") async def import_economic( request: Request, current_user: dict = Depends(require_permission("invoice_error_finder.run_import")), ): """Trigger e-conomic invoice import.""" try: service = EconomicImportService() result = await service.import_invoices( triggered_by_user_id=_get_user_id(request), is_scheduled=False, ) return result except Exception as exc: logger.error("❌ Economic import endpoint failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/import/simply") async def import_simply( request: Request, current_user: dict = Depends(require_permission("invoice_error_finder.run_import")), ): """Trigger Simply CRM sales order import.""" try: service = SimplyImportService() result = await service.import_sales_orders( triggered_by_user_id=_get_user_id(request), is_scheduled=False, ) return result except Exception as exc: logger.error("❌ Simply import endpoint failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/analyze") async def analyze_issues( reference_month: Optional[str] = Query(None), current_user: dict = Depends(require_permission("invoice_error_finder.analyze")), ): """Run detection rules and create/update issues.""" try: ref_date = None if reference_month: ref_date = date.fromisoformat(reference_month + "-01") service = DetectionService() counts = service.analyze(reference_month=ref_date) return {"reference_month": (ref_date or date.today().replace(day=1)).isoformat(), "counts": counts} except Exception as exc: logger.error("❌ Analyze endpoint failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.get("/dashboard") async def get_dashboard( current_user: dict = Depends(require_permission("invoice_error_finder.view")), ) -> Dict[str, Any]: """Summary counts for the dashboard.""" try: status_counts = execute_query( """ SELECT issue_type, status, COUNT(*) AS count, COALESCE(SUM(amount_impact), 0) AS total_impact FROM invoice_error_finder_issues GROUP BY issue_type, status """, (), ) or [] summary = {} for row in status_counts: itype = row["issue_type"] status = row["status"] summary.setdefault(itype, {})[status] = { "count": row["count"], "total_impact": float(row["total_impact"] or 0), } def count_by_type(issue_type: str, statuses: List[str]) -> Dict[str, Any]: total = 0 impact = 0.0 for status in statuses: data = summary.get(issue_type, {}).get(status, {}) total += data.get("count", 0) impact += data.get("total_impact", 0.0) return {"count": total, "total_impact": impact} last_runs = execute_query( """ SELECT source_type, status, records_imported, records_failed, started_at FROM invoice_error_finder_import_runs ORDER BY started_at DESC LIMIT 5 """, (), ) or [] return { "missing_line": count_by_type("missing_line", ["open", "investigating"]), "open_order_not_invoiced": count_by_type("open_order_not_invoiced", ["open", "investigating"]), "quantity_drop": count_by_type("quantity_drop", ["open", "investigating"]), "price_change": count_by_type("price_change", ["open", "investigating"]), "ready_to_invoice": count_by_type("*", ["ready_to_invoice"]), "no_owner": count_by_type("*", ["open", "investigating", "ready_to_invoice"]) if False else { "count": execute_query_single( "SELECT COUNT(*) AS c FROM invoice_error_finder_issues WHERE status IN ('open','investigating','ready_to_invoice') AND assigned_user_id IS NULL" )["c"], "total_impact": 0.0, }, "last_import_runs": [dict(r) for r in last_runs], } except Exception as exc: logger.error("❌ Dashboard endpoint failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.get("/issues") async def list_issues( status: Optional[str] = Query(None), issue_type: Optional[str] = Query(None), customer_id: Optional[int] = Query(None), assigned_user_id: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), current_user: dict = Depends(require_permission("invoice_error_finder.view")), ): """List detected issues with optional filters.""" try: filters = ["1=1"] params: List[Any] = [] if status: filters.append("i.status = %s") params.append(status) if issue_type: filters.append("i.issue_type = %s") params.append(issue_type) if customer_id: filters.append("i.customer_id = %s") params.append(customer_id) if assigned_user_id is not None and assigned_user_id.strip() != "": try: assigned_user_id = int(assigned_user_id) except (TypeError, ValueError): assigned_user_id = None if assigned_user_id is not None: filters.append("i.assigned_user_id IS NOT DISTINCT FROM %s") params.append(assigned_user_id) where_clause = " AND ".join(filters) count_row = execute_query_single( f"SELECT COUNT(*) AS c FROM invoice_error_finder_issues i WHERE {where_clause}", tuple(params), ) total = count_row["c"] if count_row else 0 params.extend([limit, offset]) rows = execute_query( f""" SELECT i.*, COALESCE(u.full_name, u.username) AS assigned_user_name, sg.titel AS sag_title FROM invoice_error_finder_issues i LEFT JOIN users u ON u.user_id = i.assigned_user_id LEFT JOIN sag_sager sg ON sg.id = i.sag_id WHERE {where_clause} ORDER BY i.created_at DESC LIMIT %s OFFSET %s """, tuple(params), ) or [] return { "total": total, "limit": limit, "offset": offset, "items": [dict(r) for r in rows], } except Exception as exc: logger.error("❌ Issues list endpoint failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.get("/issues/{issue_id}") async def get_issue( issue_id: int, current_user: dict = Depends(require_permission("invoice_error_finder.view")), ): """Get a single issue.""" try: row = execute_query_single( """ SELECT i.*, COALESCE(u.full_name, u.username) AS assigned_user_name, sg.titel AS sag_title FROM invoice_error_finder_issues i LEFT JOIN users u ON u.user_id = i.assigned_user_id LEFT JOIN sag_sager sg ON sg.id = i.sag_id WHERE i.id = %s """, (issue_id,), ) if not row: raise HTTPException(status_code=404, detail="Issue not found") return dict(row) except HTTPException: raise except Exception as exc: logger.error("❌ Get issue endpoint failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.patch("/issues/{issue_id}/status") async def update_issue_status( issue_id: int, payload: IssueStatusUpdate, current_user: dict = Depends(require_permission("invoice_error_finder.update_status")), ): """Update issue status, notes and/or assignee.""" try: if payload.status not in ALLOWED_ISSUE_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") resolved_at = None if payload.status in {"invoiced", "ignored"}: resolved_at = "CURRENT_TIMESTAMP" extra_fields = [] extra_values: List[Any] = [] if payload.assigned_user_id is not None: extra_fields.append("assigned_user_id = %s") extra_values.append(payload.assigned_user_id) if payload.notes is not None: extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s") extra_values.append(payload.notes) resolved_sql = f"resolved_at = COALESCE(resolved_at, {resolved_at})" if resolved_at else "resolved_at = resolved_at" execute_query( f""" UPDATE invoice_error_finder_issues SET status = %s, updated_at = CURRENT_TIMESTAMP, {resolved_sql} {',' + ','.join(extra_fields) if extra_fields else ''} WHERE id = %s """, (payload.status, *extra_values, issue_id), ) return {"id": issue_id, "status": payload.status} except HTTPException: raise except Exception as exc: logger.error("❌ Update issue status failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/issues/{issue_id}/create-sag") async def create_sag_for_issue( issue_id: int, payload: CreateSagRequest, request: Request, current_user: dict = Depends(require_permission("invoice_error_finder.create_sag")), ): """Create a new sag/case from an issue and link it.""" try: issue = execute_query_single( "SELECT * FROM invoice_error_finder_issues WHERE id = %s", (issue_id,), ) if not issue: raise HTTPException(status_code=404, detail="Issue not found") customer_id = issue.get("customer_id") if not customer_id: raise HTTPException(status_code=400, detail="Issue has no mapped customer") user_id = _get_user_id(request) or 1 sag_result = execute_query( """ INSERT INTO sag_sager (titel, beskrivelse, status, customer_id, created_by_user_id) VALUES (%s, %s, %s, %s, %s) RETURNING id """, ( payload.titel, payload.beskrivelse, payload.status or "åben", customer_id, user_id, ), ) if not sag_result: raise HTTPException(status_code=500, detail="Failed to create sag") sag_id = sag_result[0]["id"] execute_query( """ UPDATE invoice_error_finder_issues SET sag_id = %s, status = 'investigating', updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (sag_id, issue_id), ) return {"issue_id": issue_id, "sag_id": sag_id} except HTTPException: raise except Exception as exc: logger.error("❌ Create sag for issue failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/issues/{issue_id}/link-sag") async def link_sag_to_issue( issue_id: int, payload: LinkSagRequest, current_user: dict = Depends(require_permission("invoice_error_finder.create_sag")), ): """Link an existing sag to an issue.""" try: issue = execute_query_single( "SELECT id FROM invoice_error_finder_issues WHERE id = %s", (issue_id,), ) if not issue: raise HTTPException(status_code=404, detail="Issue not found") sag = execute_query_single( "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (payload.sag_id,), ) if not sag: raise HTTPException(status_code=404, detail="Sag not found") execute_query( """ UPDATE invoice_error_finder_issues SET sag_id = %s, status = 'investigating', updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (payload.sag_id, issue_id), ) return {"issue_id": issue_id, "sag_id": payload.sag_id} except HTTPException: raise except Exception as exc: logger.error("❌ Link sag to issue failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/issues/{issue_id}/create-ordre-draft") async def create_ordre_draft_for_issue( issue_id: int, payload: CreateOrdreDraftRequest, request: Request, current_user: dict = Depends(require_permission("invoice_error_finder.create_ordre_draft")), ): """ Create a local ordre_draft from an issue. IMPORTANT: This only creates a local draft; it does NOT send anything to e-conomic. """ try: issue = execute_query_single( "SELECT * FROM invoice_error_finder_issues WHERE id = %s", (issue_id,), ) if not issue: raise HTTPException(status_code=404, detail="Issue not found") customer_id = issue.get("customer_id") if not customer_id: raise HTTPException(status_code=400, detail="Issue has no mapped customer") customer = execute_query_single( "SELECT id, name FROM customers WHERE id = %s", (customer_id,), ) if not customer: raise HTTPException(status_code=404, detail="Customer not found") user_id = _get_user_id(request) or 1 description = payload.description or issue.get("product_name") or issue.get("product_number") or "Fakturakorrektion" quantity = issue.get("expected_quantity") or 1 unit_price = issue.get("expected_price") or issue.get("amount_impact") or 0 line = { "description": description, "quantity": float(quantity) if quantity else 1, "unit_price": float(unit_price) if unit_price else 0, "line_total": float(quantity or 1) * float(unit_price or 0), } draft_result = execute_query( """ INSERT INTO ordre_drafts (title, customer_id, lines_json, notes, created_by_user_id) VALUES (%s, %s, %s::jsonb, %s, %s) RETURNING id """, ( f"Fakturakorrektion: {customer['name']}", customer_id, json.dumps([line], ensure_ascii=False), f"Oprettet fra faktura-fejl-finder issue #{issue_id}", user_id, ), ) if not draft_result: raise HTTPException(status_code=500, detail="Failed to create ordre draft") draft_id = draft_result[0]["id"] execute_query( """ UPDATE invoice_error_finder_issues SET status = 'ready_to_invoice', updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (issue_id,), ) return {"issue_id": issue_id, "draft_id": draft_id} except HTTPException: raise except Exception as exc: logger.error("❌ Create ordre draft for issue failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/issues/{issue_id}/ignore") async def ignore_issue( issue_id: int, ignored_until: Optional[str] = Query(None), current_user: dict = Depends(require_permission("invoice_error_finder.ignore")), ): """Ignore an issue (optionally until a date).""" try: issue = execute_query_single( "SELECT id FROM invoice_error_finder_issues WHERE id = %s", (issue_id,), ) if not issue: raise HTTPException(status_code=404, detail="Issue not found") until_date = None if ignored_until: until_date = date.fromisoformat(ignored_until) execute_query( """ UPDATE invoice_error_finder_issues SET status = 'ignored', ignored_until = %s, resolved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (until_date, issue_id), ) return {"id": issue_id, "status": "ignored"} except HTTPException: raise except Exception as exc: logger.error("❌ Ignore issue failed: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail=str(exc)) @router.get("/config") async def get_config( current_user: dict = Depends(require_permission("invoice_error_finder.view")), ): """Return module configuration for the frontend.""" return { "issue_statuses": ISSUE_STATUS_LABELS, "issue_types": ISSUE_TYPE_LABELS, }