- 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
567 lines
19 KiB
Python
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,
|
|
}
|