bmc_hub/app/modules/invoice_error_finder/backend/router.py
Christian 128a2b83d0 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
2026-07-10 07:08:49 +02:00

567 lines
19 KiB
Python

"""
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,
}