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:
parent
3311b8e590
commit
128a2b83d0
84
app/jobs/invoice_error_finder_sync.py
Normal file
84
app/jobs/invoice_error_finder_sync.py
Normal 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
|
||||
1
app/modules/invoice_error_finder/__init__.py
Normal file
1
app/modules/invoice_error_finder/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Invoice Error Finder module."""
|
||||
1
app/modules/invoice_error_finder/backend/__init__.py
Normal file
1
app/modules/invoice_error_finder/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Invoice Error Finder backend."""
|
||||
566
app/modules/invoice_error_finder/backend/router.py
Normal file
566
app/modules/invoice_error_finder/backend/router.py
Normal 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,
|
||||
}
|
||||
1
app/modules/invoice_error_finder/frontend/__init__.py
Normal file
1
app/modules/invoice_error_finder/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Invoice Error Finder frontend."""
|
||||
60
app/modules/invoice_error_finder/frontend/views.py
Normal file
60
app/modules/invoice_error_finder/frontend/views.py
Normal 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(),
|
||||
},
|
||||
)
|
||||
179
app/modules/invoice_error_finder/migrations/001_init.sql
Normal file
179
app/modules/invoice_error_finder/migrations/001_init.sql
Normal 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();
|
||||
17
app/modules/invoice_error_finder/module.json
Normal file
17
app/modules/invoice_error_finder/module.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
app/modules/invoice_error_finder/services/__init__.py
Normal file
1
app/modules/invoice_error_finder/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Invoice Error Finder services."""
|
||||
592
app/modules/invoice_error_finder/services/detection_service.py
Normal file
592
app/modules/invoice_error_finder/services/detection_service.py
Normal 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
|
||||
@ -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]
|
||||
@ -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]
|
||||
241
app/modules/invoice_error_finder/templates/dashboard.html
Normal file
241
app/modules/invoice_error_finder/templates/dashboard.html
Normal 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 %}
|
||||
333
app/modules/invoice_error_finder/templates/issues.html
Normal file
333
app/modules/invoice_error_finder/templates/issues.html
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
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 %}
|
||||
@ -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>
|
||||
|
||||
19
main.py
19
main.py
@ -147,6 +147,8 @@ from app.modules.drift.frontend import views as drift_views
|
||||
from app.modules.drift.backend.router import run_uptime_kuma_sync
|
||||
from app.modules.internet_connections.backend import router as internet_connections_api
|
||||
from app.modules.internet_connections.frontend import views as internet_connections_views
|
||||
from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api
|
||||
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
|
||||
from app.bug_reports.backend import router as bug_reports_api
|
||||
|
||||
# Configure logging
|
||||
@ -282,7 +284,20 @@ async def lifespan(app: FastAPI):
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("✅ Drift Uptime Kuma sync job scheduled (every 120 seconds)")
|
||||
|
||||
|
||||
# Register Invoice Error Finder scheduled sync job (daily at 05:00)
|
||||
from app.jobs.invoice_error_finder_sync import run_invoice_error_finder_sync
|
||||
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=run_invoice_error_finder_sync,
|
||||
trigger=CronTrigger(hour=5, minute=0),
|
||||
id='invoice_error_finder_sync',
|
||||
name='Invoice Error Finder Sync',
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("✅ Invoice Error Finder sync job scheduled (daily at 05:00)")
|
||||
|
||||
logger.info("✅ System initialized successfully")
|
||||
yield
|
||||
# Shutdown
|
||||
@ -484,6 +499,7 @@ app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Bi
|
||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
||||
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
|
||||
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.backend import router as links_api
|
||||
@ -522,6 +538,7 @@ app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
app.include_router(manual_views.router, tags=["Frontend"])
|
||||
app.include_router(drift_views.router, tags=["Frontend"])
|
||||
app.include_router(internet_connections_views.router, tags=["Frontend"])
|
||||
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
|
||||
58
migrations/1007_invoice_error_finder.sql
Normal file
58
migrations/1007_invoice_error_finder.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user