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
This commit is contained in:
Christian 2026-07-10 07:08:49 +02:00
parent 3311b8e590
commit 128a2b83d0
17 changed files with 2692 additions and 1 deletions

View File

@ -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

View File

@ -0,0 +1 @@
"""Invoice Error Finder module."""

View File

@ -0,0 +1 @@
"""Invoice Error Finder backend."""

View File

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

View File

@ -0,0 +1 @@
"""Invoice Error Finder frontend."""

View File

@ -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(),
},
)

View File

@ -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();

View File

@ -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
}
}
}

View File

@ -0,0 +1 @@
"""Invoice Error Finder services."""

View File

@ -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

View File

@ -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]

View File

@ -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]

View File

@ -0,0 +1,241 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Faktura-fejl-finder - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🔍 Faktura-fejl-finder</h1>
<p class="text-muted mb-0">Find abonnementer, varer og salgsordrer som burde være faktureret, men ikke er blevet det.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="importEconomic()">
<i class="bi bi-cloud-download me-1"></i>Importér e-conomic
</button>
<button class="btn btn-outline-primary" onclick="importSimply()">
<i class="bi bi-cloud-download me-1"></i>Importér Simply
</button>
<button class="btn btn-primary" onclick="runAnalysis()">
<i class="bi bi-search me-1"></i>Kør analyse
</button>
<a href="/invoice-error-finder/issues" class="btn btn-outline-secondary">
<i class="bi bi-list-ul me-1"></i>Fejlliste
</a>
</div>
</div>
<div id="importStatus" class="alert d-none mb-4"></div>
<div class="row g-4 mb-4">
<div class="col-12 col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Manglende varelinjer</h6>
<h2 class="mb-0" id="missingLineCount">-</h2>
</div>
<div class="bg-danger bg-opacity-10 p-2 rounded">
<i class="bi bi-file-x text-danger fs-4"></i>
</div>
</div>
<a href="/invoice-error-finder/issues?issue_type=missing_line" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Åbne ordrer uden faktura</h6>
<h2 class="mb-0" id="openOrderCount">-</h2>
</div>
<div class="bg-warning bg-opacity-10 p-2 rounded">
<i class="bi bi-cart-x text-warning fs-4"></i>
</div>
</div>
<a href="/invoice-error-finder/issues?issue_type=open_order_not_invoiced" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Antalsfald</h6>
<h2 class="mb-0" id="quantityDropCount">-</h2>
</div>
<div class="bg-info bg-opacity-10 p-2 rounded">
<i class="bi bi-graph-down-arrow text-info fs-4"></i>
</div>
</div>
<a href="/invoice-error-finder/issues?issue_type=quantity_drop" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Prisændringer</h6>
<h2 class="mb-0" id="priceChangeCount">-</h2>
</div>
<div class="bg-primary bg-opacity-10 p-2 rounded">
<i class="bi bi-currency-exchange text-primary fs-4"></i>
</div>
</div>
<a href="/invoice-error-finder/issues?issue_type=price_change" class="stretched-link"></a>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-12 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Klar til fakturering</h6>
<h2 class="mb-0" id="readyToInvoiceCount">-</h2>
</div>
<div class="bg-success bg-opacity-10 p-2 rounded">
<i class="bi bi-check-circle text-success fs-4"></i>
</div>
</div>
<a href="/invoice-error-finder/issues?status=ready_to_invoice" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Fejl uden ansvarlig</h6>
<h2 class="mb-0" id="noOwnerCount">-</h2>
</div>
<div class="bg-secondary bg-opacity-10 p-2 rounded">
<i class="bi bi-person-x text-secondary fs-4"></i>
</div>
</div>
<a href="/invoice-error-finder/issues?assigned_user_id=null" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Seneste importkørsler</h6>
<ul class="list-unstyled mb-0 small" id="lastImportRuns">
<li class="text-muted">Indlæser...</li>
</ul>
</div>
<div class="bg-light p-2 rounded">
<i class="bi bi-clock-history text-muted fs-4"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
async function loadDashboard() {
try {
const res = await fetch('/api/v1/invoice-error-finder/dashboard');
if (!res.ok) throw new Error('Kunne ikke hente dashboard');
const data = await res.json();
document.getElementById('missingLineCount').textContent = data.missing_line?.count ?? 0;
document.getElementById('openOrderCount').textContent = data.open_order_not_invoiced?.count ?? 0;
document.getElementById('quantityDropCount').textContent = data.quantity_drop?.count ?? 0;
document.getElementById('priceChangeCount').textContent = data.price_change?.count ?? 0;
document.getElementById('readyToInvoiceCount').textContent = data.ready_to_invoice?.count ?? 0;
document.getElementById('noOwnerCount').textContent = data.no_owner?.count ?? 0;
const runsList = document.getElementById('lastImportRuns');
if (data.last_import_runs && data.last_import_runs.length > 0) {
runsList.innerHTML = data.last_import_runs.map(run => {
const date = new Date(run.started_at).toLocaleString('da-DK');
const icon = run.status === 'success' ? '✅' : run.status === 'partial' ? '⚠️' : '❌';
return `<li>${icon} ${run.source_type}: ${run.records_imported} importeret (${date})</li>`;
}).join('');
} else {
runsList.innerHTML = '<li class="text-muted">Ingen importer endnu</li>';
}
} catch (err) {
console.error(err);
showStatus('Fejl ved indlæsning af dashboard: ' + err.message, 'danger');
}
}
async function importEconomic() {
setLoading(true);
try {
const res = await fetch('/api/v1/invoice-error-finder/import/economic', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Import fejlede');
showStatus(`e-conomic import færdig: ${data.records_imported} importeret, ${data.records_failed} fejlede.`, 'success');
await loadDashboard();
} catch (err) {
showStatus('e-conomic import fejlede: ' + err.message, 'danger');
} finally {
setLoading(false);
}
}
async function importSimply() {
setLoading(true);
try {
const res = await fetch('/api/v1/invoice-error-finder/import/simply', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Import fejlede');
showStatus(`Simply import færdig: ${data.records_imported} importeret, ${data.records_failed} fejlede.`, 'success');
await loadDashboard();
} catch (err) {
showStatus('Simply import fejlede: ' + err.message, 'danger');
} finally {
setLoading(false);
}
}
async function runAnalysis() {
setLoading(true);
try {
const res = await fetch('/api/v1/invoice-error-finder/analyze', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Analyse fejlede');
const counts = Object.entries(data.counts || {})
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
showStatus(`Analyse færdig for ${data.reference_month}. ${counts}`, 'success');
await loadDashboard();
} catch (err) {
showStatus('Analyse fejlede: ' + err.message, 'danger');
} finally {
setLoading(false);
}
}
function showStatus(message, type) {
const el = document.getElementById('importStatus');
el.className = `alert alert-${type} mb-4`;
el.textContent = message;
el.classList.remove('d-none');
}
function setLoading(loading) {
document.querySelectorAll('button').forEach(btn => btn.disabled = loading);
}
loadDashboard();
</script>
{% endblock %}

View File

@ -0,0 +1,333 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Faktura-fejl-finder - Fejlliste - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">📋 Faktura-fejl-liste</h1>
<p class="text-muted mb-0">Gennemgå, godkend og håndter registrerede fakturaafvigelser.</p>
</div>
<div class="d-flex gap-2">
<a href="/invoice-error-finder" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Tilbage til dashboard
</a>
<button class="btn btn-primary" onclick="loadIssues()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-3">
<label class="form-label">Status</label>
<select id="filterStatus" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
<option value="open" selected>Åben</option>
<option value="investigating">Under undersøgelse</option>
<option value="approved_change">Godkendt ændring</option>
<option value="error_found">Fejl fundet</option>
<option value="ready_to_invoice">Klar til fakturering</option>
<option value="invoiced">Faktureret</option>
<option value="ignored">Ignoreret</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Fejltype</label>
<select id="filterType" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
<option value="missing_line">Manglende varelinje</option>
<option value="open_order_not_invoiced">Åben salgsordre ikke faktureret</option>
<option value="quantity_drop">Antalsfald</option>
<option value="price_change">Prisændring</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Kunde</label>
<select id="filterCustomer" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Ansvarlig</label>
<select id="filterAssigned" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
<option value="null">Ikke tildelt</option>
{% for user in users %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div id="issuesStatus" class="alert d-none mb-4"></div>
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Kunde</th>
<th>Fejltype</th>
<th>Vare</th>
<th>Forventet</th>
<th>Faktisk</th>
<th>Periode</th>
<th>Beløb/impact</th>
<th>Status</th>
<th>Ansvarlig</th>
<th style="min-width: 220px;">Handling</th>
</tr>
</thead>
<tbody id="issuesBody">
<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="text-muted small" id="paginationInfo"></span>
<div class="btn-group" id="paginationControls"></div>
</div>
</div>
<script>
let currentOffset = 0;
const pageSize = 100;
const issueTypeLabels = {
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'
};
const statusLabels = {
open: 'Åben',
investigating: 'Under undersøgelse',
approved_change: 'Godkendt ændring',
error_found: 'Fejl fundet',
ready_to_invoice: 'Klar til fakturering',
invoiced: 'Faktureret',
ignored: 'Ignoreret'
};
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function formatCurrency(value) {
if (value == null) return '-';
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(value);
}
function formatNumber(value) {
if (value == null) return '-';
return new Intl.NumberFormat('da-DK').format(value);
}
function statusBadge(status) {
const map = {
open: 'bg-danger',
investigating: 'bg-warning text-dark',
approved_change: 'bg-info text-dark',
error_found: 'bg-danger',
ready_to_invoice: 'bg-success',
invoiced: 'bg-secondary',
ignored: 'bg-light text-dark'
};
const cls = map[status] || 'bg-light text-dark';
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
}
function buildQueryParams() {
const params = new URLSearchParams();
params.set('limit', pageSize);
params.set('offset', currentOffset);
const status = document.getElementById('filterStatus').value;
if (status) params.set('status', status);
const type = document.getElementById('filterType').value;
if (type) params.set('issue_type', type);
const customer = document.getElementById('filterCustomer').value;
if (customer) params.set('customer_id', customer);
const assigned = document.getElementById('filterAssigned').value;
if (assigned === 'null') {
params.set('assigned_user_id', '');
} else if (assigned) {
params.set('assigned_user_id', assigned);
}
return params;
}
async function loadIssues() {
const body = document.getElementById('issuesBody');
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>';
try {
const params = buildQueryParams();
const res = await fetch('/api/v1/invoice-error-finder/issues?' + params.toString());
if (!res.ok) throw new Error('Kunne ikke hente fejlliste');
const data = await res.json();
if (data.items.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Ingen fejl fundet</td></tr>';
} else {
body.innerHTML = data.items.map(issue => `
<tr>
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
<td>${escapeHtml(issue.product_name || issue.product_number || '-')}</td>
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
<td>${issue.reference_period_start || '-'}</td>
<td>${formatCurrency(issue.amount_impact)}</td>
<td>${statusBadge(issue.status)}</td>
<td>${escapeHtml(issue.assigned_user_name || '-')}</td>
<td>
<div class="btn-group btn-group-sm">
${renderActionButtons(issue)}
</div>
</td>
</tr>
`).join('');
}
document.getElementById('paginationInfo').textContent = `Viser ${data.items.length} af ${data.total} fejl`;
renderPagination(data.total);
} catch (err) {
body.innerHTML = `<tr><td colspan="10" class="text-danger text-center py-4">Fejl: ${escapeHtml(err.message)}</td></tr>`;
}
}
function renderActionButtons(issue) {
if (issue.status === 'ignored') return '<span class="text-muted small">Ignoreret</span>';
if (issue.status === 'invoiced') return '<span class="text-muted small">Faktureret</span>';
return `
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
<button class="btn btn-outline-primary" title="Klar til fakturering" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
`;
}
function renderPagination(total) {
const controls = document.getElementById('paginationControls');
const pages = Math.ceil(total / pageSize);
if (pages <= 1) {
controls.innerHTML = '';
return;
}
const currentPage = Math.floor(currentOffset / pageSize);
let html = `<button class="btn btn-outline-secondary" ${currentOffset === 0 ? 'disabled' : ''} onclick="goToPage(0)">«</button>`;
for (let i = 0; i < pages; i++) {
const active = i === currentPage ? 'active' : '';
html += `<button class="btn btn-outline-secondary ${active}" onclick="goToPage(${i})">${i + 1}</button>`;
}
html += `<button class="btn btn-outline-secondary" ${currentOffset + pageSize >= total ? 'disabled' : ''} onclick="goToPage(${pages - 1})">»</button>`;
controls.innerHTML = html;
}
function goToPage(page) {
currentOffset = page * pageSize;
loadIssues();
}
async function updateStatus(issueId, status) {
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) throw new Error('Opdatering fejlede');
showStatus('Status opdateret', 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
async function createSag(issueId) {
const titel = prompt('Titel på sag:');
if (!titel) return;
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
async function createOrdreDraft(issueId) {
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/create-ordre-draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opret kladde fejlede');
showStatus(`Ordrekladde #${data.draft_id} oprettet`, 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
async function ignoreIssue(issueId) {
if (!confirm('Ignorér denne fejl?')) return;
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/ignore`, {
method: 'POST'
});
if (!res.ok) throw new Error('Ignorér fejlede');
showStatus('Fejl ignoreret', 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
function showStatus(message, type) {
const el = document.getElementById('issuesStatus');
el.className = `alert alert-${type} mb-4`;
el.textContent = message;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 4000);
}
loadIssues();
</script>
{% endblock %}

View File

@ -1001,6 +1001,9 @@
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li data-menu-key="menu-okonomi-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Kontrol</h6></li>
<li data-menu-key="menu-okonomi-invoice-error-finder"><a class="dropdown-item py-2" href="/invoice-error-finder"><i class="bi bi-search me-2"></i>Faktura-fejl-finder</a></li>
</ul>
</li>
</ul>

17
main.py
View File

@ -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
@ -283,6 +285,19 @@ async def lifespan(app: FastAPI):
)
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

View File

@ -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;