From 128a2b83d0f10f39a8f72083dab96bea16f306fd Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 07:08:49 +0200 Subject: [PATCH] feat(invoice_error_finder): add invoice error finder module - Detect missing invoice lines, open orders not invoiced, quantity drops, price changes - Import e-conomic invoices and Simply CRM sales orders - Dashboard and issues UI with sag/ordre-draft actions - Scheduled daily sync job at 05:00 - Add invoice_error_finder permissions --- app/jobs/invoice_error_finder_sync.py | 84 +++ app/modules/invoice_error_finder/__init__.py | 1 + .../invoice_error_finder/backend/__init__.py | 1 + .../invoice_error_finder/backend/router.py | 566 +++++++++++++++++ .../invoice_error_finder/frontend/__init__.py | 1 + .../invoice_error_finder/frontend/views.py | 60 ++ .../migrations/001_init.sql | 179 ++++++ app/modules/invoice_error_finder/module.json | 17 + .../invoice_error_finder/services/__init__.py | 1 + .../services/detection_service.py | 592 ++++++++++++++++++ .../services/economic_import_service.py | 289 +++++++++ .../services/simply_import_service.py | 248 ++++++++ .../templates/dashboard.html | 241 +++++++ .../templates/issues.html | 333 ++++++++++ app/shared/frontend/base.html | 3 + main.py | 19 +- migrations/1007_invoice_error_finder.sql | 58 ++ 17 files changed, 2692 insertions(+), 1 deletion(-) create mode 100644 app/jobs/invoice_error_finder_sync.py create mode 100644 app/modules/invoice_error_finder/__init__.py create mode 100644 app/modules/invoice_error_finder/backend/__init__.py create mode 100644 app/modules/invoice_error_finder/backend/router.py create mode 100644 app/modules/invoice_error_finder/frontend/__init__.py create mode 100644 app/modules/invoice_error_finder/frontend/views.py create mode 100644 app/modules/invoice_error_finder/migrations/001_init.sql create mode 100644 app/modules/invoice_error_finder/module.json create mode 100644 app/modules/invoice_error_finder/services/__init__.py create mode 100644 app/modules/invoice_error_finder/services/detection_service.py create mode 100644 app/modules/invoice_error_finder/services/economic_import_service.py create mode 100644 app/modules/invoice_error_finder/services/simply_import_service.py create mode 100644 app/modules/invoice_error_finder/templates/dashboard.html create mode 100644 app/modules/invoice_error_finder/templates/issues.html create mode 100644 migrations/1007_invoice_error_finder.sql diff --git a/app/jobs/invoice_error_finder_sync.py b/app/jobs/invoice_error_finder_sync.py new file mode 100644 index 0000000..4084bc8 --- /dev/null +++ b/app/jobs/invoice_error_finder_sync.py @@ -0,0 +1,84 @@ +""" +Scheduled sync job for Invoice Error Finder. +Runs daily after subscription processing to import e-conomic and Simply data +and re-run anomaly detection. +""" +import logging + +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__) + + +async def run_invoice_error_finder_sync() -> dict: + """ + Daily scheduled job: + 1. Import e-conomic invoices (last 13 months). + 2. Import open Simply CRM sales orders. + 3. Re-import Simply subscription staging via existing endpoint. + 4. Run anomaly detection for current month. + """ + logger.info("🔄 Starting scheduled Invoice Error Finder sync") + + economic_result = {"records_imported": 0, "records_failed": 0} + simply_result = {"records_imported": 0, "records_failed": 0} + staging_result = {"records_imported": 0, "records_failed": 0} + detection_counts = {} + errors = [] + + try: + economic_service = EconomicImportService() + economic_result = await economic_service.import_invoices( + triggered_by_user_id=None, + is_scheduled=True, + ) + except Exception as exc: + logger.error("❌ Scheduled e-conomic import failed: %s", exc, exc_info=True) + errors.append(f"economic: {exc}") + + try: + simply_service = SimplyImportService() + simply_result = await simply_service.import_sales_orders( + triggered_by_user_id=None, + is_scheduled=True, + ) + except Exception as exc: + logger.error("❌ Scheduled Simply sales order import failed: %s", exc, exc_info=True) + errors.append(f"simply: {exc}") + + # Refresh Simply subscription staging by calling the existing import function directly + try: + from app.subscriptions.backend.router import import_simply_subscriptions_to_staging + + staging_data = await import_simply_subscriptions_to_staging() + staging_result = { + "records_imported": staging_data.get("imported", 0), + "records_failed": staging_data.get("errors", 0), + } + except Exception as exc: + logger.error("❌ Scheduled Simply subscription staging import failed: %s", exc, exc_info=True) + errors.append(f"staging: {exc}") + + try: + detection_service = DetectionService() + detection_counts = detection_service.analyze() + except Exception as exc: + logger.error("❌ Scheduled detection failed: %s", exc, exc_info=True) + errors.append(f"detection: {exc}") + + result = { + "economic": economic_result, + "simply": simply_result, + "staging": staging_result, + "detection": detection_counts, + "errors": errors, + } + + if errors: + logger.warning("⚠️ Invoice Error Finder sync completed with errors: %s", errors) + else: + logger.info("✅ Invoice Error Finder sync completed successfully: %s", result) + + return result diff --git a/app/modules/invoice_error_finder/__init__.py b/app/modules/invoice_error_finder/__init__.py new file mode 100644 index 0000000..4e6c620 --- /dev/null +++ b/app/modules/invoice_error_finder/__init__.py @@ -0,0 +1 @@ +"""Invoice Error Finder module.""" diff --git a/app/modules/invoice_error_finder/backend/__init__.py b/app/modules/invoice_error_finder/backend/__init__.py new file mode 100644 index 0000000..975284d --- /dev/null +++ b/app/modules/invoice_error_finder/backend/__init__.py @@ -0,0 +1 @@ +"""Invoice Error Finder backend.""" diff --git a/app/modules/invoice_error_finder/backend/router.py b/app/modules/invoice_error_finder/backend/router.py new file mode 100644 index 0000000..4a118a8 --- /dev/null +++ b/app/modules/invoice_error_finder/backend/router.py @@ -0,0 +1,566 @@ +""" +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, + } diff --git a/app/modules/invoice_error_finder/frontend/__init__.py b/app/modules/invoice_error_finder/frontend/__init__.py new file mode 100644 index 0000000..71d44e5 --- /dev/null +++ b/app/modules/invoice_error_finder/frontend/__init__.py @@ -0,0 +1 @@ +"""Invoice Error Finder frontend.""" diff --git a/app/modules/invoice_error_finder/frontend/views.py b/app/modules/invoice_error_finder/frontend/views.py new file mode 100644 index 0000000..9f4e560 --- /dev/null +++ b/app/modules/invoice_error_finder/frontend/views.py @@ -0,0 +1,60 @@ +""" +Invoice Error Finder frontend views. +""" +import logging +from typing import Any, Dict + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from app.core.database import execute_query + +logger = logging.getLogger(__name__) +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +def _fetch_assignment_users() -> list: + return execute_query( + """ + SELECT user_id, COALESCE(full_name, username) AS display_name + FROM users + ORDER BY display_name + """, + (), + ) or [] + + +def _fetch_customers() -> list: + return execute_query( + """ + SELECT id, name + FROM customers + WHERE deleted_at IS NULL + ORDER BY name + """, + (), + ) or [] + + +@router.get("/invoice-error-finder", response_class=HTMLResponse) +async def dashboard(request: Request): + return templates.TemplateResponse( + "modules/invoice_error_finder/templates/dashboard.html", + { + "request": request, + }, + ) + + +@router.get("/invoice-error-finder/issues", response_class=HTMLResponse) +async def issues_list(request: Request): + return templates.TemplateResponse( + "modules/invoice_error_finder/templates/issues.html", + { + "request": request, + "users": _fetch_assignment_users(), + "customers": _fetch_customers(), + }, + ) diff --git a/app/modules/invoice_error_finder/migrations/001_init.sql b/app/modules/invoice_error_finder/migrations/001_init.sql new file mode 100644 index 0000000..fe4ba6c --- /dev/null +++ b/app/modules/invoice_error_finder/migrations/001_init.sql @@ -0,0 +1,179 @@ +-- Migration 001: Invoice Error Finder module +-- Staging tables for e-conomic invoices/lines and Simply CRM sales orders, +-- plus detected issues and import runs. + +-- Import run log (one row per source/import attempt) +CREATE TABLE IF NOT EXISTS invoice_error_finder_import_runs ( + id SERIAL PRIMARY KEY, + source_type VARCHAR(50) NOT NULL, -- 'economic_invoices' | 'simply_sales_orders' + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'partial', 'failed')), + records_imported INTEGER NOT NULL DEFAULT 0, + records_failed INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + triggered_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + is_scheduled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ief_import_runs_source + ON invoice_error_finder_import_runs(source_type, started_at DESC); + +-- e-conomic invoice headers +CREATE TABLE IF NOT EXISTS invoice_error_finder_economic_invoices ( + id SERIAL PRIMARY KEY, + import_run_id INTEGER NOT NULL REFERENCES invoice_error_finder_import_runs(id) ON DELETE CASCADE, + source_invoice_number VARCHAR(80), + source_type VARCHAR(30) NOT NULL DEFAULT 'booked', -- booked | paid | draft | unpaid + customer_number INTEGER, + customer_name VARCHAR(255), + invoice_date DATE, + due_date DATE, + currency VARCHAR(10) DEFAULT 'DKK', + net_amount NUMERIC(14,2), + vat_amount NUMERIC(14,2), + total_amount NUMERIC(14,2), + source_raw JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_ief_economic_invoice_import UNIQUE (import_run_id, source_invoice_number, source_type) +); + +CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_run + ON invoice_error_finder_economic_invoices(import_run_id); +CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_customer + ON invoice_error_finder_economic_invoices(customer_number); +CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_date + ON invoice_error_finder_economic_invoices(invoice_date); + +-- e-conomic invoice lines +CREATE TABLE IF NOT EXISTS invoice_error_finder_economic_invoice_lines ( + id SERIAL PRIMARY KEY, + invoice_id INTEGER NOT NULL REFERENCES invoice_error_finder_economic_invoices(id) ON DELETE CASCADE, + line_number INTEGER, + product_number VARCHAR(100), + product_name VARCHAR(500), + description TEXT, + quantity NUMERIC(14,4) NOT NULL DEFAULT 0, + unit_price NUMERIC(14,4) NOT NULL DEFAULT 0, + line_net_amount NUMERIC(14,2) NOT NULL DEFAULT 0, + discount_percentage NUMERIC(5,2) DEFAULT 0, + source_raw JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ief_economic_lines_invoice + ON invoice_error_finder_economic_invoice_lines(invoice_id); +CREATE INDEX IF NOT EXISTS idx_ief_economic_lines_product + ON invoice_error_finder_economic_invoice_lines(product_number); + +-- Simply CRM open sales orders +CREATE TABLE IF NOT EXISTS invoice_error_finder_simply_sales_orders ( + id SERIAL PRIMARY KEY, + import_run_id INTEGER NOT NULL REFERENCES invoice_error_finder_import_runs(id) ON DELETE CASCADE, + source_record_id VARCHAR(80) NOT NULL, + salesorder_no VARCHAR(80), + account_id VARCHAR(80), + customer_name VARCHAR(255), + customer_cvr VARCHAR(32), + subject TEXT, + status VARCHAR(50), + product_number VARCHAR(100), + product_name VARCHAR(500), + quantity NUMERIC(14,4) NOT NULL DEFAULT 0, + unit_price NUMERIC(14,4) NOT NULL DEFAULT 0, + total_amount NUMERIC(14,2) NOT NULL DEFAULT 0, + start_period DATE, + end_period DATE, + source_raw JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_ief_simply_order_import UNIQUE (source_record_id) +); + +CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_run + ON invoice_error_finder_simply_sales_orders(import_run_id); +CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_account + ON invoice_error_finder_simply_sales_orders(account_id); +CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_status + ON invoice_error_finder_simply_sales_orders(status); + +-- Detected issues / anomalies +CREATE TABLE IF NOT EXISTS invoice_error_finder_issues ( + id SERIAL PRIMARY KEY, + issue_type VARCHAR(50) NOT NULL CHECK (issue_type IN ( + 'missing_line', + 'open_order_not_invoiced', + 'quantity_drop', + 'price_change', + 'new_item_never_invoiced' + )), + status VARCHAR(30) NOT NULL DEFAULT 'open' CHECK (status IN ( + 'open', + 'investigating', + 'approved_change', + 'error_found', + 'ready_to_invoice', + 'invoiced', + 'ignored' + )), + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + customer_name VARCHAR(255), + subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL, + simply_order_id INTEGER REFERENCES invoice_error_finder_simply_sales_orders(id) ON DELETE SET NULL, + simply_source_record_id VARCHAR(80), + sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL, + + product_number VARCHAR(100), + product_name VARCHAR(500), + + reference_period_start DATE, + reference_period_end DATE, + + expected_quantity NUMERIC(14,4), + actual_quantity NUMERIC(14,4), + expected_price NUMERIC(14,4), + actual_price NUMERIC(14,4), + + last_invoice_number VARCHAR(80), + last_invoice_date DATE, + sales_order_number VARCHAR(80), + + amount_impact NUMERIC(14,2), + currency VARCHAR(10) DEFAULT 'DKK', + + assigned_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + notes TEXT, + ignored_until DATE, + resolved_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ief_issues_type + ON invoice_error_finder_issues(issue_type); +CREATE INDEX IF NOT EXISTS idx_ief_issues_status + ON invoice_error_finder_issues(status); +CREATE INDEX IF NOT EXISTS idx_ief_issues_customer + ON invoice_error_finder_issues(customer_id); +CREATE INDEX IF NOT EXISTS idx_ief_issues_period + ON invoice_error_finder_issues(reference_period_start, reference_period_end); +CREATE INDEX IF NOT EXISTS idx_ief_issues_assigned + ON invoice_error_finder_issues(assigned_user_id) + WHERE assigned_user_id IS NULL; + +-- Trigger for updated_at +CREATE OR REPLACE FUNCTION update_ief_issues_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_ief_issues_updated_at ON invoice_error_finder_issues; +CREATE TRIGGER trigger_ief_issues_updated_at +BEFORE UPDATE ON invoice_error_finder_issues +FOR EACH ROW +EXECUTE FUNCTION update_ief_issues_updated_at(); diff --git a/app/modules/invoice_error_finder/module.json b/app/modules/invoice_error_finder/module.json new file mode 100644 index 0000000..7db2276 --- /dev/null +++ b/app/modules/invoice_error_finder/module.json @@ -0,0 +1,17 @@ +{ + "name": "invoice_error_finder", + "version": "1.0.0", + "description": "Faktura-fejl-finder: sammenligner fakturaer fra e-conomic med abonnementer og salgsordrer fra Simply CRM for at finde manglende eller ændrede fakturalinjer.", + "author": "BMC Networks", + "enabled": true, + "dependencies": ["sag"], + "table_prefix": "invoice_error_finder_", + "api_prefix": "/api/v1/invoice-error-finder", + "tags": ["Invoice Error Finder", "Faktura", "Økonomi"], + "config": { + "safety_switches": { + "read_only": false, + "dry_run": false + } + } +} diff --git a/app/modules/invoice_error_finder/services/__init__.py b/app/modules/invoice_error_finder/services/__init__.py new file mode 100644 index 0000000..9b1dd9b --- /dev/null +++ b/app/modules/invoice_error_finder/services/__init__.py @@ -0,0 +1 @@ +"""Invoice Error Finder services.""" diff --git a/app/modules/invoice_error_finder/services/detection_service.py b/app/modules/invoice_error_finder/services/detection_service.py new file mode 100644 index 0000000..51a52e0 --- /dev/null +++ b/app/modules/invoice_error_finder/services/detection_service.py @@ -0,0 +1,592 @@ +""" +Detection service for Invoice Error Finder. +Compares imported e-conomic invoices with subscriptions / Simply orders and +writes issues to invoice_error_finder_issues. +""" +import logging +from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional +from dateutil.relativedelta import relativedelta + +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) + + +class DetectionService: + """Detect invoice anomalies and write issues.""" + + def __init__( + self, + quantity_drop_threshold: float = 0.10, + open_order_days_threshold: int = 7, + ): + self.quantity_drop_threshold = quantity_drop_threshold + self.open_order_days_threshold = open_order_days_threshold + + def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]: + """ + Run all detection rules for the given reference month (defaults to current month). + Returns counts per issue_type. + """ + if reference_month is None: + reference_month = date.today().replace(day=1) + + previous_month = reference_month - relativedelta(months=1) + + logger.info("🔍 Running invoice error detection for %s", reference_month) + + counts = { + "missing_line": self._detect_missing_lines(reference_month, previous_month), + "open_order_not_invoiced": self._detect_open_orders_not_invoiced(reference_month), + "quantity_drop": self._detect_quantity_drops(reference_month, previous_month), + "price_change": self._detect_price_changes(reference_month, previous_month), + } + + logger.info("✅ Detection complete: %s", counts) + return counts + + def _detect_missing_lines(self, current_month: date, previous_month: date) -> int: + """ + Products invoiced in previous month but missing in current month for same customer. + """ + current_start, current_end = self._month_bounds(current_month) + previous_start, previous_end = self._month_bounds(previous_month) + + rows = execute_query( + """ + WITH customer_map AS ( + SELECT DISTINCT ON (economic_customer_number) + economic_customer_number, + id AS hub_customer_id + FROM customers + WHERE economic_customer_number IS NOT NULL + AND deleted_at IS NULL + ORDER BY economic_customer_number, id + ), + previous_lines AS ( + SELECT + COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key, + LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key, + SUM(line.quantity) AS quantity, + MAX(inv.invoice_date) AS last_invoice_date + FROM invoice_error_finder_economic_invoices inv + JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = inv.id + LEFT JOIN customer_map m + ON m.economic_customer_number = inv.customer_number + WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s + AND line.product_number IS NOT NULL + GROUP BY customer_key, product_key + ), + current_lines AS ( + SELECT + COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key, + LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key + FROM invoice_error_finder_economic_invoices inv + JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = inv.id + LEFT JOIN customer_map m + ON m.economic_customer_number = inv.customer_number + WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s + AND line.product_number IS NOT NULL + GROUP BY customer_key, product_key + ) + SELECT + prev.customer_key, + prev.product_key, + prev.quantity AS expected_quantity, + prev.last_invoice_date, + c.name AS customer_name, + m2.hub_customer_id + FROM previous_lines prev + LEFT JOIN current_lines cur + ON cur.customer_key = prev.customer_key + AND cur.product_key = prev.product_key + LEFT JOIN customers c ON c.id = prev.customer_key + LEFT JOIN customer_map m2 + ON m2.hub_customer_id = prev.customer_key + WHERE cur.product_key IS NULL + """, + (previous_start, previous_end, current_start, current_end), + ) or [] + + created = 0 + for row in rows: + hub_customer_id = self._resolve_hub_customer_id(row) + customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name")) + + if self._is_customer_closed_or_cancelled(hub_customer_id, current_month): + continue + + issue_id = self._upsert_issue( + issue_type="missing_line", + customer_id=hub_customer_id, + customer_name=customer_name, + product_number=row["product_key"], + reference_period_start=current_start, + reference_period_end=current_end, + expected_quantity=row.get("expected_quantity"), + actual_quantity=0, + last_invoice_date=row.get("last_invoice_date"), + amount_impact=None, + ) + if issue_id: + created += 1 + + return created + + def _detect_open_orders_not_invoiced(self, reference_month: date) -> int: + """Open Simply sales orders without a matching e-conomic invoice line.""" + month_start, month_end = self._month_bounds(reference_month) + lookback_start = month_start - timedelta(days=self.open_order_days_threshold) + + rows = execute_query( + """ + SELECT + so.id, + so.source_record_id, + so.salesorder_no, + so.account_id, + so.customer_name, + so.subject, + so.product_number, + so.product_name, + so.quantity, + so.unit_price, + so.total_amount, + so.start_period, + so.end_period, + sss.hub_customer_id + FROM invoice_error_finder_simply_sales_orders so + LEFT JOIN simply_subscription_staging sss + ON sss.source_account_id = so.account_id + WHERE so.status IN ('Created', 'Approved', 'Delivered') + AND COALESCE(so.source_record_id, '') NOT IN ( + SELECT COALESCE(simply_source_record_id, '') + FROM invoice_error_finder_issues + WHERE issue_type = 'open_order_not_invoiced' + AND status IN ('invoiced', 'ignored') + ) + ORDER BY so.id + """, + (), + ) or [] + + created = 0 + for row in rows: + hub_customer_id = row.get("hub_customer_id") + customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name")) + + if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month): + continue + + # Check if there is any e-conomic invoice line for this customer + product recently + has_invoice = self._has_recent_invoice_for_product( + hub_customer_id, + row.get("product_number"), + lookback_start, + month_end, + ) + if has_invoice: + continue + + issue_id = self._upsert_issue( + issue_type="open_order_not_invoiced", + customer_id=hub_customer_id, + customer_name=customer_name, + simply_order_id=row["id"], + simply_source_record_id=row.get("source_record_id"), + product_number=row.get("product_number"), + product_name=row.get("product_name"), + reference_period_start=month_start, + reference_period_end=month_end, + expected_quantity=row.get("quantity"), + actual_quantity=0, + sales_order_number=row.get("salesorder_no"), + amount_impact=row.get("total_amount"), + ) + if issue_id: + created += 1 + + return created + + def _detect_quantity_drops(self, current_month: date, previous_month: date) -> int: + """Flag products where invoiced quantity dropped more than threshold.""" + current_start, current_end = self._month_bounds(current_month) + previous_start, previous_end = self._month_bounds(previous_month) + + rows = execute_query( + """ + WITH customer_map AS ( + SELECT DISTINCT ON (economic_customer_number) + economic_customer_number, + id AS hub_customer_id + FROM customers + WHERE economic_customer_number IS NOT NULL + AND deleted_at IS NULL + ORDER BY economic_customer_number, id + ), + monthly_qty AS ( + SELECT + COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key, + LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key, + DATE_TRUNC('month', inv.invoice_date)::date AS period, + SUM(line.quantity) AS quantity + FROM invoice_error_finder_economic_invoices inv + JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = inv.id + LEFT JOIN customer_map m + ON m.economic_customer_number = inv.customer_number + WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s + AND line.product_number IS NOT NULL + GROUP BY customer_key, product_key, period + ), + prev AS ( + SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s + ), + cur AS ( + SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s + ) + SELECT + prev.customer_key, + prev.product_key, + prev.quantity AS expected_quantity, + cur.quantity AS actual_quantity, + c.name AS customer_name, + m.hub_customer_id + FROM prev + JOIN cur + ON cur.customer_key = prev.customer_key + AND cur.product_key = prev.product_key + LEFT JOIN customers c ON c.id = prev.customer_key + LEFT JOIN customer_map m ON m.hub_customer_id = prev.customer_key + WHERE prev.quantity > 0 + AND cur.quantity < prev.quantity * (1 - %s) + """, + ( + previous_start, + current_end, + previous_start, + current_start, + self.quantity_drop_threshold, + ), + ) or [] + + created = 0 + for row in rows: + hub_customer_id = self._resolve_hub_customer_id(row) + customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name")) + + if self._is_customer_closed_or_cancelled(hub_customer_id, current_month): + continue + + issue_id = self._upsert_issue( + issue_type="quantity_drop", + customer_id=hub_customer_id, + customer_name=customer_name, + product_number=row["product_key"], + reference_period_start=current_start, + reference_period_end=current_end, + expected_quantity=row["expected_quantity"], + actual_quantity=row["actual_quantity"], + amount_impact=None, + ) + if issue_id: + created += 1 + + return created + + def _detect_price_changes(self, current_month: date, previous_month: date) -> int: + """Flag products where unit price changed between months.""" + current_start, current_end = self._month_bounds(current_month) + previous_start, previous_end = self._month_bounds(previous_month) + + rows = execute_query( + """ + WITH customer_map AS ( + SELECT DISTINCT ON (economic_customer_number) + economic_customer_number, + id AS hub_customer_id + FROM customers + WHERE economic_customer_number IS NOT NULL + AND deleted_at IS NULL + ORDER BY economic_customer_number, id + ), + monthly_price AS ( + SELECT + COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key, + LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key, + DATE_TRUNC('month', inv.invoice_date)::date AS period, + AVG(line.unit_price) AS avg_price + FROM invoice_error_finder_economic_invoices inv + JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = inv.id + LEFT JOIN customer_map m + ON m.economic_customer_number = inv.customer_number + WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s + AND line.product_number IS NOT NULL + AND line.unit_price > 0 + GROUP BY customer_key, product_key, period + ), + prev AS ( + SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s + ), + cur AS ( + SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s + ) + SELECT + prev.customer_key, + prev.product_key, + prev.avg_price AS expected_price, + cur.avg_price AS actual_price, + c.name AS customer_name, + m.hub_customer_id + FROM prev + JOIN cur + ON cur.customer_key = prev.customer_key + AND cur.product_key = prev.product_key + LEFT JOIN customers c ON c.id = prev.customer_key + LEFT JOIN customer_map m ON m.hub_customer_id = prev.customer_key + WHERE ABS(cur.avg_price - prev.avg_price) > 0.001 + """, + ( + previous_start, + current_end, + previous_start, + current_start, + ), + ) or [] + + created = 0 + for row in rows: + hub_customer_id = self._resolve_hub_customer_id(row) + customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name")) + + if self._is_customer_closed_or_cancelled(hub_customer_id, current_month): + continue + + expected_price = row["expected_price"] + actual_price = row["actual_price"] + impact = None + if expected_price and actual_price is not None: + impact = actual_price - expected_price + + issue_id = self._upsert_issue( + issue_type="price_change", + customer_id=hub_customer_id, + customer_name=customer_name, + product_number=row["product_key"], + reference_period_start=current_start, + reference_period_end=current_end, + expected_price=expected_price, + actual_price=actual_price, + amount_impact=impact, + ) + if issue_id: + created += 1 + + return created + + def _has_recent_invoice_for_product( + self, + customer_id: Optional[int], + product_number: Optional[str], + start_date: date, + end_date: date, + ) -> bool: + if not customer_id and not product_number: + return False + + customer_rows = execute_query( + "SELECT economic_customer_number FROM customers WHERE id = %s", + (customer_id,), + ) or [] + economic_numbers = [str(r["economic_customer_number"]) for r in customer_rows if r.get("economic_customer_number")] + + query = """ + SELECT 1 + FROM invoice_error_finder_economic_invoices inv + JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = inv.id + WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s + """ + params: List[Any] = [start_date, end_date] + + if economic_numbers: + query += " AND inv.customer_number = ANY(%s::int[])" + params.append(economic_numbers) + else: + return False + + if product_number: + query += " AND LOWER(TRIM(line.product_number)) = LOWER(TRIM(%s))" + params.append(product_number) + + query += " LIMIT 1" + result = execute_query(query, tuple(params)) + return bool(result) + + def _is_customer_closed_or_cancelled(self, customer_id: Optional[int], reference_month: date) -> bool: + if not customer_id: + return False + + customer = execute_query_single( + "SELECT deleted_at FROM customers WHERE id = %s", + (customer_id,), + ) + if customer and customer.get("deleted_at"): + return True + + # Consider customer closed if all active subscriptions have ended before the reference month + active = execute_query( + """ + SELECT 1 + FROM sag_subscriptions + WHERE customer_id = %s + AND status = 'active' + AND (end_date IS NULL OR end_date >= %s) + LIMIT 1 + """, + (customer_id, reference_month), + ) + if not active: + return True + + return False + + def _upsert_issue(self, **kwargs: Any) -> Optional[int]: + """Insert a new issue or update an existing open one.""" + issue_type = kwargs["issue_type"] + customer_id = kwargs.get("customer_id") + product_number = kwargs.get("product_number") + reference_period_start = kwargs.get("reference_period_start") + reference_period_end = kwargs.get("reference_period_end") + simply_order_id = kwargs.get("simply_order_id") + simply_source_record_id = kwargs.get("simply_source_record_id") + + existing = execute_query_single( + """ + SELECT id, status + FROM invoice_error_finder_issues + WHERE issue_type = %s + AND customer_id IS NOT DISTINCT FROM %s + AND COALESCE(product_number, '') = COALESCE(%s, '') + AND reference_period_start = %s + AND reference_period_end = %s + AND ( + COALESCE(simply_source_record_id, '') = COALESCE(%s, '') + OR ( + simply_source_record_id IS NULL + AND COALESCE(simply_order_id, 0) = COALESCE(%s, 0) + ) + ) + ORDER BY id DESC + LIMIT 1 + """, + ( + issue_type, + customer_id, + product_number, + reference_period_start, + reference_period_end, + simply_source_record_id, + simply_order_id, + ), + ) + + if existing and existing.get("status") not in {"ignored", "invoiced"}: + execute_query( + """ + UPDATE invoice_error_finder_issues + SET expected_quantity = COALESCE(%s, expected_quantity), + actual_quantity = COALESCE(%s, actual_quantity), + expected_price = COALESCE(%s, expected_price), + actual_price = COALESCE(%s, actual_price), + amount_impact = COALESCE(%s, amount_impact), + last_invoice_number = COALESCE(%s, last_invoice_number), + last_invoice_date = COALESCE(%s, last_invoice_date), + sales_order_number = COALESCE(%s, sales_order_number), + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + kwargs.get("expected_quantity"), + kwargs.get("actual_quantity"), + kwargs.get("expected_price"), + kwargs.get("actual_price"), + kwargs.get("amount_impact"), + kwargs.get("last_invoice_number"), + kwargs.get("last_invoice_date"), + kwargs.get("sales_order_number"), + existing["id"], + ), + ) + return existing["id"] + + if existing and existing.get("status") in {"ignored", "invoiced"}: + return None + + row = execute_query_single( + """ + INSERT INTO invoice_error_finder_issues ( + issue_type, status, customer_id, customer_name, subscription_id, + simply_order_id, simply_source_record_id, sag_id, product_number, product_name, + reference_period_start, reference_period_end, expected_quantity, + actual_quantity, expected_price, actual_price, last_invoice_number, + last_invoice_date, sales_order_number, amount_impact, currency, notes + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + RETURNING id + """, + ( + issue_type, + kwargs.get("status", "open"), + customer_id, + kwargs.get("customer_name"), + kwargs.get("subscription_id"), + simply_order_id, + simply_source_record_id, + kwargs.get("sag_id"), + product_number, + kwargs.get("product_name"), + reference_period_start, + reference_period_end, + kwargs.get("expected_quantity"), + kwargs.get("actual_quantity"), + kwargs.get("expected_price"), + kwargs.get("actual_price"), + kwargs.get("last_invoice_number"), + kwargs.get("last_invoice_date"), + kwargs.get("sales_order_number"), + kwargs.get("amount_impact"), + kwargs.get("currency", "DKK"), + kwargs.get("notes"), + ), + ) + return row["id"] if row else None + + @staticmethod + def _resolve_hub_customer_id(row: Dict[str, Any]) -> Optional[int]: + value = row.get("hub_customer_id") or row.get("customer_key") + if isinstance(value, int): + return value + return None + + @staticmethod + def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]: + if hub_customer_id: + row = execute_query_single( + "SELECT name FROM customers WHERE id = %s", + (hub_customer_id,), + ) + if row: + return row["name"] + return fallback + + @staticmethod + def _month_bounds(month_date: date) -> tuple[date, date]: + start = month_date.replace(day=1) + end = (start + relativedelta(months=1)) - relativedelta(days=1) + return start, end diff --git a/app/modules/invoice_error_finder/services/economic_import_service.py b/app/modules/invoice_error_finder/services/economic_import_service.py new file mode 100644 index 0000000..b474bc7 --- /dev/null +++ b/app/modules/invoice_error_finder/services/economic_import_service.py @@ -0,0 +1,289 @@ +""" +e-conomic import service for Invoice Error Finder. +Fetches invoices and invoice lines from e-conomic and persists them locally +for comparison with subscriptions and sales orders. +""" +import logging +from datetime import datetime, date +from typing import Dict, List, Optional, Any +from dateutil.relativedelta import relativedelta + +import aiohttp +from app.core.config import settings +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) + + +class EconomicImportService: + """Import e-conomic invoices/lines into invoice_error_finder staging tables.""" + + def __init__(self): + self.api_url = getattr(settings, "ECONOMIC_API_URL", "https://restapi.e-conomic.com") + self.app_secret_token = getattr(settings, "ECONOMIC_APP_SECRET_TOKEN", None) + self.agreement_grant_token = getattr(settings, "ECONOMIC_AGREEMENT_GRANT_TOKEN", None) + + def _headers(self) -> Dict[str, str]: + if not self.app_secret_token or not self.agreement_grant_token: + raise ValueError("e-conomic credentials not configured") + return { + "X-AppSecretToken": self.app_secret_token, + "X-AgreementGrantToken": self.agreement_grant_token, + "Content-Type": "application/json", + } + + async def import_invoices( + self, + triggered_by_user_id: Optional[int] = None, + is_scheduled: bool = False, + months_back: int = 13, + ) -> Dict[str, Any]: + """ + Fetch all e-conomic invoices (booked/drafts/paid/unpaid) for the last N months, + fetch lines for each, and persist to staging tables. + """ + run_id = self._create_import_run("economic_invoices", triggered_by_user_id, is_scheduled) + try: + start_date = (datetime.now() - relativedelta(months=months_back)).replace(day=1).date() + logger.info("📅 Importing e-conomic invoices from %s onwards", start_date) + + endpoints = [ + ("booked", f"{self.api_url}/invoices/booked"), + ("paid", f"{self.api_url}/invoices/paid"), + ("unpaid", f"{self.api_url}/invoices/unpaid"), + ("draft", f"{self.api_url}/invoices/drafts"), + ] + + all_invoices: List[Dict[str, Any]] = [] + async with aiohttp.ClientSession() as session: + for source_type, endpoint in endpoints: + try: + page = 0 + while True: + async with session.get( + endpoint, + params={"pagesize": 1000, "skippages": page}, + headers=self._headers(), + ) as response: + if response.status != 200: + error_text = await response.text() + logger.warning( + "⚠️ e-conomic endpoint %s returned %s: %s", + endpoint, response.status, error_text[:200] + ) + break + + data = await response.json() + batch = data.get("collection", []) + if not batch: + break + + for inv in batch: + inv["__source_type__"] = source_type + all_invoices.append(inv) + + if len(batch) < 1000: + break + page += 1 + except Exception as exc: + logger.error("❌ Error fetching from %s: %s", endpoint, exc) + + logger.info("📥 Fetched %s e-conomic invoice headers", len(all_invoices)) + + imported_count = 0 + failed_count = 0 + + for inv in all_invoices: + try: + invoice_date_raw = inv.get("date") + invoice_date = self._parse_date(invoice_date_raw) + if invoice_date and invoice_date < start_date: + continue + + invoice_id = self._persist_invoice(run_id, inv) + if invoice_id: + lines = await self._fetch_invoice_lines(session, inv) + self._persist_lines(invoice_id, lines) + imported_count += 1 + except Exception as exc: + logger.error("❌ Failed to import invoice %s: %s", inv.get("draftInvoiceNumber") or inv.get("bookedInvoiceNumber"), exc) + failed_count += 1 + + self._complete_import_run(run_id, "success", imported_count, failed_count) + logger.info( + "✅ e-conomic import complete: %s imported, %s failed", + imported_count, + failed_count, + ) + return { + "import_run_id": run_id, + "records_imported": imported_count, + "records_failed": failed_count, + } + except Exception as exc: + self._complete_import_run(run_id, "failed", 0, 0, str(exc)) + logger.error("❌ e-conomic import failed: %s", exc, exc_info=True) + raise + + async def _fetch_invoice_lines( + self, session: aiohttp.ClientSession, invoice: Dict[str, Any] + ) -> List[Dict[str, Any]]: + """Fetch full invoice with lines using the self link or direct URL.""" + self_link = invoice.get("self") + invoice_number = invoice.get("draftInvoiceNumber") or invoice.get("bookedInvoiceNumber") + fetch_url = self_link or f"{self.api_url}/invoices/sales/{invoice_number}" + + try: + async with session.get(fetch_url, headers=self._headers()) as response: + if response.status == 200: + full = await response.json() + return full.get("lines", []) + except Exception as exc: + logger.warning("⚠️ Could not fetch lines for invoice %s: %s", invoice_number, exc) + + return invoice.get("lines", []) + + def _persist_invoice(self, run_id: int, invoice: Dict[str, Any]) -> Optional[int]: + customer = invoice.get("customer") or {} + customer_number = customer.get("customerNumber") + invoice_number = invoice.get("draftInvoiceNumber") or invoice.get("bookedInvoiceNumber") + source_type = invoice.get("__source_type__", "booked") + + if not invoice_number: + return None + + row = execute_query_single( + """ + INSERT INTO invoice_error_finder_economic_invoices ( + import_run_id, source_invoice_number, source_type, customer_number, + customer_name, invoice_date, due_date, currency, net_amount, + vat_amount, total_amount, source_raw + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb) + ON CONFLICT (import_run_id, source_invoice_number, source_type) + DO UPDATE SET + customer_number = EXCLUDED.customer_number, + customer_name = EXCLUDED.customer_name, + invoice_date = EXCLUDED.invoice_date, + due_date = EXCLUDED.due_date, + currency = EXCLUDED.currency, + net_amount = EXCLUDED.net_amount, + vat_amount = EXCLUDED.vat_amount, + total_amount = EXCLUDED.total_amount, + source_raw = EXCLUDED.source_raw + RETURNING id + """, + ( + run_id, + str(invoice_number), + source_type, + customer_number, + (customer.get("name") or invoice.get("customerName"))[:255] if (customer.get("name") or invoice.get("customerName")) else None, + self._parse_date(invoice.get("date")), + self._parse_date(invoice.get("dueDate")), + (invoice.get("currency") or "DKK")[:10], + self._parse_amount(invoice.get("netAmount")), + self._parse_amount(invoice.get("vatAmount")), + self._parse_amount(invoice.get("grossAmount")), + str(invoice), + ), + ) + return row["id"] if row else None + + def _persist_lines(self, invoice_id: int, lines: List[Dict[str, Any]]) -> None: + if not lines: + return + + execute_query( + "DELETE FROM invoice_error_finder_economic_invoice_lines WHERE invoice_id = %s", + (invoice_id,), + ) + + for line in lines: + product = line.get("product") or {} + execute_query( + """ + INSERT INTO invoice_error_finder_economic_invoice_lines ( + invoice_id, line_number, product_number, product_name, + description, quantity, unit_price, line_net_amount, + discount_percentage, source_raw + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb) + """, + ( + invoice_id, + line.get("lineNumber"), + self._safe_str(product.get("productNumber"), 100), + self._safe_str(product.get("name"), 500), + line.get("description"), + self._parse_amount(line.get("quantity")), + self._parse_amount(line.get("unitNetPrice")), + self._parse_amount(line.get("totalNetAmount")), + self._parse_amount(line.get("discountPercentage")), + str(line), + ), + ) + + def _create_import_run( + self, source_type: str, triggered_by_user_id: Optional[int], is_scheduled: bool + ) -> int: + # Treat 0 / invalid user ids as None (shadowadmin has id 0 and is not in users table) + user_id = triggered_by_user_id if triggered_by_user_id else None + row = execute_query_single( + """ + INSERT INTO invoice_error_finder_import_runs + (source_type, status, triggered_by_user_id, is_scheduled) + VALUES (%s, 'running', %s, %s) + RETURNING id + """, + (source_type, user_id, is_scheduled), + ) + return row["id"] + + def _complete_import_run( + self, + run_id: int, + status: str, + records_imported: int, + records_failed: int, + error_message: Optional[str] = None, + ) -> None: + execute_query( + """ + UPDATE invoice_error_finder_import_runs + SET status = %s, + completed_at = CURRENT_TIMESTAMP, + records_imported = %s, + records_failed = %s, + error_message = %s + WHERE id = %s + """, + (status, records_imported, records_failed, error_message, run_id), + ) + + @staticmethod + def _parse_date(value: Any) -> Optional[date]: + if not value: + return None + if isinstance(value, date): + return value + if isinstance(value, datetime): + return value.date() + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).date() + except Exception: + return None + + @staticmethod + def _parse_amount(value: Any) -> Optional[float]: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _safe_str(value: Any, max_length: int) -> Optional[str]: + if value is None: + return None + text = str(value) + return text[:max_length] diff --git a/app/modules/invoice_error_finder/services/simply_import_service.py b/app/modules/invoice_error_finder/services/simply_import_service.py new file mode 100644 index 0000000..2828e2e --- /dev/null +++ b/app/modules/invoice_error_finder/services/simply_import_service.py @@ -0,0 +1,248 @@ +""" +Simply CRM import service for Invoice Error Finder. +Fetches open sales orders from Simply CRM and persists them locally. +""" +import logging +from datetime import date +from typing import Dict, List, Optional, Any + +import json + +from app.services.simplycrm_service import SimplyCRMService +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) + +# Simply SalesOrder statuses considered "open / not yet invoiced" +OPEN_SOSTATUS = {"Created", "Approved", "Delivered"} + + +class SimplyImportService: + """Import open Simply CRM sales orders into invoice_error_finder staging tables.""" + + async def import_sales_orders( + self, + triggered_by_user_id: Optional[int] = None, + is_scheduled: bool = False, + ) -> Dict[str, Any]: + """Fetch all open SalesOrders from Simply CRM and persist them.""" + run_id = self._create_import_run("simply_sales_orders", triggered_by_user_id, is_scheduled) + try: + async with SimplyCRMService() as service: + raw_orders = await self._fetch_all_open_orders(service) + + logger.info("📥 Fetched %s open Simply CRM sales orders", len(raw_orders)) + + imported_count = 0 + failed_count = 0 + + for raw in raw_orders: + try: + self._persist_order(run_id, raw) + imported_count += 1 + except Exception as exc: + logger.error("❌ Failed to persist Simply order %s: %s", raw.get("id"), exc) + failed_count += 1 + + status = "success" if failed_count == 0 else "partial" + self._complete_import_run(run_id, status, imported_count, failed_count) + logger.info( + "✅ Simply import complete: %s imported, %s failed", + imported_count, + failed_count, + ) + return { + "import_run_id": run_id, + "records_imported": imported_count, + "records_failed": failed_count, + } + except Exception as exc: + self._complete_import_run(run_id, "failed", 0, 0, str(exc)) + logger.error("❌ Simply import failed: %s", exc, exc_info=True) + raise + + async def _fetch_all_open_orders(self, service: SimplyCRMService) -> List[Dict[str, Any]]: + """Fetch all open sales orders with pagination.""" + all_records: List[Dict[str, Any]] = [] + offset = 0 + limit = 100 + seen_ids = set() + + while True: + # Simply webservice does not support IN in all versions; fetch batches and filter in code + query = f"SELECT * FROM SalesOrder LIMIT {offset}, {limit};" + batch = await service.query(query) + if not batch: + break + + for record in batch: + record_id = record.get("id") + if record_id in seen_ids: + continue + seen_ids.add(record_id) + + status = record.get("sostatus") + if status in OPEN_SOSTATUS: + all_records.append(record) + + if len(batch) < limit: + break + offset += limit + + return all_records + + def _persist_order(self, run_id: int, raw: Dict[str, Any]) -> None: + source_record_id = str(raw.get("id") or "") + if not source_record_id: + return + + # Sales orders in Simply may have line items inline + line_items = raw.get("LineItems") or [] + if isinstance(line_items, str): + try: + line_items = json.loads(line_items) + except Exception: + line_items = [] + + # If there are explicit line items, create one row per line. Otherwise one summary row from the header. + rows_to_insert = [] + if line_items: + for line in line_items: + rows_to_insert.append({ + "product_number": self._extract_product_number(line), + "product_name": line.get("productname") or line.get("comment"), + "quantity": self._parse_amount(line.get("quantity")), + "unit_price": self._parse_amount(line.get("listprice") or line.get("unit_price")), + "total_amount": self._parse_amount(line.get("netprice") or line.get("total")), + }) + else: + rows_to_insert.append({ + "product_number": self._extract_product_number(raw), + "product_name": raw.get("comment") or raw.get("subject"), + "quantity": self._parse_amount(raw.get("quantity"), 0), + "unit_price": self._parse_amount(raw.get("listprice"), 0), + "total_amount": self._parse_amount(raw.get("hdnGrandTotal"), 0), + }) + + for row in rows_to_insert: + execute_query( + """ + INSERT INTO invoice_error_finder_simply_sales_orders ( + import_run_id, source_record_id, salesorder_no, account_id, + customer_name, customer_cvr, subject, status, product_number, + product_name, quantity, unit_price, total_amount, start_period, + end_period, source_raw + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb) + ON CONFLICT (source_record_id) + DO UPDATE SET + import_run_id = EXCLUDED.import_run_id, + salesorder_no = EXCLUDED.salesorder_no, + account_id = EXCLUDED.account_id, + customer_name = EXCLUDED.customer_name, + customer_cvr = EXCLUDED.customer_cvr, + subject = EXCLUDED.subject, + status = EXCLUDED.status, + product_number = EXCLUDED.product_number, + product_name = EXCLUDED.product_name, + quantity = EXCLUDED.quantity, + unit_price = EXCLUDED.unit_price, + total_amount = EXCLUDED.total_amount, + start_period = EXCLUDED.start_period, + end_period = EXCLUDED.end_period, + source_raw = EXCLUDED.source_raw + """, + ( + run_id, + source_record_id, + self._safe_str(raw.get("salesorder_no"), 80), + self._safe_str(raw.get("account_id"), 80), + self._safe_str(raw.get("accountname") or raw.get("customer_name"), 255), + self._safe_str(raw.get("siccode") or raw.get("vat_number"), 32), + raw.get("subject"), + raw.get("sostatus"), + self._safe_str(row["product_number"], 100), + self._safe_str(row["product_name"], 500), + row["quantity"], + row["unit_price"], + row["total_amount"], + self._parse_date(raw.get("start_period")), + self._parse_date(raw.get("end_period")), + json.dumps(raw, ensure_ascii=False, default=str), + ), + ) + + @staticmethod + def _extract_product_number(line: Dict[str, Any]) -> Optional[str]: + product = line.get("productid") or {} + if isinstance(product, dict): + return product.get("productnumber") or product.get("product_no") + # productid can also be a string like "14x842339"; strip the prefix and return the rest + if isinstance(product, str): + return product.split("x")[-1] if "x" in product else product + return line.get("product_no") or line.get("productnumber") + + def _create_import_run( + self, source_type: str, triggered_by_user_id: Optional[int], is_scheduled: bool + ) -> int: + # Treat 0 / invalid user ids as None (shadowadmin has id 0 and is not in users table) + user_id = triggered_by_user_id if triggered_by_user_id else None + row = execute_query_single( + """ + INSERT INTO invoice_error_finder_import_runs + (source_type, status, triggered_by_user_id, is_scheduled) + VALUES (%s, 'running', %s, %s) + RETURNING id + """, + (source_type, user_id, is_scheduled), + ) + return row["id"] + + def _complete_import_run( + self, + run_id: int, + status: str, + records_imported: int, + records_failed: int, + error_message: Optional[str] = None, + ) -> None: + execute_query( + """ + UPDATE invoice_error_finder_import_runs + SET status = %s, + completed_at = CURRENT_TIMESTAMP, + records_imported = %s, + records_failed = %s, + error_message = %s + WHERE id = %s + """, + (status, records_imported, records_failed, error_message, run_id), + ) + + @staticmethod + def _parse_date(value: Any) -> Optional[date]: + if not value: + return None + if isinstance(value, date): + return value + from datetime import datetime + if isinstance(value, datetime): + return value.date() + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).date() + except Exception: + return None + + @staticmethod + def _parse_amount(value: Any, default: Optional[float] = None) -> Optional[float]: + if value is None or value == "": + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _safe_str(value: Any, max_length: int) -> Optional[str]: + if value is None: + return None + return str(value)[:max_length] diff --git a/app/modules/invoice_error_finder/templates/dashboard.html b/app/modules/invoice_error_finder/templates/dashboard.html new file mode 100644 index 0000000..0f9f933 --- /dev/null +++ b/app/modules/invoice_error_finder/templates/dashboard.html @@ -0,0 +1,241 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Faktura-fejl-finder - BMC Hub{% endblock %} + +{% block content %} +
+
+
+

🔍 Faktura-fejl-finder

+

Find abonnementer, varer og salgsordrer som burde være faktureret, men ikke er blevet det.

+
+
+ + + + + Fejlliste + +
+
+ +
+ +
+
+
+
+
+
+
Manglende varelinjer
+

-

+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
Åbne ordrer uden faktura
+

-

+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
Antalsfald
+

-

+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
Prisændringer
+

-

+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
Klar til fakturering
+

-

+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
Fejl uden ansvarlig
+

-

+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
Seneste importkørsler
+
    +
  • Indlæser...
  • +
+
+
+ +
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/app/modules/invoice_error_finder/templates/issues.html b/app/modules/invoice_error_finder/templates/issues.html new file mode 100644 index 0000000..267c1df --- /dev/null +++ b/app/modules/invoice_error_finder/templates/issues.html @@ -0,0 +1,333 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Faktura-fejl-finder - Fejlliste - BMC Hub{% endblock %} + +{% block content %} +
+
+
+

📋 Faktura-fejl-liste

+

Gennemgå, godkend og håndter registrerede fakturaafvigelser.

+
+
+ + Tilbage til dashboard + + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
KundeFejltypeVareForventetFaktiskPeriodeBeløb/impactStatusAnsvarligHandling
Indlæser...
+
+
+
+ +
+ +
+
+
+ + +{% endblock %} diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index b93cd01..2f8b39e 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -1001,6 +1001,9 @@
  • Fastpris Aftaler
  • Abonnementer
  • Internetforbindelser
  • +
  • +
  • +
  • Faktura-fejl-finder
  • diff --git a/main.py b/main.py index 6b03e78..6957eab 100644 --- a/main.py +++ b/main.py @@ -147,6 +147,8 @@ from app.modules.drift.frontend import views as drift_views from app.modules.drift.backend.router import run_uptime_kuma_sync from app.modules.internet_connections.backend import router as internet_connections_api from app.modules.internet_connections.frontend import views as internet_connections_views +from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api +from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views from app.bug_reports.backend import router as bug_reports_api # Configure logging @@ -282,7 +284,20 @@ async def lifespan(app: FastAPI): replace_existing=True, ) logger.info("✅ Drift Uptime Kuma sync job scheduled (every 120 seconds)") - + + # Register Invoice Error Finder scheduled sync job (daily at 05:00) + from app.jobs.invoice_error_finder_sync import run_invoice_error_finder_sync + + backup_scheduler.scheduler.add_job( + func=run_invoice_error_finder_sync, + trigger=CronTrigger(hour=5, minute=0), + id='invoice_error_finder_sync', + name='Invoice Error Finder Sync', + max_instances=1, + replace_existing=True, + ) + logger.info("✅ Invoice Error Finder sync job scheduled (daily at 05:00)") + logger.info("✅ System initialized successfully") yield # Shutdown @@ -484,6 +499,7 @@ app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Bi app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"]) app.include_router(drift_api, prefix="/api/v1", tags=["Drift"]) app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"]) +app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"]) if settings.LINKS_MODULE_ENABLED: from app.modules.links.backend import router as links_api @@ -522,6 +538,7 @@ app.include_router(anydesk_views.router, tags=["Frontend"]) app.include_router(manual_views.router, tags=["Frontend"]) app.include_router(drift_views.router, tags=["Frontend"]) app.include_router(internet_connections_views.router, tags=["Frontend"]) +app.include_router(invoice_error_finder_views.router, tags=["Frontend"]) if settings.LINKS_MODULE_ENABLED: from app.modules.links.frontend import views as links_views diff --git a/migrations/1007_invoice_error_finder.sql b/migrations/1007_invoice_error_finder.sql new file mode 100644 index 0000000..bf27399 --- /dev/null +++ b/migrations/1007_invoice_error_finder.sql @@ -0,0 +1,58 @@ +-- Migration 1007: Invoice Error Finder module fixes and permissions + +-- Add stable Simply CRM source record id to issues so detection survives import-run re-imports +ALTER TABLE invoice_error_finder_issues + ADD COLUMN IF NOT EXISTS simply_source_record_id VARCHAR(80); + +CREATE INDEX IF NOT EXISTS idx_ief_issues_simply_source + ON invoice_error_finder_issues(simply_source_record_id); + +-- Module permissions +INSERT INTO permissions (code, description, category) VALUES +('invoice_error_finder.view', 'View invoice error finder dashboard and issues', 'invoice_error_finder'), +('invoice_error_finder.run_import', 'Trigger invoice/error data imports', 'invoice_error_finder'), +('invoice_error_finder.analyze', 'Run invoice error detection analysis', 'invoice_error_finder'), +('invoice_error_finder.update_status', 'Update issue status and assignee', 'invoice_error_finder'), +('invoice_error_finder.create_sag', 'Create/link sag from invoice error issue', 'invoice_error_finder'), +('invoice_error_finder.create_ordre_draft', 'Create ordre draft from invoice error issue', 'invoice_error_finder'), +('invoice_error_finder.ignore', 'Ignore invoice error issues', 'invoice_error_finder'), +('invoice_error_finder.admin', 'Administer invoice error finder settings', 'invoice_error_finder') +ON CONFLICT (code) DO NOTHING; + +-- Assign permissions to groups +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Administrators' + AND p.category = 'invoice_error_finder' +ON CONFLICT DO NOTHING; + +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Managers' + AND p.category = 'invoice_error_finder' +ON CONFLICT DO NOTHING; + +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Technicians' + AND p.code IN ( + 'invoice_error_finder.view', + 'invoice_error_finder.update_status', + 'invoice_error_finder.create_sag', + 'invoice_error_finder.create_ordre_draft' + ) +ON CONFLICT DO NOTHING; + +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Viewers' + AND p.code = 'invoice_error_finder.view' +ON CONFLICT DO NOTHING;