2025-12-07 03:29:54 +01:00
|
|
|
|
"""
|
|
|
|
|
|
Supplier Invoices Router - Leverandørfakturaer (Kassekladde)
|
|
|
|
|
|
Backend API for managing supplier invoices that integrate with e-conomic
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-02 13:48:14 +01:00
|
|
|
|
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
|
2025-12-15 12:28:12 +01:00
|
|
|
|
from pydantic import BaseModel
|
2025-12-07 03:29:54 +01:00
|
|
|
|
from typing import List, Dict, Optional
|
|
|
|
|
|
from datetime import datetime, date, timedelta
|
|
|
|
|
|
from decimal import Decimal
|
2026-07-09 23:44:30 +02:00
|
|
|
|
import ipaddress
|
2025-12-07 03:29:54 +01:00
|
|
|
|
from pathlib import Path
|
2026-06-11 09:45:11 +02:00
|
|
|
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single, table_has_column
|
2025-12-07 03:29:54 +01:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
from app.services.economic_service import get_economic_service
|
|
|
|
|
|
from app.services.ollama_service import ollama_service
|
|
|
|
|
|
from app.services.template_service import template_service
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
from app.services.invoice2data_service import get_invoice2data_service
|
2026-08-31 13:01:35 +02:00
|
|
|
|
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
|
2025-12-07 03:29:54 +01:00
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
2026-04-15 09:34:26 +02:00
|
|
|
|
import json
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
2026-04-12 09:26:35 +02:00
|
|
|
|
_PURCHASE_CASE_TYPE = "indkøb"
|
2026-07-28 14:18:24 +02:00
|
|
|
|
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
|
2026-08-31 13:01:35 +02:00
|
|
|
|
"address", "service_address", "monthly_cost", "sales_price", "technology",
|
|
|
|
|
|
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
|
|
|
|
|
|
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
|
|
|
|
|
|
"sla_price", "sla_status", "cidr", "contract_number", "range_added", "range_removed",
|
|
|
|
|
|
"range_monthly_cost", "range_sales_price",
|
2026-07-28 14:18:24 +02:00
|
|
|
|
}
|
2026-04-12 09:26:35 +02:00
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _v2_status_from_legacy(legacy_status: Optional[str]) -> str:
|
|
|
|
|
|
value = str(legacy_status or "").strip().lower()
|
|
|
|
|
|
if value in {"approved", "sent_to_economic"}:
|
|
|
|
|
|
return "godkendt"
|
|
|
|
|
|
if value == "paid":
|
|
|
|
|
|
return "betalt"
|
|
|
|
|
|
if value in {"cancelled", "credited", "rejected"}:
|
|
|
|
|
|
return "afvist"
|
|
|
|
|
|
return "modtaget"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _legacy_status_from_v2(v2_status: str) -> str:
|
|
|
|
|
|
mapping = {
|
|
|
|
|
|
"modtaget": "pending",
|
|
|
|
|
|
"godkendt": "approved",
|
|
|
|
|
|
"betalt": "paid",
|
|
|
|
|
|
"afvist": "cancelled",
|
|
|
|
|
|
}
|
|
|
|
|
|
return mapping.get(v2_status, "pending")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_v2_status(value: Optional[str]) -> str:
|
|
|
|
|
|
v2_status = str(value or "").strip().lower()
|
|
|
|
|
|
if not v2_status:
|
|
|
|
|
|
return "modtaget"
|
|
|
|
|
|
if v2_status not in SUPPLIER_STATUS_V2:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Ugyldig v2 status '{v2_status}'. Tilladte: {', '.join(SUPPLIER_STATUS_V2)}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return v2_status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _record_supplier_invoice_event(
|
|
|
|
|
|
invoice_id: int,
|
|
|
|
|
|
event_type: str,
|
|
|
|
|
|
from_status: Optional[str],
|
|
|
|
|
|
to_status: Optional[str],
|
|
|
|
|
|
payload: Optional[Dict] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO supplier_invoice_events (
|
|
|
|
|
|
supplier_invoice_id,
|
|
|
|
|
|
event_type,
|
|
|
|
|
|
from_status,
|
|
|
|
|
|
to_status,
|
|
|
|
|
|
payload_json
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s::jsonb)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
invoice_id,
|
|
|
|
|
|
event_type,
|
|
|
|
|
|
from_status,
|
|
|
|
|
|
to_status,
|
|
|
|
|
|
json.dumps(payload or {}),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as event_error:
|
|
|
|
|
|
# Keep core invoice transitions operational even before migration rollout.
|
|
|
|
|
|
logger.warning("⚠️ Could not persist supplier invoice event for %s: %s", invoice_id, event_error)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_invoice_status_v2(invoice_row: Dict) -> str:
|
|
|
|
|
|
explicit_v2 = invoice_row.get("workflow_status_v2")
|
|
|
|
|
|
if explicit_v2:
|
|
|
|
|
|
return _normalize_v2_status(explicit_v2)
|
|
|
|
|
|
return _v2_status_from_legacy(invoice_row.get("status"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _transition_invoice_status_v2(
|
|
|
|
|
|
invoice_id: int,
|
|
|
|
|
|
new_status_v2: str,
|
|
|
|
|
|
actor: Optional[str] = None,
|
|
|
|
|
|
reason: Optional[str] = None,
|
|
|
|
|
|
) -> Dict:
|
|
|
|
|
|
invoice = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, invoice_number, status, workflow_status_v2
|
|
|
|
|
|
FROM supplier_invoices
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(invoice_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not invoice:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
from_status_v2 = _get_invoice_status_v2(invoice)
|
|
|
|
|
|
to_status_v2 = _normalize_v2_status(new_status_v2)
|
|
|
|
|
|
|
|
|
|
|
|
if from_status_v2 == to_status_v2:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"invoice_number": invoice.get("invoice_number"),
|
|
|
|
|
|
"from_status": from_status_v2,
|
|
|
|
|
|
"to_status": to_status_v2,
|
|
|
|
|
|
"changed": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
allowed_transitions = {
|
|
|
|
|
|
"modtaget": {"godkendt", "afvist"},
|
|
|
|
|
|
"godkendt": {"betalt", "afvist"},
|
|
|
|
|
|
"betalt": set(),
|
|
|
|
|
|
"afvist": set(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if to_status_v2 not in allowed_transitions.get(from_status_v2, set()):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=(
|
|
|
|
|
|
f"Ugyldig statusovergang: {from_status_v2} -> {to_status_v2}. "
|
|
|
|
|
|
"Tilladte overgange følger workflow: modtaget -> godkendt -> betalt eller afvist."
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
legacy_status = _legacy_status_from_v2(to_status_v2)
|
|
|
|
|
|
approved_by = actor if to_status_v2 == "godkendt" else None
|
|
|
|
|
|
rejected_by = actor if to_status_v2 == "afvist" else None
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE supplier_invoices
|
|
|
|
|
|
SET status = %s,
|
|
|
|
|
|
workflow_status_v2 = %s,
|
|
|
|
|
|
approved_by = CASE WHEN %s IS NULL THEN approved_by ELSE %s END,
|
|
|
|
|
|
approved_at = CASE WHEN %s IS NULL THEN approved_at ELSE CURRENT_TIMESTAMP END,
|
|
|
|
|
|
rejected_by = CASE WHEN %s IS NULL THEN rejected_by ELSE %s END,
|
|
|
|
|
|
rejected_at = CASE WHEN %s IS NULL THEN rejected_at ELSE CURRENT_TIMESTAMP END,
|
|
|
|
|
|
rejection_reason = CASE WHEN %s IS NULL THEN rejection_reason ELSE %s END,
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
legacy_status,
|
|
|
|
|
|
to_status_v2,
|
|
|
|
|
|
approved_by,
|
|
|
|
|
|
approved_by,
|
|
|
|
|
|
approved_by,
|
|
|
|
|
|
rejected_by,
|
|
|
|
|
|
rejected_by,
|
|
|
|
|
|
rejected_by,
|
|
|
|
|
|
reason,
|
|
|
|
|
|
reason,
|
|
|
|
|
|
invoice_id,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_record_supplier_invoice_event(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
event_type="status_transition",
|
|
|
|
|
|
from_status=from_status_v2,
|
|
|
|
|
|
to_status=to_status_v2,
|
|
|
|
|
|
payload={"actor": actor, "reason": reason},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"invoice_number": invoice.get("invoice_number"),
|
|
|
|
|
|
"from_status": from_status_v2,
|
|
|
|
|
|
"to_status": to_status_v2,
|
|
|
|
|
|
"changed": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:26:35 +02:00
|
|
|
|
|
|
|
|
|
|
def _resolve_procurement_customer_id() -> int:
|
|
|
|
|
|
"""Find customer used for internally-owned procurement cases."""
|
|
|
|
|
|
configured_id = getattr(settings, "PROCUREMENT_CASE_CUSTOMER_ID", None)
|
|
|
|
|
|
if configured_id:
|
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
|
"SELECT id FROM customers WHERE id = %s AND is_active = true",
|
|
|
|
|
|
(configured_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return int(row["id"])
|
|
|
|
|
|
|
|
|
|
|
|
bmc_row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM customers
|
|
|
|
|
|
WHERE is_active = true
|
|
|
|
|
|
AND LOWER(name) LIKE %s
|
|
|
|
|
|
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
("%bmc%", "%bmc networks%")
|
|
|
|
|
|
)
|
|
|
|
|
|
if bmc_row:
|
|
|
|
|
|
return int(bmc_row["id"])
|
|
|
|
|
|
|
|
|
|
|
|
fallback = execute_query_single(
|
|
|
|
|
|
"SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1"
|
|
|
|
|
|
)
|
|
|
|
|
|
if fallback:
|
|
|
|
|
|
return int(fallback["id"])
|
|
|
|
|
|
|
|
|
|
|
|
raise ValueError("No active customer available for procurement case creation")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _resolve_group_id_by_name_tokens(tokens: List[str]) -> Optional[int]:
|
|
|
|
|
|
lowered = [str(token or "").strip().lower() for token in tokens if str(token or "").strip()]
|
|
|
|
|
|
if not lowered:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
clauses = " OR ".join(["LOWER(name) LIKE %s" for _ in lowered])
|
|
|
|
|
|
params = tuple(f"%{token}%" for token in lowered)
|
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM groups
|
|
|
|
|
|
WHERE {clauses}
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
params,
|
|
|
|
|
|
)
|
|
|
|
|
|
return int(row["id"]) if row and row.get("id") else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_internet_change_case(
|
|
|
|
|
|
connection_id: int,
|
|
|
|
|
|
invoice_number: str,
|
|
|
|
|
|
reference: str,
|
|
|
|
|
|
connection_name: str,
|
|
|
|
|
|
owner_customer_id: Optional[int],
|
|
|
|
|
|
changes: Dict[str, Dict[str, object]],
|
|
|
|
|
|
) -> Optional[int]:
|
2026-08-31 13:01:35 +02:00
|
|
|
|
outcome = ensure_external_change_case(
|
|
|
|
|
|
connection_id=connection_id,
|
|
|
|
|
|
source_type="globalconnect_invoice",
|
|
|
|
|
|
source_key=str(invoice_number),
|
|
|
|
|
|
source_label=f"GlobalConnect faktura {invoice_number}",
|
|
|
|
|
|
source_url="/billing/supplier-invoices",
|
|
|
|
|
|
reference=reference,
|
|
|
|
|
|
connection_name=connection_name,
|
|
|
|
|
|
provider="GlobalConnect",
|
|
|
|
|
|
owner_customer_id=owner_customer_id,
|
|
|
|
|
|
changes=changes,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
)
|
2026-08-31 13:01:35 +02:00
|
|
|
|
return outcome.get("case_id")
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 09:26:35 +02:00
|
|
|
|
def _ensure_case_for_supplier_invoice(
|
|
|
|
|
|
invoice_id: int,
|
|
|
|
|
|
invoice_number: str,
|
|
|
|
|
|
vendor_name: Optional[str],
|
|
|
|
|
|
total_amount,
|
|
|
|
|
|
currency: Optional[str],
|
|
|
|
|
|
file_id: Optional[int] = None,
|
|
|
|
|
|
) -> Optional[int]:
|
|
|
|
|
|
"""Create and link a procurement case if missing for a supplier invoice."""
|
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
|
"SELECT sag_id FROM supplier_invoices WHERE id = %s",
|
|
|
|
|
|
(invoice_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not existing:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
current_sag_id = existing.get("sag_id")
|
|
|
|
|
|
if current_sag_id:
|
|
|
|
|
|
return int(current_sag_id)
|
|
|
|
|
|
|
|
|
|
|
|
customer_id = _resolve_procurement_customer_id()
|
|
|
|
|
|
vendor_label = (vendor_name or "Ukendt leverandør").strip()
|
|
|
|
|
|
currency_label = (currency or "DKK").strip()
|
|
|
|
|
|
amount_label = f"{Decimal(total_amount or 0):.2f}"
|
|
|
|
|
|
|
|
|
|
|
|
case_title = f"Leverandørfaktura {invoice_number} - {vendor_label}"
|
|
|
|
|
|
case_description = (
|
|
|
|
|
|
"Auto-oprettet fra leverandørfaktura\n"
|
|
|
|
|
|
f"Faktura: {invoice_number}\n"
|
|
|
|
|
|
f"Leverandør: {vendor_label}\n"
|
|
|
|
|
|
f"Beløb: {amount_label} {currency_label}\n"
|
|
|
|
|
|
f"Invoice ID: {invoice_id}\n"
|
|
|
|
|
|
f"Fil ID: {file_id or '-'}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
try:
|
|
|
|
|
|
case_row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id, 1)
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as insert_error:
|
|
|
|
|
|
# Some environments use sag_sager without a `type` column.
|
|
|
|
|
|
if 'column "type"' not in str(insert_error):
|
|
|
|
|
|
raise
|
|
|
|
|
|
case_row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO sag_sager (titel, beskrivelse, status, customer_id, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(case_title, case_description, "åben", customer_id, 1)
|
|
|
|
|
|
)
|
2026-04-12 09:26:35 +02:00
|
|
|
|
if not case_row:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
sag_id = int(case_row["id"])
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE supplier_invoices SET sag_id = %s WHERE id = %s",
|
|
|
|
|
|
(sag_id, invoice_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
sag_id,
|
|
|
|
|
|
"Invoice Bot",
|
|
|
|
|
|
(
|
|
|
|
|
|
"🔗 Automatisk oprettet fra leverandørfaktura\n"
|
|
|
|
|
|
f"Faktura: {invoice_number}\n"
|
|
|
|
|
|
f"Leverandør: {vendor_label}\n"
|
|
|
|
|
|
f"Beløb: {amount_label} {currency_label}\n"
|
|
|
|
|
|
f"Invoice ID: {invoice_id}"
|
|
|
|
|
|
),
|
|
|
|
|
|
True,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as comment_error:
|
|
|
|
|
|
logger.warning("⚠️ Could not create case comment for supplier invoice %s: %s", invoice_id, comment_error)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("✅ Linked supplier invoice %s to SAG-%s", invoice_id, sag_id)
|
|
|
|
|
|
return sag_id
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _append_amount_validation_case_note(
|
|
|
|
|
|
sag_id: Optional[int],
|
|
|
|
|
|
invoice_id: int,
|
|
|
|
|
|
invoice_number: str,
|
|
|
|
|
|
validation_details: Optional[Dict],
|
|
|
|
|
|
validation_warning: Optional[str],
|
|
|
|
|
|
vat_warning: Optional[str],
|
|
|
|
|
|
file_id: Optional[int] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if not sag_id:
|
|
|
|
|
|
return
|
|
|
|
|
|
if not validation_warning and not vat_warning:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
details = validation_details or {}
|
|
|
|
|
|
line_sum = details.get("line_sum")
|
|
|
|
|
|
subtotal = details.get("subtotal")
|
|
|
|
|
|
difference = details.get("difference")
|
|
|
|
|
|
vat_amount = details.get("vat_amount")
|
|
|
|
|
|
vat_expected = details.get("vat_expected")
|
|
|
|
|
|
vat_difference = details.get("vat_difference")
|
|
|
|
|
|
|
|
|
|
|
|
note_lines = [
|
|
|
|
|
|
"Automatisk valideringsadvarsel på leverandørfaktura",
|
|
|
|
|
|
f"Faktura: {invoice_number}",
|
|
|
|
|
|
f"Invoice ID: {invoice_id}",
|
|
|
|
|
|
f"Fil ID: {file_id or '-'}",
|
|
|
|
|
|
]
|
|
|
|
|
|
if validation_warning:
|
|
|
|
|
|
note_lines.append(f"Subtotal-advarsel: {validation_warning}")
|
|
|
|
|
|
if line_sum is not None or subtotal is not None or difference is not None:
|
|
|
|
|
|
note_lines.append(
|
|
|
|
|
|
f"Beregnet linjesum: {line_sum if line_sum is not None else '-'} · Subtotal: {subtotal if subtotal is not None else '-'} · Afvigelse: {difference if difference is not None else '-'}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if vat_warning:
|
|
|
|
|
|
note_lines.append(f"Moms-advarsel: {vat_warning}")
|
|
|
|
|
|
if vat_amount is not None or vat_expected is not None or vat_difference is not None:
|
|
|
|
|
|
note_lines.append(
|
|
|
|
|
|
f"Registreret moms: {vat_amount if vat_amount is not None else '-'} · Forventet moms: {vat_expected if vat_expected is not None else '-'} · Afvigelse: {vat_difference if vat_difference is not None else '-'}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
note_body = "\n".join(note_lines)
|
|
|
|
|
|
try:
|
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM sag_kommentarer
|
|
|
|
|
|
WHERE sag_id = %s
|
|
|
|
|
|
AND indhold = %s
|
|
|
|
|
|
ORDER BY id DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(sag_id, note_body),
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(sag_id, "Invoice Bot", note_body, True),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as comment_error:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"⚠️ Could not persist amount validation case note for supplier invoice %s / SAG-%s: %s",
|
|
|
|
|
|
invoice_id,
|
|
|
|
|
|
sag_id,
|
|
|
|
|
|
comment_error,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
def _to_decimal(value, default: Decimal = Decimal("0")) -> Decimal:
|
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
|
return default
|
|
|
|
|
|
try:
|
|
|
|
|
|
return Decimal(str(value))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _normalize_change_value(value):
|
|
|
|
|
|
if isinstance(value, Decimal):
|
|
|
|
|
|
return str(value.quantize(Decimal("0.01")))
|
|
|
|
|
|
if isinstance(value, float):
|
|
|
|
|
|
return f"{value:.2f}"
|
|
|
|
|
|
if isinstance(value, (datetime, date)):
|
|
|
|
|
|
return value.isoformat()
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
|
return text or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _append_connection_history(connection_id: int, event_type: str, summary: str, details: Dict) -> None:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_history (connection_id, event_type, summary, details)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s::jsonb)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(connection_id, event_type, summary, json.dumps(details or {})),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_change_dict(existing: Dict, updated: Dict, fields: List[str]) -> Dict[str, Dict[str, object]]:
|
|
|
|
|
|
changes: Dict[str, Dict[str, object]] = {}
|
|
|
|
|
|
for field in fields:
|
|
|
|
|
|
before = _normalize_change_value(existing.get(field))
|
|
|
|
|
|
after = _normalize_change_value(updated.get(field))
|
|
|
|
|
|
if before != after:
|
|
|
|
|
|
changes[field] = {"from": before, "to": after}
|
|
|
|
|
|
return changes
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
def _extract_lines_from_llm_payload(extraction_row: Optional[Dict]) -> List[Dict]:
|
|
|
|
|
|
if not extraction_row:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
raw_payload = extraction_row.get("llm_response_json")
|
|
|
|
|
|
if not raw_payload:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = raw_payload if isinstance(raw_payload, dict) else json.loads(str(raw_payload))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
lines = payload.get("lines")
|
|
|
|
|
|
if not isinstance(lines, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
normalized = []
|
|
|
|
|
|
for idx, line in enumerate(lines, start=1):
|
|
|
|
|
|
if not isinstance(line, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
normalized.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"line_number": line.get("line_number") or idx,
|
|
|
|
|
|
"sku": line.get("sku") or line.get("item_number") or line.get("article_number"),
|
|
|
|
|
|
"description": line.get("description") or line.get("name") or "",
|
|
|
|
|
|
"quantity": line.get("quantity") or 1,
|
|
|
|
|
|
"unit_price": line.get("unit_price") or 0,
|
|
|
|
|
|
"line_total": line.get("line_total") or line.get("amount") or 0,
|
|
|
|
|
|
"vat_rate": line.get("vat_rate") or 25.0,
|
|
|
|
|
|
"vat_amount": line.get("vat_amount") or 0,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"ip_address": line.get("ip_address"),
|
|
|
|
|
|
"contract_number": line.get("contract_number"),
|
|
|
|
|
|
"provider_reference": line.get("provider_reference"),
|
|
|
|
|
|
"customer_reference": line.get("customer_reference"),
|
|
|
|
|
|
"circuit_id": line.get("circuit_id"),
|
|
|
|
|
|
"end_customer_name": line.get("end_customer_name"),
|
|
|
|
|
|
"period_start": line.get("period_start"),
|
|
|
|
|
|
"period_end": line.get("period_end"),
|
|
|
|
|
|
"service_address": line.get("service_address"),
|
|
|
|
|
|
"location_street": line.get("location_street"),
|
|
|
|
|
|
"location_zip": line.get("location_zip"),
|
|
|
|
|
|
"location_city": line.get("location_city"),
|
2026-04-15 09:34:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_extraction_lines(extraction_row: Optional[Dict]) -> List[Dict]:
|
|
|
|
|
|
if not extraction_row:
|
|
|
|
|
|
return []
|
|
|
|
|
|
extraction_id = extraction_row.get("extraction_id")
|
|
|
|
|
|
if not extraction_id:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
db_lines = execute_query(
|
|
|
|
|
|
"""
|
2026-07-09 23:44:30 +02:00
|
|
|
|
SELECT
|
|
|
|
|
|
line_number, sku, description, quantity, unit_price, line_total, vat_rate, vat_amount,
|
|
|
|
|
|
ip_address, contract_number, provider_reference, customer_reference, circuit_id,
|
|
|
|
|
|
end_customer_name, period_start, period_end, service_address,
|
|
|
|
|
|
location_street, location_zip, location_city
|
2026-04-15 09:34:26 +02:00
|
|
|
|
FROM extraction_lines
|
|
|
|
|
|
WHERE extraction_id = %s
|
|
|
|
|
|
ORDER BY line_number
|
|
|
|
|
|
""",
|
|
|
|
|
|
(extraction_id,),
|
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
|
|
if db_lines:
|
|
|
|
|
|
return db_lines
|
|
|
|
|
|
|
|
|
|
|
|
return _extract_lines_from_llm_payload(extraction_row)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _compose_supplier_line_description(line: Dict) -> str:
|
|
|
|
|
|
description = str(line.get("description") or "").strip()
|
|
|
|
|
|
extras = []
|
|
|
|
|
|
for key in ("provider_reference", "circuit_id", "contract_number"):
|
|
|
|
|
|
value = str(line.get(key) or "").strip()
|
|
|
|
|
|
if value and value not in extras:
|
|
|
|
|
|
extras.append(value)
|
|
|
|
|
|
|
|
|
|
|
|
if line.get("service_address"):
|
|
|
|
|
|
extras.append(str(line.get("service_address")).strip())
|
|
|
|
|
|
elif line.get("location_street") and line.get("location_zip") and line.get("location_city"):
|
|
|
|
|
|
extras.append(f"{line.get('location_street')}, {line.get('location_zip')} {line.get('location_city')}")
|
|
|
|
|
|
|
|
|
|
|
|
if line.get("period_start") and line.get("period_end"):
|
|
|
|
|
|
extras.append(f"{line.get('period_start')} til {line.get('period_end')}")
|
|
|
|
|
|
|
|
|
|
|
|
if not extras:
|
|
|
|
|
|
return description
|
|
|
|
|
|
return f"{description} ({' · '.join(extras)})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_globalconnect_extraction(extraction_row: Optional[Dict]) -> bool:
|
|
|
|
|
|
if not extraction_row:
|
|
|
|
|
|
return False
|
|
|
|
|
|
vendor_name = str(extraction_row.get("vendor_name") or "").lower()
|
|
|
|
|
|
if "globalconnect" in vendor_name:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = extraction_row.get("llm_response_json")
|
|
|
|
|
|
if isinstance(payload, str):
|
|
|
|
|
|
payload = json.loads(payload)
|
|
|
|
|
|
issuer = str((payload or {}).get("issuer") or "").lower()
|
|
|
|
|
|
template = str((payload or {}).get("template") or "").lower()
|
|
|
|
|
|
return "globalconnect" in issuer or template == "dk.globalconnect"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_company_name(value: Optional[str]) -> str:
|
|
|
|
|
|
normalized = str(value or "").upper()
|
|
|
|
|
|
replacements = {
|
|
|
|
|
|
"Æ": "AE",
|
|
|
|
|
|
"Ø": "OE",
|
|
|
|
|
|
"Å": "AA",
|
|
|
|
|
|
"&": " OG ",
|
|
|
|
|
|
"/": " ",
|
|
|
|
|
|
}
|
|
|
|
|
|
for source, target in replacements.items():
|
|
|
|
|
|
normalized = normalized.replace(source, target)
|
|
|
|
|
|
normalized = re.sub(r"[^A-Z0-9 ]+", " ", normalized)
|
|
|
|
|
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_service_address(line: Dict) -> Optional[str]:
|
|
|
|
|
|
if line.get("service_address"):
|
|
|
|
|
|
return str(line.get("service_address")).strip()
|
|
|
|
|
|
if line.get("location_street") and line.get("location_zip") and line.get("location_city"):
|
|
|
|
|
|
return f"{line.get('location_street')}, {line.get('location_zip')} {line.get('location_city')}"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_provider_reference(value: Optional[str]) -> str:
|
|
|
|
|
|
raw = str(value or "").strip().upper()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return re.sub(r"[^A-Z0-9]", "", raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
|
def _provider_reference_match_keys(value: Optional[str]) -> set[str]:
|
|
|
|
|
|
normalized = _normalize_provider_reference(value)
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
keys = {normalized}
|
|
|
|
|
|
if normalized.startswith("DSLEB"):
|
|
|
|
|
|
keys.add(normalized[3:])
|
|
|
|
|
|
elif normalized.startswith("EB"):
|
|
|
|
|
|
keys.add(f"DSL{normalized}")
|
|
|
|
|
|
return keys
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_unique_globalconnect_connection_by_reference(reference: Optional[str]) -> Optional[int]:
|
|
|
|
|
|
target_keys = _provider_reference_match_keys(reference)
|
|
|
|
|
|
if not target_keys:
|
|
|
|
|
|
return None
|
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, circuit_number
|
|
|
|
|
|
FROM internet_connections_connections
|
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
|
AND provider ILIKE 'GlobalConnect%%'
|
|
|
|
|
|
AND NULLIF(BTRIM(circuit_number), '') IS NOT NULL
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
"""
|
|
|
|
|
|
) or []
|
|
|
|
|
|
matches = [row for row in rows if target_keys & _provider_reference_match_keys(row.get("circuit_number"))]
|
|
|
|
|
|
return int(matches[0]["id"]) if len(matches) == 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_pending_connection_for_ip_reference(line: Dict, invoice_number: str) -> Optional[int]:
|
|
|
|
|
|
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
|
|
|
|
|
|
normalized_reference = _normalize_provider_reference(display_reference)
|
|
|
|
|
|
if not normalized_reference:
|
|
|
|
|
|
return None
|
|
|
|
|
|
existing_id = _find_unique_globalconnect_connection_by_reference(display_reference)
|
|
|
|
|
|
if existing_id:
|
|
|
|
|
|
return existing_id
|
|
|
|
|
|
service_address = _build_service_address(line)
|
|
|
|
|
|
connection_id = execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_connections (
|
|
|
|
|
|
name, provider, customer_id, address, status, monthly_cost, sales_price,
|
|
|
|
|
|
technology, connection_type, circuit_number, notes, allocation_model,
|
|
|
|
|
|
value_type, value_label
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, NULL, %s, 'pending', 0, 0, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
f"Afventer mapping · {display_reference}",
|
|
|
|
|
|
"GlobalConnect A/S",
|
|
|
|
|
|
service_address,
|
|
|
|
|
|
"Internet",
|
|
|
|
|
|
"Internet",
|
|
|
|
|
|
display_reference,
|
|
|
|
|
|
f"Oprettet fra IP-range på faktura {invoice_number}. Kunde tildeles aldrig automatisk. Serviceadresse kræver manuel kontrol.",
|
|
|
|
|
|
"dedicated",
|
|
|
|
|
|
"other",
|
|
|
|
|
|
"Afventer manuel klassifikation",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
return int(connection_id) if connection_id else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _canonical_ip_network(value: Optional[str]) -> str:
|
|
|
|
|
|
"""Canonicalize invoice IP/CIDR values before matching or persistence."""
|
|
|
|
|
|
raw = re.sub(r"\s+", "", str(value or "").strip())
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return str(ipaddress.ip_network(raw, strict=False))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str:
|
|
|
|
|
|
parts = [f"Afventer mapping for {reference}."]
|
|
|
|
|
|
if end_customer_name:
|
|
|
|
|
|
parts.append(f"Udtrukket kunde: {end_customer_name}.")
|
|
|
|
|
|
if service_address:
|
|
|
|
|
|
parts.append(f"Udtrukket adresse: {service_address}.")
|
|
|
|
|
|
parts.append("Kræver manuel kontrol før forbindelsen kan anses som korrekt.")
|
|
|
|
|
|
return " ".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_confident_globalconnect_mapping(matched_customer: Optional[Dict], service_address: Optional[str]) -> bool:
|
|
|
|
|
|
return bool(matched_customer and service_address)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_internal_bmc_customer() -> Optional[Dict]:
|
|
|
|
|
|
preferred = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, name, address, postal_code, city
|
|
|
|
|
|
FROM customers
|
|
|
|
|
|
WHERE is_active = true
|
|
|
|
|
|
AND lower(name) = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
("bmc networks",),
|
|
|
|
|
|
)
|
|
|
|
|
|
if preferred:
|
|
|
|
|
|
return dict(preferred)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
customer_id = _resolve_procurement_customer_id()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
fallback = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, name, address, postal_code, city
|
|
|
|
|
|
FROM customers
|
|
|
|
|
|
WHERE id = %s AND is_active = true
|
|
|
|
|
|
""",
|
|
|
|
|
|
(customer_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
return dict(fallback) if fallback else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _should_assign_internal_bmc_owner(
|
|
|
|
|
|
lines: List[Dict],
|
|
|
|
|
|
matched_customer: Optional[Dict],
|
|
|
|
|
|
service_address: Optional[str],
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
if matched_customer:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if any(str(line.get("end_customer_name") or "").strip() for line in lines):
|
|
|
|
|
|
return False
|
2026-08-30 14:34:43 +02:00
|
|
|
|
description = " ".join(str(line.get("description") or "").lower() for line in lines)
|
|
|
|
|
|
explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit")
|
|
|
|
|
|
return any(marker in description for marker in explicit_shared_markers)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str:
|
|
|
|
|
|
if internal_owner and not matched_customer:
|
|
|
|
|
|
return "delefiber"
|
|
|
|
|
|
return "other"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_active_customers_for_matching() -> List[Dict]:
|
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, name, address, postal_code, city
|
|
|
|
|
|
FROM customers
|
|
|
|
|
|
WHERE is_active = true
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
"""
|
|
|
|
|
|
) or []
|
|
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) -> Optional[Dict]:
|
|
|
|
|
|
end_customer_name = str(line.get("end_customer_name") or "").strip()
|
|
|
|
|
|
service_address = _build_service_address(line) or ""
|
|
|
|
|
|
normalized_target = _normalize_company_name(end_customer_name)
|
|
|
|
|
|
address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()]
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
best_matches = []
|
2026-07-09 23:44:30 +02:00
|
|
|
|
best_score = 0
|
|
|
|
|
|
for customer in customers:
|
|
|
|
|
|
customer_name = str(customer.get("name") or "").strip()
|
|
|
|
|
|
normalized_customer = _normalize_company_name(customer_name)
|
|
|
|
|
|
score = 0
|
|
|
|
|
|
|
|
|
|
|
|
if normalized_target and normalized_customer:
|
|
|
|
|
|
if normalized_target == normalized_customer:
|
|
|
|
|
|
score = 100
|
|
|
|
|
|
elif normalized_target in normalized_customer or normalized_customer in normalized_target:
|
|
|
|
|
|
score = 80
|
|
|
|
|
|
else:
|
|
|
|
|
|
target_tokens = set(normalized_target.split())
|
|
|
|
|
|
customer_tokens = set(normalized_customer.split())
|
|
|
|
|
|
overlap = target_tokens & customer_tokens
|
|
|
|
|
|
if overlap:
|
|
|
|
|
|
score = max(score, min(len(overlap) * 15, 60))
|
|
|
|
|
|
|
|
|
|
|
|
if service_address:
|
|
|
|
|
|
customer_address = str(customer.get("address") or "").upper()
|
|
|
|
|
|
customer_city = str(customer.get("city") or "").upper()
|
|
|
|
|
|
customer_postal = str(customer.get("postal_code") or "").upper()
|
|
|
|
|
|
address_score = 0
|
|
|
|
|
|
if customer_address and customer_address in service_address.upper():
|
|
|
|
|
|
address_score += 35
|
|
|
|
|
|
if customer_city and customer_city in service_address.upper():
|
|
|
|
|
|
address_score += 20
|
|
|
|
|
|
if customer_postal and customer_postal in service_address.upper():
|
|
|
|
|
|
address_score += 20
|
|
|
|
|
|
if address_parts and customer_address:
|
|
|
|
|
|
overlap = sum(1 for part in address_parts if len(part) > 2 and part in customer_address)
|
|
|
|
|
|
address_score += min(overlap * 5, 15)
|
|
|
|
|
|
score = max(score, address_score)
|
|
|
|
|
|
|
|
|
|
|
|
if score > best_score:
|
|
|
|
|
|
best_score = score
|
2026-07-28 14:18:24 +02:00
|
|
|
|
best_matches = [customer]
|
|
|
|
|
|
elif score == best_score and score > 0:
|
|
|
|
|
|
best_matches.append(customer)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
if best_score < 50 or not best_matches:
|
|
|
|
|
|
return None
|
|
|
|
|
|
# An address shared by several tenants is not enough to select a customer.
|
|
|
|
|
|
# Require a unique winner unless the invoice also supplied a customer name.
|
|
|
|
|
|
if len(best_matches) > 1 and not normalized_target:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return best_matches[0]
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_ip_range_line(line: Dict) -> bool:
|
|
|
|
|
|
description = str(line.get("description") or "").lower()
|
|
|
|
|
|
return bool(line.get("ip_address")) and (
|
|
|
|
|
|
"ipv4" in description
|
|
|
|
|
|
or "ipv6" in description
|
|
|
|
|
|
or "ip-adress" in description
|
|
|
|
|
|
or "ip adress" in description
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_connection_component(line: Dict) -> bool:
|
|
|
|
|
|
if _looks_like_ip_range_line(line):
|
|
|
|
|
|
return False
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
if not reference:
|
|
|
|
|
|
return False
|
|
|
|
|
|
description = str(line.get("description") or "").lower()
|
|
|
|
|
|
if not description:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return any(
|
|
|
|
|
|
token in description
|
|
|
|
|
|
for token in (
|
|
|
|
|
|
"fiber",
|
|
|
|
|
|
"adsl",
|
|
|
|
|
|
"vdsl",
|
|
|
|
|
|
"dsl",
|
|
|
|
|
|
"internet",
|
|
|
|
|
|
"mpls",
|
|
|
|
|
|
"rackskab",
|
|
|
|
|
|
"datacenter",
|
|
|
|
|
|
"sla",
|
|
|
|
|
|
"forbindelse",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _can_create_connection_from_line(line: Dict) -> bool:
|
|
|
|
|
|
if not _looks_like_connection_component(line):
|
|
|
|
|
|
return False
|
|
|
|
|
|
service_address = _build_service_address(line)
|
|
|
|
|
|
if not service_address:
|
|
|
|
|
|
return False
|
|
|
|
|
|
description = str(line.get("description") or "")
|
|
|
|
|
|
speed_mbps, upload_mbps, download_mbps = _infer_speed_profile(description)
|
|
|
|
|
|
return bool(speed_mbps or upload_mbps or download_mbps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_connection_type(description: str) -> str:
|
|
|
|
|
|
desc = description.lower()
|
|
|
|
|
|
if "mpls" in desc:
|
|
|
|
|
|
return "mpls"
|
|
|
|
|
|
if "rackskab" in desc or "datacenter" in desc:
|
|
|
|
|
|
return "datacenter"
|
|
|
|
|
|
if "adsl" in desc or "vdsl" in desc or "dsl" in desc:
|
|
|
|
|
|
return "xdsl"
|
|
|
|
|
|
return "fiber"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_technology(description: str) -> str:
|
|
|
|
|
|
desc = description.lower()
|
|
|
|
|
|
if "adsl" in desc:
|
|
|
|
|
|
return "ADSL"
|
|
|
|
|
|
if "vdsl" in desc:
|
|
|
|
|
|
return "VDSL"
|
|
|
|
|
|
if "mpls" in desc:
|
|
|
|
|
|
return "MPLS"
|
|
|
|
|
|
if "fiber" in desc:
|
|
|
|
|
|
return "Fiber"
|
|
|
|
|
|
if "datacenter" in desc:
|
|
|
|
|
|
return "Datacenter"
|
|
|
|
|
|
return "Internet"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_speed_profile(description: str) -> tuple[Optional[int], Optional[int], Optional[int]]:
|
|
|
|
|
|
slash_match = re.search(
|
|
|
|
|
|
r"(\d+(?:[.,]\d+)?)\s*/\s*(\d+(?:[.,]\d+)?)\s*(K|M|G)bps",
|
|
|
|
|
|
description,
|
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
if slash_match:
|
|
|
|
|
|
down_value = float(slash_match.group(1).replace(",", "."))
|
|
|
|
|
|
up_value = float(slash_match.group(2).replace(",", "."))
|
|
|
|
|
|
unit = slash_match.group(3).upper()
|
|
|
|
|
|
multiplier = 1
|
|
|
|
|
|
if unit == "G":
|
|
|
|
|
|
multiplier = 1000
|
|
|
|
|
|
elif unit == "K":
|
|
|
|
|
|
multiplier = 1 / 1000
|
|
|
|
|
|
|
|
|
|
|
|
download_mbps = max(int(round(down_value * multiplier)), 1)
|
|
|
|
|
|
upload_mbps = max(int(round(up_value * multiplier)), 1)
|
|
|
|
|
|
return download_mbps, upload_mbps, download_mbps
|
|
|
|
|
|
|
|
|
|
|
|
match = re.search(r"(\d+(?:[.,]\d+)?)\s*(G|M)bps", description, re.IGNORECASE)
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
return None, None, None
|
|
|
|
|
|
value = float(match.group(1).replace(",", "."))
|
|
|
|
|
|
unit = match.group(2).upper()
|
|
|
|
|
|
speed_mbps = int(value * 1000) if unit == "G" else int(value)
|
|
|
|
|
|
return speed_mbps, speed_mbps, speed_mbps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _line_monthly_cost(line: Dict) -> Decimal:
|
|
|
|
|
|
unit_price = _to_decimal(line.get("unit_price"))
|
|
|
|
|
|
if unit_price > 0:
|
|
|
|
|
|
return unit_price
|
|
|
|
|
|
|
|
|
|
|
|
line_total = _to_decimal(line.get("line_total"))
|
|
|
|
|
|
quantity = _to_decimal(line.get("quantity"), Decimal("1"))
|
|
|
|
|
|
if quantity <= 0:
|
|
|
|
|
|
return line_total
|
|
|
|
|
|
return line_total / quantity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_connection_pricing_entry(connection_id: int, effective_from, purchase_price: Decimal, notes: str):
|
|
|
|
|
|
if not effective_from:
|
|
|
|
|
|
return
|
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM internet_connections_pricing
|
|
|
|
|
|
WHERE connection_id = %s AND effective_from = %s
|
|
|
|
|
|
ORDER BY id DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(connection_id, effective_from),
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_pricing
|
|
|
|
|
|
SET purchase_price = %s,
|
|
|
|
|
|
notes = %s
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(purchase_price, notes, existing["id"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_pricing (
|
|
|
|
|
|
connection_id, effective_from, purchase_price, sales_price, notes
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(connection_id, effective_from, purchase_price, Decimal("0"), notes),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_ip_addresses_for_range(range_id: int, cidr: str) -> int:
|
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT COUNT(*) AS total
|
|
|
|
|
|
FROM internet_connections_ip_addresses
|
|
|
|
|
|
WHERE range_id = %s AND deleted_at IS NULL
|
|
|
|
|
|
""",
|
|
|
|
|
|
(range_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing and int(existing.get("total") or 0) > 0:
|
|
|
|
|
|
return int(existing.get("total") or 0)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
network = ipaddress.ip_network(cidr, strict=False)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
created = 0
|
|
|
|
|
|
for host in network.hosts():
|
|
|
|
|
|
existing_ip = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM internet_connections_ip_addresses
|
|
|
|
|
|
WHERE ip_address = %s
|
|
|
|
|
|
AND deleted_at IS NULL
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(str(host),),
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing_ip:
|
|
|
|
|
|
continue
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_ip_addresses (
|
|
|
|
|
|
range_id, ip_address, status, assigned_to, assigned_type, comment
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(range_id, str(host), "available", None, None, "Auto-generated from synced CIDR"),
|
|
|
|
|
|
)
|
|
|
|
|
|
created += 1
|
|
|
|
|
|
return created
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canonical_id: int):
|
|
|
|
|
|
duplicate_ids = [connection_id for connection_id in connection_ids if connection_id != canonical_id]
|
|
|
|
|
|
if not duplicate_ids:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
for duplicate_id in duplicate_ids:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_ip_ranges
|
|
|
|
|
|
SET connection_id = %s,
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE connection_id = %s AND deleted_at IS NULL
|
|
|
|
|
|
""",
|
|
|
|
|
|
(canonical_id, duplicate_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_ip_addresses
|
|
|
|
|
|
SET assigned_connection_id = %s
|
|
|
|
|
|
WHERE assigned_connection_id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(canonical_id, duplicate_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_connections
|
|
|
|
|
|
SET deleted_at = CURRENT_TIMESTAMP,
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP,
|
|
|
|
|
|
notes = CONCAT(COALESCE(notes, ''), CASE WHEN COALESCE(notes, '') = '' THEN '' ELSE E'\n' END, %s)
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(f"Dublet samlet under forbindelse #{canonical_id} via GlobalConnect reference-normalisering.", duplicate_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]:
|
2026-08-30 14:34:43 +02:00
|
|
|
|
canonical_cidr = _canonical_ip_network(cidr)
|
|
|
|
|
|
candidates = execute_query(
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"""
|
2026-08-30 14:34:43 +02:00
|
|
|
|
SELECT id, cidr
|
2026-07-09 23:44:30 +02:00
|
|
|
|
FROM internet_connections_ip_ranges
|
|
|
|
|
|
WHERE connection_id = %s
|
|
|
|
|
|
AND deleted_at IS NULL
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
""",
|
2026-08-30 14:34:43 +02:00
|
|
|
|
(connection_id,),
|
|
|
|
|
|
) or []
|
|
|
|
|
|
matches = [
|
|
|
|
|
|
row for row in candidates
|
|
|
|
|
|
if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr
|
|
|
|
|
|
]
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if not matches:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
canonical_id = int(matches[0]["id"])
|
|
|
|
|
|
duplicate_ids = [int(row["id"]) for row in matches[1:]]
|
|
|
|
|
|
for duplicate_id in duplicate_ids:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_ip_addresses
|
|
|
|
|
|
SET range_id = %s
|
|
|
|
|
|
WHERE range_id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(canonical_id, duplicate_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_ip_ranges
|
|
|
|
|
|
SET deleted_at = CURRENT_TIMESTAMP,
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP,
|
|
|
|
|
|
description = CONCAT(COALESCE(description, ''), CASE WHEN COALESCE(description, '') = '' THEN '' ELSE E'\n' END, %s)
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(f"Dublet samlet under range #{canonical_id} via GlobalConnect reference-normalisering.", duplicate_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
return canonical_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_service_address_for_match(value: Optional[str]) -> str:
|
|
|
|
|
|
normalized = str(value or "").strip().upper()
|
|
|
|
|
|
normalized = normalized.replace("Æ", "AE").replace("Ø", "OE").replace("Å", "AA")
|
|
|
|
|
|
normalized = re.sub(r"[^A-Z0-9]", "", normalized)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
|
|
|
|
|
|
if not reference:
|
|
|
|
|
|
return []
|
2026-08-30 14:34:43 +02:00
|
|
|
|
match_keys = sorted(_provider_reference_match_keys(reference))
|
2026-07-09 23:44:30 +02:00
|
|
|
|
rows = execute_query(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, customer_id, address, monthly_cost, technology, connection_type,
|
|
|
|
|
|
circuit_number, speed_mbps, download_mbps, upload_mbps, status,
|
2026-09-07 19:05:58 +02:00
|
|
|
|
allocation_model, value_type, value_label, is_manual_shared
|
2026-07-09 23:44:30 +02:00
|
|
|
|
FROM internet_connections_connections
|
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
|
AND provider ILIKE 'GlobalConnect%%'
|
2026-08-30 14:34:43 +02:00
|
|
|
|
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = ANY(%s)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
ORDER BY id
|
|
|
|
|
|
""",
|
2026-08-30 14:34:43 +02:00
|
|
|
|
(match_keys,),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
) or []
|
|
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_existing_globalconnect_connection(reference: str, service_address: Optional[str]) -> Dict[str, object]:
|
|
|
|
|
|
matches = _get_globalconnect_connections_by_reference(reference)
|
|
|
|
|
|
if not matches:
|
|
|
|
|
|
return {"row": None, "merge_ids": [], "conflict_reason": None, "matched_on": None}
|
|
|
|
|
|
|
|
|
|
|
|
normalized_target = _normalize_service_address_for_match(service_address)
|
|
|
|
|
|
exact_matches = []
|
|
|
|
|
|
blank_address_matches = []
|
|
|
|
|
|
other_address_matches = []
|
|
|
|
|
|
|
|
|
|
|
|
for row in matches:
|
|
|
|
|
|
normalized_existing = _normalize_service_address_for_match(row.get("address"))
|
|
|
|
|
|
if not normalized_existing:
|
|
|
|
|
|
blank_address_matches.append(row)
|
|
|
|
|
|
elif normalized_existing == normalized_target:
|
|
|
|
|
|
exact_matches.append(row)
|
|
|
|
|
|
else:
|
|
|
|
|
|
other_address_matches.append(row)
|
|
|
|
|
|
|
|
|
|
|
|
if exact_matches:
|
|
|
|
|
|
canonical = exact_matches[0]
|
|
|
|
|
|
merge_ids = [int(row["id"]) for row in exact_matches[1:] + blank_address_matches]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"row": canonical,
|
|
|
|
|
|
"merge_ids": merge_ids,
|
|
|
|
|
|
"conflict_reason": None,
|
|
|
|
|
|
"matched_on": "address",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if blank_address_matches and not other_address_matches:
|
|
|
|
|
|
canonical = blank_address_matches[0]
|
|
|
|
|
|
merge_ids = [int(row["id"]) for row in blank_address_matches[1:]]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"row": canonical,
|
|
|
|
|
|
"merge_ids": merge_ids,
|
|
|
|
|
|
"conflict_reason": None,
|
|
|
|
|
|
"matched_on": "blank_address",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conflicting_addresses = ", ".join(
|
|
|
|
|
|
sorted({str(row.get("address") or "").strip() for row in other_address_matches if str(row.get("address") or "").strip()})
|
|
|
|
|
|
)
|
|
|
|
|
|
conflict_reason = "Reference findes allerede på anden adresse"
|
|
|
|
|
|
if conflicting_addresses:
|
|
|
|
|
|
conflict_reason = f"{conflict_reason}: {conflicting_addresses}"
|
|
|
|
|
|
return {"row": None, "merge_ids": [], "conflict_reason": conflict_reason, "matched_on": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_date, invoice_number: str, customers: List[Dict]) -> Optional[int]:
|
|
|
|
|
|
if not reference or not lines:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
sorted_lines = sorted(
|
|
|
|
|
|
lines,
|
|
|
|
|
|
key=lambda item: (
|
|
|
|
|
|
"administrationsgebyr" in str(item.get("description") or "").lower(),
|
|
|
|
|
|
"sla" in str(item.get("description") or "").lower(),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
primary_line = sorted_lines[0]
|
|
|
|
|
|
display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip()
|
|
|
|
|
|
service_address = _build_service_address(primary_line)
|
|
|
|
|
|
if not service_address:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Skipping GlobalConnect connection %s from invoice %s because service address is missing",
|
|
|
|
|
|
display_reference or reference,
|
|
|
|
|
|
invoice_number,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
2026-07-28 14:18:24 +02:00
|
|
|
|
existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address)
|
|
|
|
|
|
existing = existing_resolution.get("row")
|
|
|
|
|
|
conflict_reason = existing_resolution.get("conflict_reason")
|
|
|
|
|
|
if conflict_reason:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Skipping GlobalConnect connection %s from invoice %s because %s",
|
|
|
|
|
|
display_reference or reference,
|
|
|
|
|
|
invoice_number,
|
|
|
|
|
|
conflict_reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])]
|
|
|
|
|
|
if existing and merge_ids:
|
|
|
|
|
|
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
|
# Supplier data may suggest a customer name, but customer ownership is
|
|
|
|
|
|
# always a manual CRM decision. Existing manually selected owners survive
|
|
|
|
|
|
# because update SQL uses COALESCE(NULL, customer_id); new records stay NULL.
|
|
|
|
|
|
matched_customer = None
|
2026-07-09 23:44:30 +02:00
|
|
|
|
description = str(primary_line.get("description") or reference)
|
|
|
|
|
|
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
|
2026-07-28 14:18:24 +02:00
|
|
|
|
# Internal BMC ownership is only a default for a newly discovered
|
|
|
|
|
|
# connection. An existing connection with no customer may deliberately be
|
|
|
|
|
|
# unassigned and must not gain an owner merely because a later invoice is
|
|
|
|
|
|
# ambiguous.
|
2026-08-30 14:34:43 +02:00
|
|
|
|
is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address)
|
|
|
|
|
|
internal_owner = None
|
|
|
|
|
|
owner_customer = None
|
|
|
|
|
|
is_confident = False
|
|
|
|
|
|
connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}"
|
2026-07-09 23:44:30 +02:00
|
|
|
|
monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0"))
|
|
|
|
|
|
note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description")))
|
|
|
|
|
|
base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
|
|
|
|
|
|
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference)
|
2026-08-30 14:34:43 +02:00
|
|
|
|
note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk."
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
|
2026-08-30 14:34:43 +02:00
|
|
|
|
target_status = "pending"
|
|
|
|
|
|
shared_value_type = "delefiber" if is_shared_candidate else "other"
|
2026-07-09 23:44:30 +02:00
|
|
|
|
payload = (
|
|
|
|
|
|
connection_name,
|
|
|
|
|
|
"GlobalConnect A/S",
|
|
|
|
|
|
owner_customer["id"] if owner_customer else None,
|
|
|
|
|
|
service_address,
|
|
|
|
|
|
monthly_cost,
|
|
|
|
|
|
_infer_technology(description),
|
|
|
|
|
|
_infer_connection_type(description),
|
|
|
|
|
|
display_reference,
|
|
|
|
|
|
speed_mbps,
|
|
|
|
|
|
upload_mbps,
|
|
|
|
|
|
download_mbps,
|
|
|
|
|
|
note_text,
|
2026-08-30 14:34:43 +02:00
|
|
|
|
"shared" if is_shared_candidate else "dedicated",
|
2026-07-09 23:44:30 +02:00
|
|
|
|
shared_value_type,
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
2026-09-07 19:05:58 +02:00
|
|
|
|
# Delefiber/BMCnet classification is internal operational data. A
|
|
|
|
|
|
# supplier invoice may update speed and cost, but must never undo a
|
|
|
|
|
|
# deliberate manual shared/delefiber designation.
|
|
|
|
|
|
preserve_manual_classification = bool(existing.get("is_manual_shared"))
|
|
|
|
|
|
resolved_allocation_model = (
|
|
|
|
|
|
existing.get("allocation_model") if preserve_manual_classification
|
|
|
|
|
|
else ("shared" if is_shared_candidate else "dedicated")
|
|
|
|
|
|
)
|
|
|
|
|
|
resolved_value_type = (
|
|
|
|
|
|
existing.get("value_type") if preserve_manual_classification
|
|
|
|
|
|
else shared_value_type
|
|
|
|
|
|
)
|
|
|
|
|
|
resolved_value_label = existing.get("value_label") if preserve_manual_classification else None
|
2026-07-09 23:44:30 +02:00
|
|
|
|
updated_snapshot = {
|
2026-08-30 14:34:43 +02:00
|
|
|
|
"customer_id": existing.get("customer_id"),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"address": service_address,
|
|
|
|
|
|
"monthly_cost": monthly_cost,
|
|
|
|
|
|
"technology": _infer_technology(description),
|
|
|
|
|
|
"connection_type": _infer_connection_type(description),
|
|
|
|
|
|
"circuit_number": display_reference,
|
|
|
|
|
|
"speed_mbps": speed_mbps,
|
|
|
|
|
|
"download_mbps": download_mbps,
|
|
|
|
|
|
"upload_mbps": upload_mbps,
|
|
|
|
|
|
"status": target_status,
|
2026-09-07 19:05:58 +02:00
|
|
|
|
"allocation_model": resolved_allocation_model,
|
|
|
|
|
|
"value_type": resolved_value_type,
|
|
|
|
|
|
"value_label": resolved_value_label,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
update_payload = (
|
|
|
|
|
|
connection_name,
|
|
|
|
|
|
"GlobalConnect A/S",
|
|
|
|
|
|
owner_customer["id"] if owner_customer else None,
|
|
|
|
|
|
service_address,
|
|
|
|
|
|
monthly_cost,
|
|
|
|
|
|
_infer_technology(description),
|
|
|
|
|
|
_infer_connection_type(description),
|
|
|
|
|
|
display_reference,
|
|
|
|
|
|
speed_mbps,
|
|
|
|
|
|
download_mbps,
|
|
|
|
|
|
upload_mbps,
|
|
|
|
|
|
note_text,
|
2026-09-07 19:05:58 +02:00
|
|
|
|
resolved_allocation_model,
|
|
|
|
|
|
resolved_value_type,
|
|
|
|
|
|
resolved_value_label,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
existing["id"],
|
|
|
|
|
|
)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_connections
|
|
|
|
|
|
SET name = %s,
|
|
|
|
|
|
provider = %s,
|
|
|
|
|
|
customer_id = COALESCE(%s, customer_id),
|
|
|
|
|
|
address = COALESCE(%s, address),
|
|
|
|
|
|
status = %s,
|
|
|
|
|
|
monthly_cost = %s,
|
|
|
|
|
|
technology = %s,
|
|
|
|
|
|
connection_type = %s,
|
|
|
|
|
|
circuit_number = %s,
|
|
|
|
|
|
speed_mbps = COALESCE(%s, speed_mbps),
|
|
|
|
|
|
download_mbps = COALESCE(%s, download_mbps),
|
|
|
|
|
|
upload_mbps = COALESCE(%s, upload_mbps),
|
|
|
|
|
|
allocation_model = %s,
|
|
|
|
|
|
value_type = %s,
|
|
|
|
|
|
value_label = %s,
|
|
|
|
|
|
notes = %s,
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
update_payload[0],
|
|
|
|
|
|
update_payload[1],
|
|
|
|
|
|
update_payload[2],
|
|
|
|
|
|
update_payload[3],
|
2026-08-30 14:34:43 +02:00
|
|
|
|
target_status,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
update_payload[4],
|
|
|
|
|
|
update_payload[5],
|
|
|
|
|
|
update_payload[6],
|
|
|
|
|
|
update_payload[7],
|
|
|
|
|
|
update_payload[8],
|
|
|
|
|
|
update_payload[9],
|
|
|
|
|
|
update_payload[10],
|
|
|
|
|
|
update_payload[12],
|
|
|
|
|
|
update_payload[13],
|
|
|
|
|
|
update_payload[14],
|
|
|
|
|
|
update_payload[11],
|
|
|
|
|
|
update_payload[15],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
connection_id = int(existing["id"])
|
|
|
|
|
|
changes = _build_change_dict(
|
|
|
|
|
|
existing,
|
|
|
|
|
|
updated_snapshot,
|
|
|
|
|
|
[
|
|
|
|
|
|
"customer_id",
|
|
|
|
|
|
"address",
|
|
|
|
|
|
"monthly_cost",
|
|
|
|
|
|
"technology",
|
|
|
|
|
|
"connection_type",
|
|
|
|
|
|
"circuit_number",
|
|
|
|
|
|
"speed_mbps",
|
|
|
|
|
|
"download_mbps",
|
|
|
|
|
|
"upload_mbps",
|
|
|
|
|
|
"status",
|
|
|
|
|
|
"allocation_model",
|
|
|
|
|
|
"value_type",
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
if changes:
|
|
|
|
|
|
_append_connection_history(
|
|
|
|
|
|
connection_id,
|
|
|
|
|
|
"supplier_invoice_sync_changed",
|
|
|
|
|
|
f"Opdateret fra GlobalConnect faktura {invoice_number}",
|
|
|
|
|
|
{"reference": display_reference, "changes": changes},
|
|
|
|
|
|
)
|
|
|
|
|
|
_ensure_internet_change_case(
|
|
|
|
|
|
connection_id=connection_id,
|
|
|
|
|
|
invoice_number=invoice_number,
|
|
|
|
|
|
reference=display_reference,
|
|
|
|
|
|
connection_name=connection_name,
|
|
|
|
|
|
owner_customer_id=owner_customer["id"] if owner_customer else None,
|
|
|
|
|
|
changes=changes,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
connection_id = execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_connections (
|
|
|
|
|
|
name, provider, customer_id, address, status, monthly_cost, sales_price,
|
|
|
|
|
|
technology, connection_type, circuit_number, speed_mbps, upload_mbps,
|
|
|
|
|
|
download_mbps, notes, allocation_model, value_type, value_label
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, 0, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
payload[0],
|
|
|
|
|
|
payload[1],
|
|
|
|
|
|
payload[2],
|
|
|
|
|
|
payload[3],
|
2026-08-30 14:34:43 +02:00
|
|
|
|
target_status,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
payload[4],
|
|
|
|
|
|
payload[5],
|
|
|
|
|
|
payload[6],
|
|
|
|
|
|
payload[7],
|
|
|
|
|
|
payload[8],
|
|
|
|
|
|
payload[9],
|
|
|
|
|
|
payload[10],
|
|
|
|
|
|
payload[11],
|
|
|
|
|
|
payload[12],
|
|
|
|
|
|
payload[13],
|
|
|
|
|
|
payload[14],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
_append_connection_history(
|
|
|
|
|
|
connection_id,
|
|
|
|
|
|
"connection_created_from_supplier_invoice",
|
|
|
|
|
|
f"Oprettet fra GlobalConnect faktura {invoice_number}",
|
|
|
|
|
|
{"reference": display_reference, "normalized_reference": reference, "invoice_number": invoice_number},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_ensure_connection_pricing_entry(
|
|
|
|
|
|
connection_id=connection_id,
|
|
|
|
|
|
effective_from=invoice_date,
|
|
|
|
|
|
purchase_price=monthly_cost,
|
|
|
|
|
|
notes=f"GlobalConnect sync fra faktura {invoice_number}",
|
|
|
|
|
|
)
|
|
|
|
|
|
return int(connection_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str):
|
2026-08-30 14:34:43 +02:00
|
|
|
|
cidr = _canonical_ip_network(line.get("ip_address"))
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if not connection_id or not cidr:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
|
|
|
|
|
|
service_address = _build_service_address(line)
|
2026-08-30 14:34:43 +02:00
|
|
|
|
# Never infer range ownership from invoice text. A user must select it.
|
|
|
|
|
|
matched_customer = None
|
2026-07-09 23:44:30 +02:00
|
|
|
|
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr)
|
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, provider_reference, contract_number, customer_id, service_address, monthly_cost
|
|
|
|
|
|
FROM internet_connections_ip_ranges
|
|
|
|
|
|
WHERE connection_id = %s AND cidr = %s AND deleted_at IS NULL
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(connection_id, cidr),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not existing and canonical_range_id:
|
|
|
|
|
|
existing = {"id": canonical_range_id}
|
|
|
|
|
|
monthly_cost = _line_monthly_cost(line)
|
|
|
|
|
|
params = (
|
|
|
|
|
|
str(line.get("description") or "IP-range").strip(),
|
|
|
|
|
|
display_reference or None,
|
|
|
|
|
|
str(line.get("contract_number") or "").strip() or None,
|
|
|
|
|
|
matched_customer["id"] if matched_customer else None,
|
|
|
|
|
|
service_address,
|
|
|
|
|
|
monthly_cost,
|
|
|
|
|
|
Decimal("0"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
changes = _build_change_dict(
|
|
|
|
|
|
existing,
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_reference": params[1],
|
|
|
|
|
|
"contract_number": params[2],
|
|
|
|
|
|
"customer_id": params[3],
|
|
|
|
|
|
"service_address": params[4],
|
|
|
|
|
|
"monthly_cost": params[5],
|
|
|
|
|
|
},
|
|
|
|
|
|
["provider_reference", "contract_number", "customer_id", "service_address", "monthly_cost"],
|
|
|
|
|
|
)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_ip_ranges
|
|
|
|
|
|
SET name = %s,
|
|
|
|
|
|
provider_reference = %s,
|
|
|
|
|
|
contract_number = %s,
|
|
|
|
|
|
customer_id = COALESCE(%s, customer_id),
|
|
|
|
|
|
service_address = COALESCE(%s, service_address),
|
|
|
|
|
|
monthly_cost = %s,
|
|
|
|
|
|
sales_price = %s,
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
params + (existing["id"],),
|
|
|
|
|
|
)
|
|
|
|
|
|
range_id = int(existing["id"])
|
|
|
|
|
|
if service_address:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_connections
|
|
|
|
|
|
SET address = COALESCE(NULLIF(TRIM(address), ''), %s),
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(service_address, connection_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
_ensure_ip_addresses_for_range(range_id, cidr)
|
|
|
|
|
|
if changes:
|
|
|
|
|
|
_append_connection_history(
|
|
|
|
|
|
connection_id,
|
|
|
|
|
|
"supplier_invoice_ip_range_changed",
|
|
|
|
|
|
f"IP-range {cidr} opdateret fra faktura {invoice_number}",
|
|
|
|
|
|
{"cidr": cidr, "changes": changes},
|
|
|
|
|
|
)
|
|
|
|
|
|
reference = display_reference or cidr
|
2026-08-31 13:01:35 +02:00
|
|
|
|
connection_owner = execute_query_single(
|
|
|
|
|
|
"SELECT customer_id FROM internet_connections_connections WHERE id=%s",
|
|
|
|
|
|
(connection_id,),
|
|
|
|
|
|
) or {}
|
2026-07-09 23:44:30 +02:00
|
|
|
|
_ensure_internet_change_case(
|
|
|
|
|
|
connection_id=connection_id,
|
|
|
|
|
|
invoice_number=invoice_number,
|
|
|
|
|
|
reference=reference,
|
|
|
|
|
|
connection_name=f"IP-range {cidr}",
|
2026-08-31 13:01:35 +02:00
|
|
|
|
owner_customer_id=connection_owner.get("customer_id"),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
changes=changes,
|
|
|
|
|
|
)
|
|
|
|
|
|
return range_id
|
|
|
|
|
|
|
|
|
|
|
|
range_id = execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_ip_ranges (
|
|
|
|
|
|
connection_id, name, cidr, description, provider_reference, contract_number,
|
|
|
|
|
|
customer_id, service_address, monthly_cost, sales_price
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
connection_id,
|
|
|
|
|
|
params[0],
|
|
|
|
|
|
cidr,
|
|
|
|
|
|
f"Synced fra GlobalConnect faktura {invoice_number}",
|
|
|
|
|
|
params[1],
|
|
|
|
|
|
params[2],
|
|
|
|
|
|
params[3],
|
|
|
|
|
|
params[4],
|
|
|
|
|
|
params[5],
|
|
|
|
|
|
params[6],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
range_id = int(range_id)
|
|
|
|
|
|
if service_address:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE internet_connections_connections
|
|
|
|
|
|
SET address = COALESCE(NULLIF(TRIM(address), ''), %s),
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(service_address, connection_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
_ensure_ip_addresses_for_range(range_id, cidr)
|
|
|
|
|
|
_append_connection_history(
|
|
|
|
|
|
connection_id,
|
|
|
|
|
|
"supplier_invoice_ip_range_created",
|
|
|
|
|
|
f"IP-range {cidr} oprettet fra faktura {invoice_number}",
|
|
|
|
|
|
{"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]},
|
|
|
|
|
|
)
|
2026-08-31 13:01:35 +02:00
|
|
|
|
# A range added to an existing connection is a commercial change. When the
|
|
|
|
|
|
# connection itself was created by this invoice, it is merely initial data.
|
|
|
|
|
|
created_with_invoice = execute_query_single(
|
|
|
|
|
|
"""SELECT 1 FROM internet_connections_history
|
|
|
|
|
|
WHERE connection_id=%s AND event_type='connection_created_from_supplier_invoice'
|
|
|
|
|
|
AND details->>'invoice_number'=%s LIMIT 1""",
|
|
|
|
|
|
(connection_id, invoice_number),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not created_with_invoice:
|
|
|
|
|
|
connection = execute_query_single(
|
|
|
|
|
|
"""SELECT name, circuit_number, customer_id, provider
|
|
|
|
|
|
FROM internet_connections_connections WHERE id=%s""",
|
|
|
|
|
|
(connection_id,),
|
|
|
|
|
|
) or {}
|
|
|
|
|
|
_ensure_internet_change_case(
|
|
|
|
|
|
connection_id=connection_id,
|
|
|
|
|
|
invoice_number=invoice_number,
|
|
|
|
|
|
reference=str(connection.get("circuit_number") or display_reference or cidr),
|
|
|
|
|
|
connection_name=str(connection.get("name") or f"IP-range {cidr}"),
|
|
|
|
|
|
owner_customer_id=connection.get("customer_id"),
|
|
|
|
|
|
changes={"range_added": {"from": None, "to": cidr}},
|
|
|
|
|
|
)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
return range_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_existing_globalconnect_connection_id(reference: str, service_address: Optional[str]) -> tuple[Optional[int], Optional[str]]:
|
|
|
|
|
|
resolution = _resolve_existing_globalconnect_connection(reference, service_address)
|
|
|
|
|
|
existing = resolution.get("row")
|
|
|
|
|
|
if existing and existing.get("id"):
|
|
|
|
|
|
return int(existing["id"]), None
|
|
|
|
|
|
return None, resolution.get("conflict_reason")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _connection_can_host_ip_range(connection_id: Optional[int], service_address: Optional[str]) -> bool:
|
|
|
|
|
|
if not connection_id:
|
|
|
|
|
|
return False
|
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT address, allocation_model
|
|
|
|
|
|
FROM internet_connections_connections
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
AND deleted_at IS NULL
|
|
|
|
|
|
""",
|
|
|
|
|
|
(connection_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not service_address:
|
|
|
|
|
|
return True
|
2026-07-28 14:18:24 +02:00
|
|
|
|
# A shared connection may serve several customers, but a supplier reference
|
|
|
|
|
|
# and its IP-range must still belong to the same physical service address.
|
|
|
|
|
|
# Bypassing the address check here caused ranges from another site to
|
|
|
|
|
|
# overwrite customer, address and price data on the wrong connection.
|
2026-07-09 23:44:30 +02:00
|
|
|
|
return _normalize_service_address_for_match(row.get("address")) == _normalize_service_address_for_match(service_address)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]:
|
|
|
|
|
|
"""Use an existing CIDR as the strongest key, but never cross service addresses."""
|
2026-08-30 14:34:43 +02:00
|
|
|
|
cidr = _canonical_ip_network(line.get("ip_address"))
|
2026-07-28 14:18:24 +02:00
|
|
|
|
if not cidr:
|
|
|
|
|
|
return {"connection_id": None, "conflict_reason": None}
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
service_address = _build_service_address(line)
|
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
|
"""
|
2026-08-30 14:34:43 +02:00
|
|
|
|
SELECT range.connection_id, range.cidr, range.service_address, range.provider_reference,
|
2026-07-28 14:18:24 +02:00
|
|
|
|
connection.address AS connection_address,
|
|
|
|
|
|
connection.circuit_number AS connection_reference
|
|
|
|
|
|
FROM internet_connections_ip_ranges range
|
|
|
|
|
|
JOIN internet_connections_connections connection ON connection.id = range.connection_id
|
2026-08-30 14:34:43 +02:00
|
|
|
|
WHERE range.deleted_at IS NULL
|
2026-07-28 14:18:24 +02:00
|
|
|
|
AND connection.deleted_at IS NULL
|
2026-08-30 14:34:43 +02:00
|
|
|
|
AND connection.provider ILIKE 'GlobalConnect%%'
|
2026-07-28 14:18:24 +02:00
|
|
|
|
ORDER BY range.id
|
|
|
|
|
|
""",
|
2026-08-30 14:34:43 +02:00
|
|
|
|
(),
|
2026-07-28 14:18:24 +02:00
|
|
|
|
) or []
|
2026-08-30 14:34:43 +02:00
|
|
|
|
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
|
2026-07-28 14:18:24 +02:00
|
|
|
|
if reference:
|
|
|
|
|
|
matching_reference = [
|
|
|
|
|
|
row for row in rows
|
|
|
|
|
|
if _normalize_provider_reference(row.get("provider_reference")) == reference
|
|
|
|
|
|
]
|
|
|
|
|
|
if matching_reference:
|
|
|
|
|
|
rows = matching_reference
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return {"connection_id": None, "conflict_reason": None}
|
|
|
|
|
|
|
|
|
|
|
|
normalized_target = _normalize_service_address_for_match(service_address)
|
|
|
|
|
|
exact_address = [
|
|
|
|
|
|
row for row in rows
|
|
|
|
|
|
if normalized_target
|
|
|
|
|
|
and _normalize_service_address_for_match(row.get("service_address") or row.get("connection_address")) == normalized_target
|
|
|
|
|
|
]
|
|
|
|
|
|
if len(exact_address) == 1:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"connection_id": int(exact_address[0]["connection_id"]),
|
|
|
|
|
|
"canonical_reference": exact_address[0].get("connection_reference"),
|
|
|
|
|
|
"conflict_reason": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(rows) == 1 and not normalized_target:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"connection_id": int(rows[0]["connection_id"]),
|
|
|
|
|
|
"canonical_reference": rows[0].get("connection_reference"),
|
|
|
|
|
|
"conflict_reason": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
known_addresses = sorted({
|
|
|
|
|
|
str(row.get("service_address") or row.get("connection_address") or "").strip()
|
|
|
|
|
|
for row in rows
|
|
|
|
|
|
if str(row.get("service_address") or row.get("connection_address") or "").strip()
|
|
|
|
|
|
})
|
|
|
|
|
|
reason = f"CIDR {cidr} findes allerede"
|
|
|
|
|
|
if known_addresses:
|
|
|
|
|
|
reason += f" på anden adresse: {', '.join(known_addresses)}"
|
|
|
|
|
|
return {"connection_id": None, "conflict_reason": reason}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _connection_skip_reason(line: Dict) -> str:
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
if not reference:
|
|
|
|
|
|
return "Mangler provider-reference/kredsløb"
|
|
|
|
|
|
if not _build_service_address(line):
|
|
|
|
|
|
return "Mangler serviceadresse"
|
|
|
|
|
|
description = str(line.get("description") or "")
|
|
|
|
|
|
speed_mbps, upload_mbps, download_mbps = _infer_speed_profile(description)
|
|
|
|
|
|
if not any((speed_mbps, upload_mbps, download_mbps)):
|
|
|
|
|
|
return "Mangler hastighed på forbindelseslinjen"
|
|
|
|
|
|
return "Forbindelseslinje kunne ikke valideres"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_sync_audit_entry(line: Dict, classification: str, status: str, reason: Optional[str] = None) -> Dict:
|
|
|
|
|
|
reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"line_number": line.get("line_number"),
|
|
|
|
|
|
"description": str(line.get("description") or "").strip() or "-",
|
|
|
|
|
|
"classification": classification,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"reason": reason,
|
|
|
|
|
|
"provider_reference": reference or None,
|
|
|
|
|
|
"ip_address": str(line.get("ip_address") or "").strip() or None,
|
|
|
|
|
|
"service_address": _build_service_address(line),
|
|
|
|
|
|
"end_customer_name": str(line.get("end_customer_name") or "").strip() or None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
def _clear_inherited_addresses_for_addressless_eb_ranges(lines: List[Dict]) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
GlobalConnect's IP overview does not repeat a service address for legacy
|
|
|
|
|
|
EB references. The extractor can incorrectly carry the preceding NKA
|
|
|
|
|
|
address forward. If the matching existing EB connection is deliberately
|
|
|
|
|
|
addressless, retain that unknown state instead of trusting the inherited
|
|
|
|
|
|
address.
|
|
|
|
|
|
"""
|
|
|
|
|
|
references_by_address: Dict[str, set] = {}
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
address = _normalize_service_address_for_match(_build_service_address(line))
|
|
|
|
|
|
if _looks_like_ip_range_line(line) and reference.startswith("EB") and address:
|
|
|
|
|
|
references_by_address.setdefault(address, set()).add(reference)
|
|
|
|
|
|
|
|
|
|
|
|
# A repeated address across several unrelated EB circuits is the extractor
|
|
|
|
|
|
# carrying the preceding site's address forward. Explicit EB addresses are
|
|
|
|
|
|
# retained when they occur on a single circuit.
|
|
|
|
|
|
inherited_addresses = {
|
|
|
|
|
|
address for address, references in references_by_address.items()
|
|
|
|
|
|
if len(references) >= 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sanitized = []
|
|
|
|
|
|
for source_line in lines:
|
|
|
|
|
|
line = dict(source_line)
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
normalized_address = _normalize_service_address_for_match(_build_service_address(line))
|
|
|
|
|
|
if (
|
|
|
|
|
|
_looks_like_ip_range_line(line)
|
|
|
|
|
|
and reference.startswith("EB")
|
|
|
|
|
|
and normalized_address in inherited_addresses
|
|
|
|
|
|
):
|
|
|
|
|
|
line["service_address"] = None
|
|
|
|
|
|
line["location_street"] = None
|
|
|
|
|
|
line["location_zip"] = None
|
|
|
|
|
|
line["location_city"] = None
|
|
|
|
|
|
line["address_source"] = "not_stated_on_invoice"
|
|
|
|
|
|
sanitized.append(line)
|
|
|
|
|
|
return sanitized
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_range_candidates: int) -> Dict:
|
|
|
|
|
|
actionable_lines = [entry for entry in line_audit if entry["classification"] in {"connection", "ip_range"}]
|
|
|
|
|
|
synced_lines = [entry for entry in actionable_lines if entry["status"] == "synced"]
|
|
|
|
|
|
skipped_lines = [entry for entry in actionable_lines if entry["status"] == "skipped"]
|
|
|
|
|
|
ignored_lines = [entry for entry in line_audit if entry["status"] == "ignored"]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"total_lines": len(line_audit),
|
|
|
|
|
|
"actionable_lines": len(actionable_lines),
|
|
|
|
|
|
"synced_actionable_lines": len(synced_lines),
|
|
|
|
|
|
"skipped_actionable_lines": len(skipped_lines),
|
|
|
|
|
|
"ignored_lines": len(ignored_lines),
|
|
|
|
|
|
"connection_groups": connection_groups,
|
|
|
|
|
|
"ip_range_candidates": ip_range_candidates,
|
|
|
|
|
|
"all_actionable_accounted_for": len(actionable_lines) == (len(synced_lines) + len(skipped_lines)),
|
|
|
|
|
|
"fully_synced": len(skipped_lines) == 0 and len(actionable_lines) > 0,
|
|
|
|
|
|
"requires_manual_review": len(skipped_lines) > 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simulate: bool = False) -> Dict:
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if not _is_globalconnect_extraction(extraction_row):
|
|
|
|
|
|
return {"skipped": True, "reason": "not_globalconnect"}
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
lines = _clear_inherited_addresses_for_addressless_eb_ranges(
|
|
|
|
|
|
_load_extraction_lines(extraction_row)
|
|
|
|
|
|
)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if not lines:
|
|
|
|
|
|
return {"skipped": True, "reason": "no_lines"}
|
|
|
|
|
|
|
|
|
|
|
|
invoice_number = str(extraction_row.get("document_id") or extraction_row.get("invoice_number") or extraction_row.get("file_id") or "ukendt")
|
|
|
|
|
|
invoice_date = extraction_row.get("document_date")
|
|
|
|
|
|
customers = _load_active_customers_for_matching()
|
|
|
|
|
|
|
|
|
|
|
|
grouped_connections: Dict[str, List[Dict]] = {}
|
|
|
|
|
|
connection_entry_indexes: Dict[str, List[int]] = {}
|
|
|
|
|
|
ip_range_lines: List[tuple[int, Dict]] = []
|
|
|
|
|
|
skipped_connection_lines = 0
|
|
|
|
|
|
line_audit: List[Dict] = []
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
|
if _looks_like_ip_range_line(line):
|
|
|
|
|
|
line_audit.append(_build_sync_audit_entry(line, "ip_range", "pending"))
|
|
|
|
|
|
ip_range_lines.append((len(line_audit) - 1, line))
|
|
|
|
|
|
continue
|
|
|
|
|
|
if _looks_like_connection_component(line):
|
|
|
|
|
|
if not _can_create_connection_from_line(line):
|
|
|
|
|
|
skipped_connection_lines += 1
|
|
|
|
|
|
line_audit.append(_build_sync_audit_entry(line, "connection", "skipped", _connection_skip_reason(line)))
|
|
|
|
|
|
continue
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
if reference:
|
|
|
|
|
|
grouped_connections.setdefault(reference, []).append(line)
|
|
|
|
|
|
line_audit.append(_build_sync_audit_entry(line, "connection", "pending"))
|
|
|
|
|
|
connection_entry_indexes.setdefault(reference, []).append(len(line_audit) - 1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
skipped_connection_lines += 1
|
|
|
|
|
|
line_audit.append(_build_sync_audit_entry(line, "connection", "skipped", "Mangler provider-reference/kredsløb"))
|
|
|
|
|
|
continue
|
|
|
|
|
|
line_audit.append(_build_sync_audit_entry(line, "other", "ignored", "Ikke en forbindelses- eller IP-range-linje"))
|
|
|
|
|
|
|
|
|
|
|
|
connection_map: Dict[str, int] = {}
|
|
|
|
|
|
created_or_updated_connections = 0
|
|
|
|
|
|
created_connections = 0
|
|
|
|
|
|
updated_connections = 0
|
|
|
|
|
|
for reference, reference_lines in grouped_connections.items():
|
|
|
|
|
|
primary_line = reference_lines[0]
|
|
|
|
|
|
service_address = _build_service_address(primary_line)
|
|
|
|
|
|
existing_connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
|
|
|
|
|
|
if connection_conflict_reason:
|
|
|
|
|
|
for index in connection_entry_indexes.get(reference, []):
|
|
|
|
|
|
line_audit[index]["status"] = "skipped"
|
|
|
|
|
|
line_audit[index]["reason"] = connection_conflict_reason
|
|
|
|
|
|
skipped_connection_lines += len(connection_entry_indexes.get(reference, []))
|
|
|
|
|
|
continue
|
|
|
|
|
|
if simulate:
|
|
|
|
|
|
connection_id = existing_connection_id or -(len(connection_map) + 1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
connection_id = _upsert_globalconnect_connection(
|
|
|
|
|
|
reference=reference,
|
|
|
|
|
|
lines=reference_lines,
|
|
|
|
|
|
invoice_date=invoice_date,
|
|
|
|
|
|
invoice_number=invoice_number,
|
|
|
|
|
|
customers=customers,
|
|
|
|
|
|
)
|
|
|
|
|
|
if connection_id:
|
|
|
|
|
|
connection_map[reference] = connection_id
|
|
|
|
|
|
created_or_updated_connections += 1
|
|
|
|
|
|
if existing_connection_id:
|
|
|
|
|
|
updated_connections += 1
|
|
|
|
|
|
outcome = "updated"
|
|
|
|
|
|
else:
|
|
|
|
|
|
created_connections += 1
|
|
|
|
|
|
outcome = "created"
|
|
|
|
|
|
for index in connection_entry_indexes.get(reference, []):
|
|
|
|
|
|
line_audit[index]["status"] = "synced"
|
|
|
|
|
|
line_audit[index]["result"] = outcome
|
|
|
|
|
|
line_audit[index]["connection_id"] = None if simulate else connection_id
|
|
|
|
|
|
else:
|
|
|
|
|
|
for index in connection_entry_indexes.get(reference, []):
|
|
|
|
|
|
line_audit[index]["status"] = "skipped"
|
|
|
|
|
|
line_audit[index]["reason"] = "Forbindelsen kunne ikke oprettes/opdateres"
|
|
|
|
|
|
|
|
|
|
|
|
created_or_updated_ranges = 0
|
|
|
|
|
|
synced_ip_ranges_existing_connection = 0
|
|
|
|
|
|
skipped_orphan_ip_ranges = 0
|
|
|
|
|
|
for audit_index, line in ip_range_lines:
|
|
|
|
|
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
|
|
|
|
|
service_address = _build_service_address(line)
|
2026-08-30 14:34:43 +02:00
|
|
|
|
reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference)
|
2026-07-28 14:18:24 +02:00
|
|
|
|
existing_range_resolution = _resolve_existing_ip_range_connection(line)
|
2026-08-30 14:34:43 +02:00
|
|
|
|
if existing_range_resolution.get("conflict_reason") and not reference_connection_id:
|
2026-07-28 14:18:24 +02:00
|
|
|
|
skipped_orphan_ip_ranges += 1
|
|
|
|
|
|
line_audit[audit_index]["status"] = "skipped"
|
|
|
|
|
|
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
|
|
|
|
|
|
continue
|
2026-08-30 14:34:43 +02:00
|
|
|
|
mapped_reference_connection_id = connection_map.get(reference)
|
|
|
|
|
|
# An existing CIDR on the same service address is stronger evidence than
|
|
|
|
|
|
# a supplier reference. OCR/extraction can accidentally carry a circuit
|
|
|
|
|
|
# number from a neighbouring invoice line.
|
|
|
|
|
|
address_range_connection_id = existing_range_resolution.get("connection_id")
|
|
|
|
|
|
connection_id = address_range_connection_id or reference_connection_id or mapped_reference_connection_id
|
2026-07-09 23:44:30 +02:00
|
|
|
|
resolved_from_existing = False
|
2026-08-30 14:34:43 +02:00
|
|
|
|
matched_by_reference = bool(
|
|
|
|
|
|
not address_range_connection_id
|
|
|
|
|
|
and (reference_connection_id or mapped_reference_connection_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if reference_connection_id or address_range_connection_id:
|
2026-07-28 14:18:24 +02:00
|
|
|
|
resolved_from_existing = True
|
2026-08-30 14:34:43 +02:00
|
|
|
|
corrected_service_address = None
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
|
2026-08-30 14:34:43 +02:00
|
|
|
|
if matched_by_reference:
|
|
|
|
|
|
authoritative_connection = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT address
|
|
|
|
|
|
FROM internet_connections_connections
|
|
|
|
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
|
""",
|
|
|
|
|
|
(connection_id,),
|
|
|
|
|
|
) or {}
|
|
|
|
|
|
corrected_service_address = str(authoritative_connection.get("address") or "").strip() or None
|
|
|
|
|
|
if not corrected_service_address:
|
|
|
|
|
|
connection_id = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
connection_id = None
|
|
|
|
|
|
if not connection_id:
|
|
|
|
|
|
matched_by_reference = False
|
2026-07-09 23:44:30 +02:00
|
|
|
|
skipped_orphan_ip_ranges += 1
|
2026-08-30 14:34:43 +02:00
|
|
|
|
line_audit[audit_index]["status"] = "skipped"
|
|
|
|
|
|
line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse"
|
2026-07-09 23:44:30 +02:00
|
|
|
|
continue
|
2026-08-30 14:34:43 +02:00
|
|
|
|
if not connection_id and reference:
|
|
|
|
|
|
connection_id, connection_conflict_reason = (None, None)
|
|
|
|
|
|
if service_address:
|
|
|
|
|
|
connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if connection_id:
|
|
|
|
|
|
connection_map[reference] = connection_id
|
|
|
|
|
|
resolved_from_existing = True
|
|
|
|
|
|
elif connection_conflict_reason:
|
|
|
|
|
|
skipped_orphan_ip_ranges += 1
|
|
|
|
|
|
line_audit[audit_index]["status"] = "skipped"
|
|
|
|
|
|
line_audit[audit_index]["reason"] = connection_conflict_reason
|
|
|
|
|
|
continue
|
2026-08-30 14:34:43 +02:00
|
|
|
|
elif not simulate and not reference.startswith("EB"):
|
|
|
|
|
|
connection_id = _create_pending_connection_for_ip_reference(line, invoice_number)
|
|
|
|
|
|
if connection_id:
|
|
|
|
|
|
connection_map[reference] = connection_id
|
|
|
|
|
|
created_or_updated_connections += 1
|
|
|
|
|
|
created_connections += 1
|
|
|
|
|
|
line_audit[audit_index]["created_pending_connection"] = True
|
|
|
|
|
|
elif simulate and not reference.startswith("EB"):
|
|
|
|
|
|
connection_id = -(len(connection_map) + 1)
|
|
|
|
|
|
|
|
|
|
|
|
if matched_by_reference:
|
|
|
|
|
|
line_audit[audit_index]["matched_by"] = "unique_circuit_reference"
|
|
|
|
|
|
if service_address and not _connection_can_host_ip_range(connection_id, service_address):
|
|
|
|
|
|
line_audit[audit_index]["address_warning"] = "IP-linjens serviceadresse afviger fra forbindelsen; kredsløbsnummer blev brugt"
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
sync_line = dict(line)
|
2026-08-30 14:34:43 +02:00
|
|
|
|
if corrected_service_address:
|
|
|
|
|
|
sync_line["service_address"] = corrected_service_address
|
|
|
|
|
|
line_audit[audit_index]["address_warning"] = (
|
|
|
|
|
|
f"Fakturaadressen '{service_address}' blev erstattet med kredsløbets adresse "
|
|
|
|
|
|
f"'{corrected_service_address}'"
|
|
|
|
|
|
)
|
|
|
|
|
|
line_audit[audit_index]["service_address_corrected"] = True
|
2026-07-28 14:18:24 +02:00
|
|
|
|
if existing_range_resolution.get("canonical_reference"):
|
|
|
|
|
|
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
|
|
|
|
|
|
sync_line["circuit_id"] = existing_range_resolution["canonical_reference"]
|
|
|
|
|
|
line_audit[audit_index]["matched_by"] = "existing_cidr_and_address"
|
|
|
|
|
|
line_audit[audit_index]["canonical_reference"] = existing_range_resolution["canonical_reference"]
|
2026-07-09 23:44:30 +02:00
|
|
|
|
if connection_id and (
|
|
|
|
|
|
simulate
|
2026-07-28 14:18:24 +02:00
|
|
|
|
or _upsert_globalconnect_ip_range(connection_id, sync_line, invoice_number)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
):
|
|
|
|
|
|
created_or_updated_ranges += 1
|
|
|
|
|
|
if resolved_from_existing:
|
|
|
|
|
|
synced_ip_ranges_existing_connection += 1
|
|
|
|
|
|
line_audit[audit_index]["status"] = "synced"
|
|
|
|
|
|
line_audit[audit_index]["result"] = "linked_existing_connection" if resolved_from_existing else "linked_synced_connection"
|
|
|
|
|
|
line_audit[audit_index]["connection_id"] = None if simulate else connection_id
|
|
|
|
|
|
elif not connection_id:
|
|
|
|
|
|
skipped_orphan_ip_ranges += 1
|
|
|
|
|
|
line_audit[audit_index]["status"] = "skipped"
|
|
|
|
|
|
line_audit[audit_index]["reason"] = "Ingen forbindelse fundet til IP-range"
|
|
|
|
|
|
|
|
|
|
|
|
verification = _summarize_sync_audit(
|
|
|
|
|
|
line_audit=line_audit,
|
|
|
|
|
|
connection_groups=len(grouped_connections),
|
|
|
|
|
|
ip_range_candidates=len(ip_range_lines),
|
|
|
|
|
|
)
|
2026-08-31 13:01:35 +02:00
|
|
|
|
case_creation_errors = []
|
|
|
|
|
|
if not simulate:
|
|
|
|
|
|
try:
|
|
|
|
|
|
case_creation_errors = execute_query(
|
|
|
|
|
|
"""SELECT connection_id, last_error AS error
|
|
|
|
|
|
FROM internet_connection_change_cases
|
|
|
|
|
|
WHERE source_type='globalconnect_invoice' AND source_key=%s
|
|
|
|
|
|
AND last_error IS NOT NULL
|
|
|
|
|
|
ORDER BY connection_id""",
|
|
|
|
|
|
(str(invoice_number),),
|
|
|
|
|
|
) or []
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
# The invoice still completes during staggered migration rollout.
|
|
|
|
|
|
logger.warning("Could not load internet change-case control report: %s", exc)
|
|
|
|
|
|
verification["change_case_errors"] = case_creation_errors
|
|
|
|
|
|
verification["requires_manual_review"] = bool(case_creation_errors) or bool(verification.get("requires_manual_review"))
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"skipped": False,
|
|
|
|
|
|
"simulate": simulate,
|
|
|
|
|
|
"connections_synced": created_or_updated_connections,
|
|
|
|
|
|
"connections_created": created_connections,
|
|
|
|
|
|
"connections_updated": updated_connections,
|
|
|
|
|
|
"ip_ranges_synced": created_or_updated_ranges,
|
|
|
|
|
|
"ip_ranges_linked_existing_connections": synced_ip_ranges_existing_connection,
|
|
|
|
|
|
"skipped_connection_lines": skipped_connection_lines,
|
|
|
|
|
|
"skipped_orphan_ip_ranges": skipped_orphan_ip_ranges,
|
|
|
|
|
|
"line_audit": line_audit,
|
|
|
|
|
|
"skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"],
|
|
|
|
|
|
"verification": verification,
|
2026-08-31 13:01:35 +02:00
|
|
|
|
"change_case_errors": case_creation_errors,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
def _record_internet_invoice_sync_run(
|
|
|
|
|
|
extraction_row: Dict,
|
|
|
|
|
|
*,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
result: Optional[Dict] = None,
|
|
|
|
|
|
error_message: Optional[str] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Persist the outcome without allowing audit logging to break invoice processing."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
extraction_id = extraction_row.get("extraction_id")
|
|
|
|
|
|
supplier_invoice = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM supplier_invoices
|
|
|
|
|
|
WHERE extraction_id = %s
|
|
|
|
|
|
ORDER BY id DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(extraction_id,),
|
|
|
|
|
|
) if extraction_id else None
|
|
|
|
|
|
payload = result or {}
|
|
|
|
|
|
verification = payload.get("verification") or {}
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO internet_connections_invoice_sync_runs (
|
|
|
|
|
|
file_id, extraction_id, supplier_invoice_id, invoice_number, vendor_name,
|
|
|
|
|
|
invoice_date, status, connections_synced, connections_created,
|
|
|
|
|
|
connections_updated, ip_ranges_synced, total_lines, actionable_lines,
|
|
|
|
|
|
skipped_lines, error_message, result_json
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
extraction_row.get("file_id"),
|
|
|
|
|
|
extraction_id,
|
|
|
|
|
|
supplier_invoice.get("id") if supplier_invoice else None,
|
|
|
|
|
|
extraction_row.get("document_id") or extraction_row.get("invoice_number"),
|
|
|
|
|
|
extraction_row.get("vendor_name"),
|
|
|
|
|
|
extraction_row.get("document_date"),
|
|
|
|
|
|
status,
|
|
|
|
|
|
int(payload.get("connections_synced") or 0),
|
|
|
|
|
|
int(payload.get("connections_created") or 0),
|
|
|
|
|
|
int(payload.get("connections_updated") or 0),
|
|
|
|
|
|
int(payload.get("ip_ranges_synced") or 0),
|
|
|
|
|
|
int(verification.get("total_lines") or 0),
|
|
|
|
|
|
int(verification.get("actionable_lines") or 0),
|
|
|
|
|
|
int(verification.get("skipped_actionable_lines") or 0),
|
|
|
|
|
|
error_message,
|
|
|
|
|
|
json.dumps(payload, default=str),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as audit_error:
|
|
|
|
|
|
logger.warning("Could not persist internet invoice sync audit: %s", audit_error)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sync_globalconnect_extraction_to_internet(
|
|
|
|
|
|
extraction_row: Dict,
|
|
|
|
|
|
simulate: bool = False,
|
|
|
|
|
|
force: bool = False,
|
|
|
|
|
|
) -> Dict:
|
|
|
|
|
|
"""Run GlobalConnect sync and keep a permanent, queryable result for the overview."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not simulate and not force:
|
|
|
|
|
|
invoice_number = str(
|
|
|
|
|
|
extraction_row.get("document_id") or extraction_row.get("invoice_number") or ""
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
extraction_id = extraction_row.get("extraction_id")
|
|
|
|
|
|
previous_run = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, status, processed_at
|
|
|
|
|
|
FROM internet_connections_invoice_sync_runs
|
|
|
|
|
|
WHERE status IN ('success', 'warning')
|
|
|
|
|
|
AND (
|
|
|
|
|
|
(%s IS NOT NULL AND extraction_id = %s)
|
|
|
|
|
|
OR (NULLIF(%s, '') IS NOT NULL AND invoice_number = %s)
|
|
|
|
|
|
)
|
|
|
|
|
|
ORDER BY processed_at DESC, id DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(extraction_id, extraction_id, invoice_number, invoice_number),
|
|
|
|
|
|
)
|
|
|
|
|
|
if previous_run:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"skipped": True,
|
|
|
|
|
|
"reason": "invoice_already_processed",
|
|
|
|
|
|
"invoice_number": invoice_number,
|
|
|
|
|
|
"previous_run_id": previous_run.get("id"),
|
|
|
|
|
|
"previous_status": previous_run.get("status"),
|
|
|
|
|
|
"previous_processed_at": previous_run.get("processed_at"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = _sync_globalconnect_extraction_to_internet_impl(extraction_row, simulate=simulate)
|
|
|
|
|
|
if not simulate:
|
|
|
|
|
|
verification = result.get("verification") or {}
|
|
|
|
|
|
if result.get("skipped"):
|
|
|
|
|
|
status = "skipped"
|
|
|
|
|
|
elif verification.get("requires_manual_review"):
|
|
|
|
|
|
status = "warning"
|
|
|
|
|
|
else:
|
|
|
|
|
|
status = "success"
|
|
|
|
|
|
_record_internet_invoice_sync_run(extraction_row, status=status, result=result)
|
|
|
|
|
|
return result
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
if not simulate:
|
|
|
|
|
|
_record_internet_invoice_sync_run(
|
|
|
|
|
|
extraction_row,
|
|
|
|
|
|
status="error",
|
|
|
|
|
|
error_message=str(exc),
|
|
|
|
|
|
result={"exception_type": type(exc).__name__},
|
|
|
|
|
|
)
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]:
|
|
|
|
|
|
sku_value = str(sku or "").strip()
|
|
|
|
|
|
desc_value = str(description or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
if vendor_id and sku_value:
|
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM products
|
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
|
AND supplier_id = %s
|
|
|
|
|
|
AND (
|
|
|
|
|
|
supplier_sku = %s
|
|
|
|
|
|
OR sku_internal = %s
|
|
|
|
|
|
OR manufacturer_sku = %s
|
|
|
|
|
|
)
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(vendor_id, sku_value, sku_value, sku_value),
|
|
|
|
|
|
)
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return int(row["id"])
|
|
|
|
|
|
|
|
|
|
|
|
if vendor_id and desc_value:
|
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM products
|
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
|
AND supplier_id = %s
|
|
|
|
|
|
AND LOWER(TRIM(name)) = LOWER(TRIM(%s))
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(vendor_id, desc_value),
|
|
|
|
|
|
)
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return int(row["id"])
|
|
|
|
|
|
|
|
|
|
|
|
if desc_value:
|
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM products
|
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
|
AND LOWER(TRIM(name)) = LOWER(TRIM(%s))
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(desc_value,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return int(row["id"])
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_product_for_supplier_line(
|
|
|
|
|
|
vendor_id: Optional[int],
|
|
|
|
|
|
vendor_name: Optional[str],
|
|
|
|
|
|
description: Optional[str],
|
|
|
|
|
|
sku: Optional[str],
|
|
|
|
|
|
unit_price,
|
|
|
|
|
|
currency: Optional[str],
|
|
|
|
|
|
vat_rate,
|
|
|
|
|
|
) -> Optional[int]:
|
|
|
|
|
|
desc_value = str(description or "").strip()
|
|
|
|
|
|
sku_value = str(sku or "").strip() or None
|
|
|
|
|
|
if not desc_value and not sku_value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
existing_id = _find_existing_product_id(vendor_id, desc_value, sku_value)
|
|
|
|
|
|
if existing_id:
|
|
|
|
|
|
return existing_id
|
|
|
|
|
|
|
|
|
|
|
|
name = desc_value or f"Vare {sku_value}"
|
|
|
|
|
|
created_id = execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO products (
|
|
|
|
|
|
name,
|
|
|
|
|
|
type,
|
|
|
|
|
|
status,
|
|
|
|
|
|
sku_internal,
|
|
|
|
|
|
supplier_id,
|
|
|
|
|
|
supplier_name,
|
|
|
|
|
|
supplier_sku,
|
|
|
|
|
|
supplier_price,
|
|
|
|
|
|
supplier_currency,
|
|
|
|
|
|
cost_price,
|
|
|
|
|
|
vat_rate,
|
|
|
|
|
|
billable,
|
|
|
|
|
|
created_by
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
name,
|
|
|
|
|
|
"product",
|
|
|
|
|
|
"active",
|
|
|
|
|
|
sku_value,
|
|
|
|
|
|
vendor_id,
|
|
|
|
|
|
vendor_name,
|
|
|
|
|
|
sku_value,
|
|
|
|
|
|
_to_decimal(unit_price),
|
|
|
|
|
|
(currency or "DKK"),
|
|
|
|
|
|
_to_decimal(unit_price),
|
|
|
|
|
|
_to_decimal(vat_rate, Decimal("25.00")),
|
|
|
|
|
|
True,
|
|
|
|
|
|
1,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info("✅ Auto-created product %s for vendor %s", created_id, vendor_id)
|
|
|
|
|
|
return int(created_id)
|
|
|
|
|
|
except Exception as product_error:
|
|
|
|
|
|
# Keep invoice creation alive even if product auto-create fails.
|
|
|
|
|
|
logger.warning("⚠️ Could not auto-create/find product for supplier line '%s': %s", desc_value, product_error)
|
|
|
|
|
|
return _find_existing_product_id(vendor_id, desc_value, sku_value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
def _smart_extract_lines(text: str) -> List[Dict]:
|
|
|
|
|
|
"""
|
2025-12-08 09:15:52 +01:00
|
|
|
|
Universal line extraction using pdfplumber layout mode.
|
|
|
|
|
|
Tries pdfplumber columnar format first, then falls back to vendor-specific patterns.
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""
|
|
|
|
|
|
lines_arr = text.split('\n')
|
|
|
|
|
|
items = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
|
|
|
|
|
|
while i < len(lines_arr):
|
|
|
|
|
|
line = lines_arr[i].strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Skip empty or header lines
|
|
|
|
|
|
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
# Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20"
|
|
|
|
|
|
# Whitespace-separated columns: position item_number description quantity unit_price total_price
|
|
|
|
|
|
# Most specific pattern - try first!
|
|
|
|
|
|
layout_match = re.match(r'^\s*(\d{1,2})\s+(\d{4,10})\s+(.+?)\s(\d{1,2})\s+([\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$', line)
|
|
|
|
|
|
if layout_match:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'line_number': len(items) + 1,
|
|
|
|
|
|
'position': layout_match.group(1),
|
|
|
|
|
|
'item_number': layout_match.group(2),
|
|
|
|
|
|
'description': layout_match.group(3).strip(),
|
|
|
|
|
|
'quantity': layout_match.group(4),
|
|
|
|
|
|
'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
|
|
|
|
|
|
'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
|
|
|
|
|
|
'raw_text': line
|
|
|
|
|
|
})
|
|
|
|
|
|
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...")
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Pattern 2: ALSO format - "100 48023976 REFURB LENOVO..." (multi-line)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
item_match = re.match(r'^(\d{1,3})\s+(\d{6,})\s+(.+)', line)
|
|
|
|
|
|
if item_match:
|
|
|
|
|
|
position = item_match.group(1)
|
|
|
|
|
|
item_number = item_match.group(2)
|
|
|
|
|
|
description = item_match.group(3).strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Find næste linje med antal+priser
|
|
|
|
|
|
quantity = None
|
|
|
|
|
|
unit_price = None
|
|
|
|
|
|
total_price = None
|
|
|
|
|
|
|
|
|
|
|
|
for j in range(i+1, min(i+10, len(lines_arr))):
|
|
|
|
|
|
price_line = lines_arr[j].strip()
|
|
|
|
|
|
price_match = re.match(r'^(\d+)\s*(?:ST|stk|pc|pcs)\s+([\d.,]+)\s+([\d.,]+)', price_line, re.IGNORECASE)
|
|
|
|
|
|
if price_match:
|
|
|
|
|
|
quantity = price_match.group(1)
|
|
|
|
|
|
unit_price = price_match.group(2).replace(',', '.')
|
|
|
|
|
|
total_price = price_match.group(3).replace(',', '.')
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if quantity and unit_price:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'line_number': len(items) + 1,
|
|
|
|
|
|
'position': position,
|
|
|
|
|
|
'item_number': item_number,
|
|
|
|
|
|
'description': description,
|
|
|
|
|
|
'quantity': quantity,
|
|
|
|
|
|
'unit_price': unit_price,
|
|
|
|
|
|
'total_price': total_price,
|
|
|
|
|
|
'raw_text': f"{line} ... {quantity}ST {unit_price} {total_price}"
|
|
|
|
|
|
})
|
|
|
|
|
|
logger.info(f"✅ ALSO: {item_number} - {description[:30]}...")
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
# Pattern 3: DCS single-line - "195006Betalingsmetode... 141,2041,20" (legacy PyPDF2 format)
|
|
|
|
|
|
# Position: 1 digit, Item: 4-10 digits, Description starts with letter
|
|
|
|
|
|
# Prices: Danish format 1-3 digits, comma, 2 decimals (e.g., 41,20 or 619,00)
|
|
|
|
|
|
# Quantity: 1-2 digits (non-greedy) before first price
|
|
|
|
|
|
dcs_match = re.match(r'^(\d)(\d{4,10})([A-Za-z].+?)(\d{1,2}?)(\d{1,3},\d{2})(\d{1,3},\d{2})$', line)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
if dcs_match:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'line_number': len(items) + 1,
|
|
|
|
|
|
'position': dcs_match.group(1),
|
|
|
|
|
|
'item_number': dcs_match.group(2),
|
|
|
|
|
|
'description': dcs_match.group(3).strip(),
|
|
|
|
|
|
'quantity': dcs_match.group(4),
|
|
|
|
|
|
'unit_price': dcs_match.group(5).replace(',', '.'),
|
|
|
|
|
|
'total_price': dcs_match.group(6).replace(',', '.'),
|
|
|
|
|
|
'raw_text': line
|
|
|
|
|
|
})
|
2025-12-08 09:15:52 +01:00
|
|
|
|
logger.info(f"✅ DCS single-line: {dcs_match.group(2)} - {dcs_match.group(3)[:30]}...")
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Pattern 4: DCS multi-line - "2994922511Ubiquiti..." then search for "...USW-FLEX 1619,00619,00" (legacy)
|
|
|
|
|
|
dcs_multi_match = re.match(r'^(\d)(\d{4,10})([A-Za-z].+)$', line)
|
|
|
|
|
|
if dcs_multi_match and not re.search(r'KN8|EAN|Model|Position|Varenr|Tekst', line):
|
|
|
|
|
|
position = dcs_multi_match.group(1)
|
|
|
|
|
|
item_number = dcs_multi_match.group(2)
|
|
|
|
|
|
description = dcs_multi_match.group(3).strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Search next 5 lines for quantity/prices (Danish format 1-3 digits before comma)
|
|
|
|
|
|
for j in range(1, 6):
|
|
|
|
|
|
if i + j >= len(lines_arr):
|
|
|
|
|
|
break
|
|
|
|
|
|
price_line = lines_arr[i + j].strip()
|
|
|
|
|
|
# Match: "S/N: ...USW-FLEX 1619,00619,00" - qty (1-2 digits, non-greedy) + TWO prices
|
|
|
|
|
|
price_match = re.search(r'(\d{1,2}?)(\d{1,3},\d{2})(\d{1,3},\d{2})\s*$', price_line)
|
|
|
|
|
|
if price_match:
|
|
|
|
|
|
quantity = price_match.group(1)
|
|
|
|
|
|
unit_price = price_match.group(2).replace(',', '.')
|
|
|
|
|
|
total_price = price_match.group(3).replace(',', '.')
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'line_number': len(items) + 1,
|
|
|
|
|
|
'position': position,
|
|
|
|
|
|
'item_number': item_number,
|
|
|
|
|
|
'description': description,
|
|
|
|
|
|
'quantity': quantity,
|
|
|
|
|
|
'unit_price': unit_price,
|
|
|
|
|
|
'total_price': total_price,
|
|
|
|
|
|
'raw_text': f"{line} ... {price_line}"
|
|
|
|
|
|
})
|
|
|
|
|
|
logger.info(f"✅ DCS multi-line: {item_number} - {description[:30]}...")
|
|
|
|
|
|
break
|
2025-12-07 03:29:54 +01:00
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
if items:
|
|
|
|
|
|
logger.info(f"📦 Multi-line extraction found {len(items)} items")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("⚠️ Multi-line extraction found no items")
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========== CRUD OPERATIONS ==========
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices")
|
|
|
|
|
|
async def list_supplier_invoices(
|
|
|
|
|
|
status: Optional[str] = None,
|
|
|
|
|
|
vendor_id: Optional[int] = None,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
sag_id: Optional[int] = None,
|
2025-12-07 03:29:54 +01:00
|
|
|
|
overdue_only: bool = False
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
List all supplier invoices with filtering options
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
status: Filter by status (pending, approved, sent_to_economic, paid, overdue, cancelled)
|
|
|
|
|
|
vendor_id: Filter by vendor
|
|
|
|
|
|
overdue_only: Only show overdue unpaid invoices
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = """
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
si.*,
|
|
|
|
|
|
v.name as vendor_full_name,
|
|
|
|
|
|
v.economic_supplier_number as vendor_economic_id,
|
|
|
|
|
|
CASE
|
|
|
|
|
|
WHEN si.paid_date IS NOT NULL THEN 'paid'
|
|
|
|
|
|
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
|
|
|
|
|
|
ELSE si.status
|
2026-04-15 09:34:26 +02:00
|
|
|
|
END as computed_status,
|
|
|
|
|
|
COALESCE(
|
|
|
|
|
|
si.workflow_status_v2,
|
|
|
|
|
|
CASE
|
|
|
|
|
|
WHEN si.status IN ('approved', 'sent_to_economic') THEN 'godkendt'
|
|
|
|
|
|
WHEN si.status = 'paid' THEN 'betalt'
|
|
|
|
|
|
WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN 'afvist'
|
|
|
|
|
|
ELSE 'modtaget'
|
|
|
|
|
|
END
|
|
|
|
|
|
) as status_v2
|
2025-12-07 03:29:54 +01:00
|
|
|
|
FROM supplier_invoices si
|
|
|
|
|
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
|
|
|
|
|
WHERE 1=1
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if status:
|
2026-04-15 09:34:26 +02:00
|
|
|
|
normalized_filter = str(status).strip().lower()
|
|
|
|
|
|
if normalized_filter in SUPPLIER_STATUS_V2:
|
|
|
|
|
|
query += """
|
|
|
|
|
|
AND COALESCE(
|
|
|
|
|
|
si.workflow_status_v2,
|
|
|
|
|
|
CASE
|
|
|
|
|
|
WHEN si.status IN ('approved', 'sent_to_economic') THEN 'godkendt'
|
|
|
|
|
|
WHEN si.status = 'paid' THEN 'betalt'
|
|
|
|
|
|
WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN 'afvist'
|
|
|
|
|
|
ELSE 'modtaget'
|
|
|
|
|
|
END
|
|
|
|
|
|
) = %s
|
|
|
|
|
|
"""
|
|
|
|
|
|
params.append(normalized_filter)
|
|
|
|
|
|
else:
|
|
|
|
|
|
query += " AND si.status = %s"
|
|
|
|
|
|
params.append(status)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if vendor_id:
|
|
|
|
|
|
query += " AND si.vendor_id = %s"
|
|
|
|
|
|
params.append(vendor_id)
|
2026-04-15 09:34:26 +02:00
|
|
|
|
|
|
|
|
|
|
if sag_id:
|
2026-06-11 09:45:11 +02:00
|
|
|
|
if table_has_column("supplier_invoices", "sag_id"):
|
|
|
|
|
|
query += " AND si.sag_id = %s"
|
|
|
|
|
|
params.append(sag_id)
|
|
|
|
|
|
elif (
|
|
|
|
|
|
table_has_column("supplier_invoice_relations", "supplier_invoice_id")
|
|
|
|
|
|
and table_has_column("supplier_invoice_relations", "relation_type")
|
|
|
|
|
|
and table_has_column("supplier_invoice_relations", "relation_id")
|
|
|
|
|
|
):
|
|
|
|
|
|
query += """
|
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
|
SELECT 1
|
|
|
|
|
|
FROM supplier_invoice_relations sir
|
|
|
|
|
|
WHERE sir.supplier_invoice_id = si.id
|
|
|
|
|
|
AND sir.relation_type = 'sag'
|
|
|
|
|
|
AND sir.relation_id = %s
|
|
|
|
|
|
)
|
|
|
|
|
|
"""
|
|
|
|
|
|
params.append(sag_id)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"⚠️ supplier invoice sag filter requested, but no schema link available (sag_id column/relation table missing)"
|
|
|
|
|
|
)
|
|
|
|
|
|
query += " AND 1 = 0"
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if overdue_only:
|
|
|
|
|
|
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
|
|
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY si.due_date ASC, si.invoice_date DESC"
|
|
|
|
|
|
|
2025-12-16 22:07:20 +01:00
|
|
|
|
invoices = execute_query(query, tuple(params) if params else ())
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
# Add lines to each invoice
|
|
|
|
|
|
for invoice in invoices:
|
|
|
|
|
|
lines = execute_query(
|
|
|
|
|
|
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
|
|
|
|
|
(invoice['id'],)
|
|
|
|
|
|
)
|
|
|
|
|
|
invoice['lines'] = lines
|
|
|
|
|
|
|
|
|
|
|
|
return invoices
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to list supplier invoices: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/pending-supplier-invoice-files")
|
|
|
|
|
|
async def get_pending_files():
|
|
|
|
|
|
"""Hent alle filer der venter på behandling, inkl. AI-extracted"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Hent både pending files OG ai_extracted files
|
|
|
|
|
|
files = execute_query(
|
|
|
|
|
|
"""SELECT DISTINCT ON (f.file_id)
|
|
|
|
|
|
f.file_id,
|
|
|
|
|
|
f.filename,
|
|
|
|
|
|
f.status,
|
|
|
|
|
|
f.uploaded_at,
|
|
|
|
|
|
f.error_message,
|
|
|
|
|
|
f.template_id,
|
|
|
|
|
|
f.file_path,
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
-- Quick analysis results (available immediately on upload)
|
|
|
|
|
|
f.detected_cvr,
|
|
|
|
|
|
f.detected_vendor_id,
|
|
|
|
|
|
f.detected_document_type,
|
|
|
|
|
|
f.detected_document_number,
|
|
|
|
|
|
f.is_own_invoice,
|
|
|
|
|
|
v_detected.name as detected_vendor_name,
|
|
|
|
|
|
v_detected.cvr_number as detected_vendor_cvr,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
-- Get vendor info from latest extraction
|
|
|
|
|
|
ext.vendor_name,
|
|
|
|
|
|
ext.vendor_cvr,
|
|
|
|
|
|
ext.vendor_matched_id,
|
|
|
|
|
|
v.name as matched_vendor_name,
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
v.cvr_number as matched_vendor_cvr_number,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
-- Check if already has invoice via latest extraction only
|
|
|
|
|
|
si.id as existing_invoice_id,
|
|
|
|
|
|
si.invoice_number as existing_invoice_number
|
|
|
|
|
|
FROM incoming_files f
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
LEFT JOIN vendors v_detected ON v_detected.id = f.detected_vendor_id
|
2025-12-08 09:15:52 +01:00
|
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
|
SELECT extraction_id, file_id, vendor_name, vendor_cvr, vendor_matched_id
|
|
|
|
|
|
FROM extractions
|
|
|
|
|
|
WHERE file_id = f.file_id
|
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
) ext ON true
|
|
|
|
|
|
LEFT JOIN vendors v ON v.id = ext.vendor_matched_id
|
|
|
|
|
|
LEFT JOIN supplier_invoices si ON si.extraction_id = ext.extraction_id
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
WHERE f.status IN ('pending', 'processing', 'failed', 'ai_extracted', 'processed', 'duplicate')
|
2025-12-08 09:15:52 +01:00
|
|
|
|
AND si.id IS NULL -- Only show files without invoice yet
|
|
|
|
|
|
ORDER BY f.file_id, f.uploaded_at DESC"""
|
|
|
|
|
|
)
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
|
|
|
|
|
|
# Convert to regular dicts so we can add new keys
|
|
|
|
|
|
files = [dict(file) for file in files] if files else []
|
|
|
|
|
|
|
|
|
|
|
|
# Check for invoice2data templates for each file
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.invoice2data_service import get_invoice2data_service
|
|
|
|
|
|
invoice2data = get_invoice2data_service()
|
|
|
|
|
|
logger.info(f"📋 Checking invoice2data templates: {len(invoice2data.templates)} loaded")
|
|
|
|
|
|
|
|
|
|
|
|
for file in files:
|
2025-12-15 12:28:12 +01:00
|
|
|
|
# Check if there's an invoice2data template for this vendor's CVR or name
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
vendor_cvr = file.get('matched_vendor_cvr_number') or file.get('detected_vendor_cvr') or file.get('vendor_cvr')
|
2025-12-15 12:28:12 +01:00
|
|
|
|
vendor_name = file.get('vendor_name') or file.get('detected_vendor_name') or file.get('matched_vendor_name')
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
file['has_invoice2data_template'] = False
|
|
|
|
|
|
|
2025-12-15 12:28:12 +01:00
|
|
|
|
logger.debug(f" File {file['file_id']}: CVR={vendor_cvr}, name={vendor_name}")
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
|
2025-12-15 12:28:12 +01:00
|
|
|
|
# Check all templates
|
|
|
|
|
|
for template_name, template_data in invoice2data.templates.items():
|
|
|
|
|
|
keywords = template_data.get('keywords', [])
|
|
|
|
|
|
logger.debug(f" Template {template_name}: keywords={keywords}")
|
|
|
|
|
|
|
|
|
|
|
|
# Match by CVR
|
|
|
|
|
|
if vendor_cvr and str(vendor_cvr) in [str(k) for k in keywords]:
|
|
|
|
|
|
file['has_invoice2data_template'] = True
|
|
|
|
|
|
file['invoice2data_template_name'] = template_name
|
|
|
|
|
|
logger.info(f" ✅ File {file['file_id']} matched template {template_name} by CVR")
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# Match by vendor name
|
|
|
|
|
|
if vendor_name:
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
|
if str(keyword).upper() in str(vendor_name).upper():
|
|
|
|
|
|
file['has_invoice2data_template'] = True
|
|
|
|
|
|
file['invoice2data_template_name'] = template_name
|
|
|
|
|
|
logger.info(f" ✅ File {file['file_id']} matched template {template_name} by name")
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if file['has_invoice2data_template']:
|
|
|
|
|
|
break
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to check invoice2data templates: {e}", exc_info=True)
|
|
|
|
|
|
# Continue without invoice2data info
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
return {"files": files if files else [], "count": len(files) if files else 0}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get pending files: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
@router.get("/supplier-invoices/files")
|
|
|
|
|
|
async def get_files_by_status(status: Optional[str] = None, limit: int = 100):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get files filtered by status(es)
|
|
|
|
|
|
|
|
|
|
|
|
Query params:
|
|
|
|
|
|
- status: Comma-separated list of statuses (e.g., "pending,extraction_failed")
|
|
|
|
|
|
- limit: Maximum number of results
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Parse status filter
|
|
|
|
|
|
status_list = []
|
|
|
|
|
|
if status:
|
|
|
|
|
|
status_list = [s.strip() for s in status.split(',')]
|
|
|
|
|
|
|
|
|
|
|
|
# Build query
|
|
|
|
|
|
if status_list:
|
|
|
|
|
|
placeholders = ','.join(['%s'] * len(status_list))
|
|
|
|
|
|
query = f"""
|
|
|
|
|
|
SELECT f.file_id, f.filename, f.file_path, f.file_size, f.mime_type,
|
|
|
|
|
|
f.status, f.uploaded_at, f.processed_at, f.detected_cvr,
|
|
|
|
|
|
f.detected_vendor_id, v.name as detected_vendor_name,
|
2026-03-02 06:22:33 +01:00
|
|
|
|
ext.vendor_name,
|
|
|
|
|
|
ext.vendor_cvr,
|
|
|
|
|
|
ext.vendor_matched_id,
|
|
|
|
|
|
COALESCE(v_ext.name, ext.vendor_name, v.name) as best_vendor_name,
|
|
|
|
|
|
ext.total_amount,
|
|
|
|
|
|
ext.confidence as vendor_match_confidence
|
2026-01-25 03:29:28 +01:00
|
|
|
|
FROM incoming_files f
|
|
|
|
|
|
LEFT JOIN vendors v ON f.detected_vendor_id = v.id
|
2026-03-02 06:22:33 +01:00
|
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
|
SELECT vendor_name, vendor_cvr, vendor_matched_id, total_amount, confidence
|
|
|
|
|
|
FROM extractions
|
|
|
|
|
|
WHERE file_id = f.file_id
|
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
) ext ON true
|
|
|
|
|
|
LEFT JOIN vendors v_ext ON v_ext.id = ext.vendor_matched_id
|
2026-01-25 03:29:28 +01:00
|
|
|
|
WHERE f.status IN ({placeholders})
|
|
|
|
|
|
ORDER BY f.uploaded_at DESC
|
|
|
|
|
|
LIMIT %s
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = tuple(status_list) + (limit,)
|
|
|
|
|
|
else:
|
|
|
|
|
|
query = """
|
|
|
|
|
|
SELECT f.file_id, f.filename, f.file_path, f.file_size, f.mime_type,
|
|
|
|
|
|
f.status, f.uploaded_at, f.processed_at, f.detected_cvr,
|
|
|
|
|
|
f.detected_vendor_id, v.name as detected_vendor_name,
|
2026-03-02 06:22:33 +01:00
|
|
|
|
ext.vendor_name,
|
|
|
|
|
|
ext.vendor_cvr,
|
|
|
|
|
|
ext.vendor_matched_id,
|
|
|
|
|
|
COALESCE(v_ext.name, ext.vendor_name, v.name) as best_vendor_name,
|
|
|
|
|
|
ext.total_amount,
|
|
|
|
|
|
ext.confidence as vendor_match_confidence
|
2026-01-25 03:29:28 +01:00
|
|
|
|
FROM incoming_files f
|
|
|
|
|
|
LEFT JOIN vendors v ON f.detected_vendor_id = v.id
|
2026-03-02 06:22:33 +01:00
|
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
|
SELECT vendor_name, vendor_cvr, vendor_matched_id, total_amount, confidence
|
|
|
|
|
|
FROM extractions
|
|
|
|
|
|
WHERE file_id = f.file_id
|
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
) ext ON true
|
|
|
|
|
|
LEFT JOIN vendors v_ext ON v_ext.id = ext.vendor_matched_id
|
2026-01-25 03:29:28 +01:00
|
|
|
|
ORDER BY f.uploaded_at DESC
|
|
|
|
|
|
LIMIT %s
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = (limit,)
|
|
|
|
|
|
|
|
|
|
|
|
files = execute_query(query, params)
|
|
|
|
|
|
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
files = []
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"count": len(files),
|
|
|
|
|
|
"files": files
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get files: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
@router.get("/supplier-invoices/files/{file_id}/pdf-text")
|
|
|
|
|
|
async def get_file_pdf_text(file_id: int):
|
|
|
|
|
|
"""Hent fuld PDF tekst fra en uploaded fil (til template builder)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get file info
|
|
|
|
|
|
file_info = execute_query(
|
|
|
|
|
|
"SELECT file_path, filename FROM incoming_files WHERE file_id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
|
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
file_data = file_info[0]
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Read PDF text
|
|
|
|
|
|
from pathlib import Path
|
2026-01-07 10:32:41 +01:00
|
|
|
|
file_path = Path(file_data['file_path'])
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Fil ikke fundet på disk: {file_path}")
|
|
|
|
|
|
|
|
|
|
|
|
pdf_text = await ollama_service._extract_text_from_file(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"file_id": file_id,
|
2026-01-07 10:32:41 +01:00
|
|
|
|
"filename": file_data['filename'],
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
"pdf_text": pdf_text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get PDF text: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
@router.get("/supplier-invoices/files/{file_id}/extracted-data")
|
|
|
|
|
|
async def get_file_extracted_data(file_id: int):
|
|
|
|
|
|
"""Hent AI-extracted data fra en uploaded fil"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get file info
|
2025-12-16 15:36:11 +01:00
|
|
|
|
file_info = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
# Get extraction results if exists
|
2025-12-16 15:36:11 +01:00
|
|
|
|
extraction = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT * FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
2025-12-08 23:46:18 +01:00
|
|
|
|
# Parse llm_response_json if it exists (from AI or template extraction)
|
|
|
|
|
|
llm_json_data = None
|
|
|
|
|
|
if extraction and extraction.get('llm_response_json'):
|
|
|
|
|
|
import json
|
|
|
|
|
|
try:
|
2026-01-07 10:32:41 +01:00
|
|
|
|
raw_json = extraction['llm_response_json']
|
|
|
|
|
|
# Always parse if it's a string, even if psycopg2 returns it as JSON type
|
|
|
|
|
|
if isinstance(raw_json, str):
|
|
|
|
|
|
llm_json_data = json.loads(raw_json)
|
|
|
|
|
|
elif isinstance(raw_json, dict):
|
|
|
|
|
|
llm_json_data = raw_json
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Fallback: try to parse as string
|
|
|
|
|
|
llm_json_data = json.loads(str(raw_json))
|
2025-12-08 23:46:18 +01:00
|
|
|
|
logger.info(f"📊 Parsed llm_response_json: invoice_number={llm_json_data.get('invoice_number')}")
|
|
|
|
|
|
except Exception as e:
|
2026-01-07 10:32:41 +01:00
|
|
|
|
logger.error(f"❌ Failed to parse llm_response_json: {e}")
|
2025-12-08 23:46:18 +01:00
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
# Get extraction lines if exist (use execute_query for multiple rows)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
extraction_lines = []
|
|
|
|
|
|
if extraction:
|
2026-01-07 10:32:41 +01:00
|
|
|
|
extraction_lines = execute_query(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"""SELECT * FROM extraction_lines
|
|
|
|
|
|
WHERE extraction_id = %s
|
|
|
|
|
|
ORDER BY line_number""",
|
|
|
|
|
|
(extraction['extraction_id'],)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Read PDF text if needed
|
|
|
|
|
|
pdf_text = None
|
2026-01-25 03:29:28 +01:00
|
|
|
|
if file_info and file_info.get('file_path'):
|
2025-12-08 09:15:52 +01:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
file_path = Path(file_info['file_path'])
|
|
|
|
|
|
if file_path.exists():
|
|
|
|
|
|
pdf_text = await ollama_service._extract_text_from_file(file_path)
|
|
|
|
|
|
|
2025-12-08 23:46:18 +01:00
|
|
|
|
# Format line items for frontend
|
|
|
|
|
|
formatted_lines = []
|
|
|
|
|
|
if extraction_lines:
|
|
|
|
|
|
for line in extraction_lines:
|
|
|
|
|
|
formatted_lines.append({
|
|
|
|
|
|
"description": line.get('description'),
|
|
|
|
|
|
"quantity": float(line.get('quantity')) if line.get('quantity') else None,
|
|
|
|
|
|
"unit_price": float(line.get('unit_price')) if line.get('unit_price') else None,
|
|
|
|
|
|
"vat_rate": float(line.get('vat_rate')) if line.get('vat_rate') else None,
|
|
|
|
|
|
"line_total": float(line.get('line_total')) if line.get('line_total') else None,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"vat_note": line.get('vat_note'),
|
|
|
|
|
|
"provider_reference": line.get('provider_reference'),
|
|
|
|
|
|
"contract_number": line.get('contract_number'),
|
|
|
|
|
|
"circuit_id": line.get('circuit_id'),
|
|
|
|
|
|
"ip_address": line.get('ip_address'),
|
|
|
|
|
|
"end_customer_name": line.get('end_customer_name'),
|
|
|
|
|
|
"service_address": line.get('service_address'),
|
2025-12-08 23:46:18 +01:00
|
|
|
|
})
|
|
|
|
|
|
elif llm_json_data and llm_json_data.get('lines'):
|
|
|
|
|
|
# Use lines from LLM JSON response
|
|
|
|
|
|
for line in llm_json_data['lines']:
|
|
|
|
|
|
formatted_lines.append({
|
|
|
|
|
|
"description": line.get('description'),
|
|
|
|
|
|
"quantity": float(line.get('quantity')) if line.get('quantity') else None,
|
|
|
|
|
|
"unit_price": float(line.get('unit_price')) if line.get('unit_price') else None,
|
|
|
|
|
|
"vat_rate": float(line.get('vat_rate')) if line.get('vat_rate') else None,
|
|
|
|
|
|
"line_total": float(line.get('line_total')) if line.get('line_total') else None,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"vat_note": line.get('vat_note'),
|
|
|
|
|
|
"provider_reference": line.get('provider_reference'),
|
|
|
|
|
|
"contract_number": line.get('contract_number'),
|
|
|
|
|
|
"circuit_id": line.get('circuit_id'),
|
|
|
|
|
|
"ip_address": line.get('ip_address'),
|
|
|
|
|
|
"end_customer_name": line.get('end_customer_name'),
|
|
|
|
|
|
"service_address": line.get('service_address'),
|
2025-12-08 23:46:18 +01:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Build llm_data response
|
|
|
|
|
|
llm_data = None
|
|
|
|
|
|
if llm_json_data:
|
2025-12-15 12:28:12 +01:00
|
|
|
|
# Normalize common invoice2data field names to our API schema
|
|
|
|
|
|
total_amount_value = llm_json_data.get('total_amount')
|
|
|
|
|
|
if total_amount_value is None:
|
|
|
|
|
|
total_amount_value = llm_json_data.get('amount_total')
|
|
|
|
|
|
|
|
|
|
|
|
invoice_date_value = llm_json_data.get('invoice_date')
|
|
|
|
|
|
if invoice_date_value is None:
|
|
|
|
|
|
invoice_date_value = llm_json_data.get('document_date')
|
|
|
|
|
|
|
|
|
|
|
|
due_date_value = llm_json_data.get('due_date')
|
|
|
|
|
|
|
2026-03-02 09:33:50 +01:00
|
|
|
|
# Vendor name: AI uses 'vendor_name', invoice2data uses 'issuer'
|
|
|
|
|
|
vendor_name_val = (
|
|
|
|
|
|
llm_json_data.get('vendor_name') or
|
|
|
|
|
|
llm_json_data.get('issuer') or
|
|
|
|
|
|
(extraction.get('vendor_name') if extraction else None)
|
|
|
|
|
|
)
|
|
|
|
|
|
# Vendor CVR: AI uses 'vendor_cvr', invoice2data uses 'vendor_vat'
|
|
|
|
|
|
vendor_cvr_val = (
|
|
|
|
|
|
llm_json_data.get('vendor_cvr') or
|
|
|
|
|
|
llm_json_data.get('vendor_vat') or
|
|
|
|
|
|
(extraction.get('vendor_cvr') if extraction else None)
|
|
|
|
|
|
)
|
|
|
|
|
|
# Vendor address: AI uses 'vendor_address', invoice2data may have separate fields
|
|
|
|
|
|
vendor_address_val = (
|
|
|
|
|
|
llm_json_data.get('vendor_address') or
|
|
|
|
|
|
llm_json_data.get('supplier_address') or
|
|
|
|
|
|
llm_json_data.get('vendor_street')
|
|
|
|
|
|
)
|
|
|
|
|
|
vendor_city_val = llm_json_data.get('vendor_city') or llm_json_data.get('city')
|
|
|
|
|
|
vendor_postal_val = llm_json_data.get('vendor_postal_code') or llm_json_data.get('postal_code')
|
|
|
|
|
|
vendor_email_val = llm_json_data.get('vendor_email') or llm_json_data.get('supplier_email')
|
|
|
|
|
|
|
2025-12-08 23:46:18 +01:00
|
|
|
|
# Use invoice_number from LLM JSON (works for both AI and template extraction)
|
|
|
|
|
|
llm_data = {
|
|
|
|
|
|
"invoice_number": llm_json_data.get('invoice_number'),
|
2025-12-15 12:28:12 +01:00
|
|
|
|
"invoice_date": invoice_date_value,
|
|
|
|
|
|
"due_date": due_date_value,
|
|
|
|
|
|
"total_amount": float(total_amount_value) if total_amount_value else None,
|
2025-12-08 23:46:18 +01:00
|
|
|
|
"currency": llm_json_data.get('currency') or 'DKK',
|
|
|
|
|
|
"document_type": llm_json_data.get('document_type'),
|
2026-03-02 09:33:50 +01:00
|
|
|
|
"vendor_name": vendor_name_val,
|
|
|
|
|
|
"vendor_cvr": vendor_cvr_val,
|
|
|
|
|
|
"vendor_address": vendor_address_val,
|
|
|
|
|
|
"vendor_city": vendor_city_val,
|
|
|
|
|
|
"vendor_postal_code": vendor_postal_val,
|
|
|
|
|
|
"vendor_email": vendor_email_val,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"lines": formatted_lines,
|
|
|
|
|
|
"_validation_warning": llm_json_data.get('_validation_warning'),
|
|
|
|
|
|
"_vat_warning": llm_json_data.get('_vat_warning'),
|
|
|
|
|
|
"_validation_details": llm_json_data.get('_validation_details'),
|
2025-12-08 23:46:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
elif extraction:
|
|
|
|
|
|
# Fallback to extraction table columns if no LLM JSON
|
|
|
|
|
|
llm_data = {
|
|
|
|
|
|
"invoice_number": extraction.get('document_id'),
|
|
|
|
|
|
"invoice_date": extraction.get('document_date').isoformat() if extraction.get('document_date') else None,
|
|
|
|
|
|
"due_date": extraction.get('due_date').isoformat() if extraction.get('due_date') else None,
|
|
|
|
|
|
"total_amount": float(extraction.get('total_amount')) if extraction.get('total_amount') else None,
|
|
|
|
|
|
"currency": extraction.get('currency') or 'DKK',
|
|
|
|
|
|
"document_type": extraction.get('document_type'),
|
2026-03-02 09:33:50 +01:00
|
|
|
|
"vendor_name": extraction.get('vendor_name'),
|
|
|
|
|
|
"vendor_cvr": extraction.get('vendor_cvr'),
|
|
|
|
|
|
"vendor_address": None,
|
|
|
|
|
|
"vendor_city": None,
|
|
|
|
|
|
"vendor_postal_code": None,
|
|
|
|
|
|
"vendor_email": None,
|
2025-12-08 23:46:18 +01:00
|
|
|
|
"lines": formatted_lines
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Get vendor from extraction
|
|
|
|
|
|
vendor_matched_id = extraction.get('vendor_matched_id') if extraction else None
|
2026-07-09 23:44:30 +02:00
|
|
|
|
internet_sync_preview = None
|
|
|
|
|
|
if extraction:
|
|
|
|
|
|
try:
|
|
|
|
|
|
internet_sync_preview = _sync_globalconnect_extraction_to_internet(extraction, simulate=True)
|
|
|
|
|
|
except Exception as preview_error:
|
|
|
|
|
|
logger.warning("⚠️ Could not build internet sync preview for file %s: %s", file_id, preview_error)
|
2025-12-08 23:46:18 +01:00
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
return {
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": file_info['filename'],
|
|
|
|
|
|
"status": file_info['status'],
|
|
|
|
|
|
"uploaded_at": file_info['uploaded_at'],
|
2025-12-08 23:46:18 +01:00
|
|
|
|
"vendor_matched_id": vendor_matched_id,
|
|
|
|
|
|
"llm_data": llm_data,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"extraction": extraction,
|
|
|
|
|
|
"extraction_lines": extraction_lines if extraction_lines else [],
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"internet_sync_preview": internet_sync_preview,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"pdf_text_preview": pdf_text[:5000] if pdf_text else None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get extracted data: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
@router.patch("/incoming-files/{file_id}")
|
|
|
|
|
|
async def update_incoming_file(file_id: int, data: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update incoming file metadata (e.g., detected_vendor_id)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Check if file exists
|
|
|
|
|
|
file_info = execute_query(
|
|
|
|
|
|
"SELECT file_id FROM incoming_files WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Build update query dynamically based on provided fields
|
|
|
|
|
|
allowed_fields = ['detected_vendor_id', 'status', 'notes']
|
|
|
|
|
|
update_fields = []
|
|
|
|
|
|
update_values = []
|
|
|
|
|
|
|
|
|
|
|
|
for field in allowed_fields:
|
|
|
|
|
|
if field in data:
|
|
|
|
|
|
update_fields.append(f"{field} = %s")
|
|
|
|
|
|
update_values.append(data[field])
|
|
|
|
|
|
|
|
|
|
|
|
if not update_fields:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="No valid fields to update")
|
|
|
|
|
|
|
|
|
|
|
|
# Execute update
|
|
|
|
|
|
update_values.append(file_id)
|
|
|
|
|
|
query = f"UPDATE incoming_files SET {', '.join(update_fields)} WHERE file_id = %s"
|
|
|
|
|
|
execute_update(query, tuple(update_values))
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated file {file_id}: {update_fields}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"message": "File updated successfully",
|
|
|
|
|
|
"updated_fields": list(data.keys())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to update file: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/incoming-files/{file_id}")
|
|
|
|
|
|
async def delete_incoming_file(file_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Delete incoming file from database and disk
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get file info
|
|
|
|
|
|
file_info = execute_query(
|
|
|
|
|
|
"SELECT file_path FROM incoming_files WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
file_path = Path(file_info[0]['file_path'])
|
|
|
|
|
|
|
|
|
|
|
|
# Delete from database
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"DELETE FROM incoming_files WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Delete from disk if exists
|
|
|
|
|
|
if file_path.exists():
|
|
|
|
|
|
file_path.unlink()
|
|
|
|
|
|
logger.info(f"🗑️ Deleted file from disk: {file_path}")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Deleted file {file_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"message": "File deleted successfully"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to delete file: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
@router.get("/supplier-invoices/files/{file_id}/download")
|
|
|
|
|
|
async def download_pending_file(file_id: int):
|
|
|
|
|
|
"""View PDF in browser"""
|
|
|
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get file info
|
2026-01-07 10:32:41 +01:00
|
|
|
|
file_result = execute_query(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
if not file_result:
|
2025-12-08 09:15:52 +01:00
|
|
|
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
|
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
file_info = file_result[0] # Get first row
|
2025-12-08 09:15:52 +01:00
|
|
|
|
file_path = Path(file_info['file_path'])
|
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Fil findes ikke på disk")
|
|
|
|
|
|
|
|
|
|
|
|
# Return with inline disposition so browser displays it instead of downloading
|
|
|
|
|
|
return FileResponse(
|
|
|
|
|
|
path=str(file_path),
|
|
|
|
|
|
media_type='application/pdf',
|
|
|
|
|
|
headers={"Content-Disposition": f"inline; filename={file_info['filename']}"}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to view file: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 23:46:18 +01:00
|
|
|
|
@router.get("/supplier-invoices/files/{file_id}/pdf")
|
|
|
|
|
|
async def get_file_pdf(file_id: int):
|
|
|
|
|
|
"""Get PDF file for viewing (alias for download endpoint)"""
|
|
|
|
|
|
return await download_pending_file(file_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
@router.post("/supplier-invoices/files/{file_id}/link-vendor")
|
|
|
|
|
|
async def link_vendor_to_extraction(file_id: int, data: dict):
|
|
|
|
|
|
"""Link an existing vendor to the extraction"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
vendor_id = data.get('vendor_id')
|
|
|
|
|
|
if not vendor_id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="vendor_id is required")
|
|
|
|
|
|
|
|
|
|
|
|
# Verify vendor exists
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendor = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT id, name FROM vendors WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(vendor_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not vendor:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
# Get latest extraction for this file
|
2025-12-16 15:36:11 +01:00
|
|
|
|
extraction = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not extraction:
|
2026-03-02 13:12:01 +01:00
|
|
|
|
# No extraction exists (e.g. custom template match or not yet processed)
|
|
|
|
|
|
# Create a minimal placeholder extraction so vendor can be linked
|
|
|
|
|
|
logger.info(f"⚠️ No extraction for file {file_id} — creating minimal extraction for vendor link")
|
|
|
|
|
|
extraction_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO extractions
|
|
|
|
|
|
(file_id, vendor_matched_id, vendor_name, vendor_cvr,
|
|
|
|
|
|
document_id, document_type, document_type_detected,
|
|
|
|
|
|
currency, confidence, status)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING extraction_id""",
|
|
|
|
|
|
(file_id, vendor_id,
|
|
|
|
|
|
vendor['name'], None,
|
|
|
|
|
|
None, 'invoice', 'invoice',
|
|
|
|
|
|
'DKK', 1.0, 'manual')
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
extraction_id = extraction['extraction_id']
|
|
|
|
|
|
# Update extraction with vendor match
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE extractions SET vendor_matched_id = %s WHERE extraction_id = %s",
|
|
|
|
|
|
(vendor_id, extraction_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Also update incoming_files so table shows vendor immediately
|
2025-12-08 09:15:52 +01:00
|
|
|
|
execute_update(
|
2026-03-02 13:12:01 +01:00
|
|
|
|
"UPDATE incoming_files SET detected_vendor_id = %s, status = 'processed' WHERE file_id = %s",
|
|
|
|
|
|
(vendor_id, file_id)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
sync_result = None
|
|
|
|
|
|
extraction_row = execute_query_single(
|
|
|
|
|
|
"SELECT * FROM extractions WHERE extraction_id = %s",
|
|
|
|
|
|
(extraction_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if extraction_row:
|
|
|
|
|
|
sync_result = _sync_globalconnect_extraction_to_internet(extraction_row)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
2026-03-02 13:12:01 +01:00
|
|
|
|
logger.info(f"✅ Linked vendor {vendor['name']} (ID: {vendor_id}) to file {file_id}")
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"vendor_id": vendor_id,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"vendor_name": vendor['name'],
|
|
|
|
|
|
"internet_sync": sync_result,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to link vendor: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/supplier-invoices/files/{file_id}")
|
|
|
|
|
|
async def delete_pending_file_endpoint(file_id: int):
|
|
|
|
|
|
"""Slet uploaded fil og relateret data"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get file info
|
2025-12-16 15:36:11 +01:00
|
|
|
|
file_info = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
# Check if already converted to invoice
|
2025-12-16 15:36:11 +01:00
|
|
|
|
invoice_exists = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"""SELECT si.id FROM supplier_invoices si
|
|
|
|
|
|
JOIN extractions e ON si.extraction_id = e.extraction_id
|
|
|
|
|
|
WHERE e.file_id = %s""",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if invoice_exists:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Kan ikke slette fil - der er allerede oprettet en faktura fra denne fil"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Delete from database (cascade will handle extractions)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"DELETE FROM incoming_files WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Delete physical file
|
2026-01-25 03:29:28 +01:00
|
|
|
|
if file_info and file_info.get('file_path'):
|
2025-12-08 09:15:52 +01:00
|
|
|
|
file_path = Path(file_info['file_path'])
|
|
|
|
|
|
if file_path.exists():
|
|
|
|
|
|
os.remove(file_path)
|
|
|
|
|
|
logger.info(f"🗑️ Deleted file: {file_path}")
|
|
|
|
|
|
|
|
|
|
|
|
return {"message": "Fil slettet", "file_id": file_id}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to delete file: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/supplier-invoices/files/{file_id}")
|
|
|
|
|
|
async def update_file_status(file_id: int, data: dict):
|
|
|
|
|
|
"""Opdater status på uploadet fil"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
allowed_statuses = ['pending', 'processing', 'processed', 'ai_extracted', 'completed', 'failed']
|
|
|
|
|
|
new_status = data.get('status')
|
|
|
|
|
|
|
|
|
|
|
|
if not new_status or new_status not in allowed_statuses:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Ugyldig status. Tilladte: {', '.join(allowed_statuses)}")
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = %s, processed_at = CURRENT_TIMESTAMP WHERE file_id = %s",
|
|
|
|
|
|
(new_status, file_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated file {file_id} status to {new_status}")
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "file_id": file_id, "new_status": new_status}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to update file status: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/files/{file_id}/link-vendor")
|
|
|
|
|
|
async def link_vendor_to_extraction(file_id: int, data: dict):
|
|
|
|
|
|
"""Link en eksisterende leverandør til en extraction"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
vendor_id = data.get('vendor_id')
|
|
|
|
|
|
|
|
|
|
|
|
if not vendor_id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="vendor_id er påkrævet")
|
|
|
|
|
|
|
|
|
|
|
|
# Verify vendor exists
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendor = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT id, name FROM vendors WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(vendor_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not vendor:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Leverandør {vendor_id} ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
# Get latest extraction for this file
|
2025-12-16 15:36:11 +01:00
|
|
|
|
extraction = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not extraction:
|
2026-03-02 13:12:41 +01:00
|
|
|
|
# Create minimal extraction if none exists
|
|
|
|
|
|
logger.info(f"⚠️ No extraction for file {file_id} — creating minimal extraction for vendor link")
|
|
|
|
|
|
extraction_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO extractions
|
|
|
|
|
|
(file_id, vendor_matched_id, vendor_name, vendor_cvr,
|
|
|
|
|
|
document_id, document_type, document_type_detected,
|
|
|
|
|
|
currency, confidence, status)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING extraction_id""",
|
|
|
|
|
|
(file_id, vendor_id, vendor['name'], None,
|
|
|
|
|
|
None, 'invoice', 'invoice', 'DKK', 1.0, 'manual')
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
extraction_id = extraction['extraction_id']
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE extractions SET vendor_matched_id = %s WHERE extraction_id = %s",
|
|
|
|
|
|
(vendor_id, extraction_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
execute_update(
|
2026-03-02 13:12:41 +01:00
|
|
|
|
"UPDATE incoming_files SET detected_vendor_id = %s, status = 'processed' WHERE file_id = %s",
|
|
|
|
|
|
(vendor_id, file_id)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
sync_result = None
|
|
|
|
|
|
extraction_row = execute_query_single(
|
|
|
|
|
|
"SELECT * FROM extractions WHERE extraction_id = %s",
|
|
|
|
|
|
(extraction_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if extraction_row:
|
|
|
|
|
|
sync_result = _sync_globalconnect_extraction_to_internet(extraction_row)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
2026-03-02 13:12:41 +01:00
|
|
|
|
logger.info(f"✅ Linked vendor {vendor['name']} (ID: {vendor_id}) to extraction {extraction_id}")
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"vendor_id": vendor_id,
|
|
|
|
|
|
"vendor_name": vendor['name'],
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"extraction_id": extraction_id,
|
|
|
|
|
|
"internet_sync": sync_result,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to link vendor: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/from-extraction/{file_id}")
|
|
|
|
|
|
async def create_invoice_from_extraction(file_id: int):
|
|
|
|
|
|
"""Opret leverandørfaktura fra extraction data"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get latest extraction for this file
|
2025-12-16 15:36:11 +01:00
|
|
|
|
extraction = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"""SELECT e.*, v.name as vendor_name
|
|
|
|
|
|
FROM extractions e
|
|
|
|
|
|
LEFT JOIN vendors v ON v.id = e.vendor_matched_id
|
|
|
|
|
|
WHERE e.file_id = %s
|
|
|
|
|
|
ORDER BY e.created_at DESC
|
|
|
|
|
|
LIMIT 1""",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not extraction:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
|
|
|
|
|
|
2026-04-12 09:26:35 +02:00
|
|
|
|
extraction_data = extraction
|
2026-01-25 03:29:28 +01:00
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
# Check if vendor is matched
|
2026-01-25 03:29:28 +01:00
|
|
|
|
if not extraction_data['vendor_matched_id']:
|
2025-12-08 09:15:52 +01:00
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Leverandør skal linkes før faktura kan oprettes. Brug 'Link eller Opret Leverandør' først."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Check if invoice already exists
|
2025-12-16 15:36:11 +01:00
|
|
|
|
existing = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT id FROM supplier_invoices WHERE extraction_id = %s",
|
2026-01-25 03:29:28 +01:00
|
|
|
|
(extraction_data['extraction_id'],))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
# Get extracted lines from DB, fallback to LLM payload lines.
|
|
|
|
|
|
lines = _load_extraction_lines(extraction_data)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
# Parse LLM response JSON if it's a string
|
|
|
|
|
|
import json
|
2026-01-25 03:29:28 +01:00
|
|
|
|
llm_data = extraction_data.get('llm_response_json')
|
2025-12-08 09:15:52 +01:00
|
|
|
|
if isinstance(llm_data, str):
|
|
|
|
|
|
try:
|
|
|
|
|
|
llm_data = json.loads(llm_data)
|
|
|
|
|
|
except:
|
|
|
|
|
|
llm_data = {}
|
|
|
|
|
|
elif not llm_data:
|
|
|
|
|
|
llm_data = {}
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
validation_warning = llm_data.get('_validation_warning') if isinstance(llm_data, dict) else None
|
|
|
|
|
|
vat_warning = llm_data.get('_vat_warning') if isinstance(llm_data, dict) else None
|
|
|
|
|
|
validation_details = llm_data.get('_validation_details') if isinstance(llm_data, dict) else None
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
# Get invoice number and type from LLM data or generate one
|
|
|
|
|
|
invoice_number = llm_data.get('invoice_number') if llm_data else None
|
|
|
|
|
|
if not invoice_number:
|
|
|
|
|
|
invoice_number = f"INV-{file_id}"
|
|
|
|
|
|
|
|
|
|
|
|
# Detect document type (invoice or credit_note)
|
|
|
|
|
|
document_type = llm_data.get('document_type', 'invoice') if llm_data else 'invoice'
|
|
|
|
|
|
invoice_type = 'credit_note' if document_type == 'credit_note' else 'invoice'
|
|
|
|
|
|
|
|
|
|
|
|
# Get dates - use today as fallback if missing
|
|
|
|
|
|
from datetime import datetime, timedelta
|
2026-01-25 03:29:28 +01:00
|
|
|
|
invoice_date = extraction_data.get('document_date')
|
2025-12-08 09:15:52 +01:00
|
|
|
|
if not invoice_date:
|
|
|
|
|
|
invoice_date = datetime.now().strftime('%Y-%m-%d')
|
|
|
|
|
|
logger.warning(f"⚠️ No invoice_date found, using today: {invoice_date}")
|
|
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
due_date = extraction_data.get('due_date')
|
2025-12-08 09:15:52 +01:00
|
|
|
|
if not due_date:
|
|
|
|
|
|
# Default to 30 days from invoice date
|
|
|
|
|
|
inv_date_obj = datetime.strptime(invoice_date, '%Y-%m-%d')
|
|
|
|
|
|
due_date = (inv_date_obj + timedelta(days=30)).strftime('%Y-%m-%d')
|
|
|
|
|
|
logger.warning(f"⚠️ No due_date found, using invoice_date + 30 days: {due_date}")
|
|
|
|
|
|
|
|
|
|
|
|
# Create supplier invoice
|
|
|
|
|
|
invoice_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO supplier_invoices (
|
|
|
|
|
|
vendor_id, invoice_number, invoice_date, due_date,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
total_amount, currency, status, workflow_status_v2, extraction_id, notes, invoice_type
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
RETURNING id""",
|
|
|
|
|
|
(
|
2026-01-25 03:29:28 +01:00
|
|
|
|
extraction_data['vendor_matched_id'],
|
2025-12-08 09:15:52 +01:00
|
|
|
|
invoice_number,
|
|
|
|
|
|
invoice_date,
|
|
|
|
|
|
due_date,
|
2026-01-25 03:29:28 +01:00
|
|
|
|
extraction_data['total_amount'],
|
|
|
|
|
|
extraction_data['currency'],
|
2026-04-15 09:34:26 +02:00
|
|
|
|
'cancelled' if invoice_type == 'credit_note' else 'pending',
|
|
|
|
|
|
'afvist' if invoice_type == 'credit_note' else 'modtaget',
|
2026-01-25 03:29:28 +01:00
|
|
|
|
extraction_data['extraction_id'],
|
2025-12-08 09:15:52 +01:00
|
|
|
|
f"Oprettet fra AI extraction (file_id: {file_id})",
|
|
|
|
|
|
invoice_type
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-04-12 09:26:35 +02:00
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
_record_supplier_invoice_event(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
event_type="invoice_created",
|
|
|
|
|
|
from_status=None,
|
|
|
|
|
|
to_status='afvist' if invoice_type == 'credit_note' else 'modtaget',
|
|
|
|
|
|
payload={"source": "from_extraction", "file_id": file_id, "invoice_type": invoice_type},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-12 09:26:35 +02:00
|
|
|
|
sag_id = _ensure_case_for_supplier_invoice(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
invoice_number=invoice_number,
|
|
|
|
|
|
vendor_name=extraction.get("vendor_name"),
|
|
|
|
|
|
total_amount=extraction_data.get("total_amount"),
|
|
|
|
|
|
currency=extraction_data.get("currency"),
|
|
|
|
|
|
file_id=file_id,
|
|
|
|
|
|
)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
_append_amount_validation_case_note(
|
|
|
|
|
|
sag_id=sag_id,
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
invoice_number=invoice_number,
|
|
|
|
|
|
validation_details=validation_details,
|
|
|
|
|
|
validation_warning=validation_warning,
|
|
|
|
|
|
vat_warning=vat_warning,
|
|
|
|
|
|
file_id=file_id,
|
|
|
|
|
|
)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
# Create invoice lines
|
|
|
|
|
|
if lines:
|
|
|
|
|
|
for line in lines:
|
2026-04-15 09:34:26 +02:00
|
|
|
|
quantity = _to_decimal(line.get('quantity'), Decimal('1'))
|
|
|
|
|
|
unit_price = _to_decimal(line.get('unit_price'))
|
|
|
|
|
|
line_total = _to_decimal(line.get('line_total'))
|
|
|
|
|
|
if line_total <= 0:
|
|
|
|
|
|
line_total = quantity * unit_price
|
|
|
|
|
|
vat_rate = _to_decimal(line.get('vat_rate'), Decimal('25.00'))
|
|
|
|
|
|
vat_amount = _to_decimal(line.get('vat_amount'))
|
|
|
|
|
|
sku = line.get('sku') or line.get('item_number')
|
|
|
|
|
|
product_id = _ensure_product_for_supplier_line(
|
|
|
|
|
|
vendor_id=extraction_data.get('vendor_matched_id'),
|
|
|
|
|
|
vendor_name=extraction.get('vendor_name'),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
description=_compose_supplier_line_description(line),
|
2026-04-15 09:34:26 +02:00
|
|
|
|
sku=sku,
|
|
|
|
|
|
unit_price=unit_price,
|
|
|
|
|
|
currency=extraction_data.get('currency'),
|
|
|
|
|
|
vat_rate=vat_rate,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
execute_update(
|
|
|
|
|
|
"""INSERT INTO supplier_invoice_lines (
|
2026-04-15 09:34:26 +02:00
|
|
|
|
supplier_invoice_id, line_number, description, quantity, unit_price,
|
|
|
|
|
|
line_total, vat_rate, vat_amount, product_id, sku
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
2025-12-08 09:15:52 +01:00
|
|
|
|
(
|
|
|
|
|
|
invoice_id,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
line.get('line_number'),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
_compose_supplier_line_description(line),
|
2026-04-15 09:34:26 +02:00
|
|
|
|
quantity,
|
|
|
|
|
|
unit_price,
|
|
|
|
|
|
line_total,
|
|
|
|
|
|
vat_rate,
|
|
|
|
|
|
vat_amount,
|
|
|
|
|
|
product_id,
|
|
|
|
|
|
sku,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Update file status
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = 'completed' WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
|
|
|
|
|
|
internet_sync_result = _sync_globalconnect_extraction_to_internet(extraction_data)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created supplier invoice {invoice_id} from extraction {extraction['extraction_id']}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"invoice_id": invoice_id,
|
2026-04-12 09:26:35 +02:00
|
|
|
|
"sag_id": sag_id,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"invoice_number": invoice_number,
|
|
|
|
|
|
"vendor_name": extraction['vendor_name'],
|
|
|
|
|
|
"total_amount": extraction['total_amount'],
|
2026-07-09 23:44:30 +02:00
|
|
|
|
"currency": extraction['currency'],
|
|
|
|
|
|
"internet_sync": internet_sync_result,
|
|
|
|
|
|
"amount_validation": {
|
|
|
|
|
|
"warning": validation_warning,
|
|
|
|
|
|
"vat_warning": vat_warning,
|
|
|
|
|
|
"details": validation_details,
|
|
|
|
|
|
},
|
2025-12-08 09:15:52 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to create invoice from extraction: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
|
@router.post("/supplier-invoices/files/{file_id}/sync-internet")
|
|
|
|
|
|
async def sync_extraction_to_internet(file_id: int):
|
|
|
|
|
|
"""Create or update internet connections/IP ranges from a GlobalConnect extraction."""
|
|
|
|
|
|
extraction = execute_query_single(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT *
|
|
|
|
|
|
FROM extractions
|
|
|
|
|
|
WHERE file_id = %s
|
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""",
|
|
|
|
|
|
(file_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not extraction:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
|
result = _sync_globalconnect_extraction_to_internet(extraction, force=True)
|
2026-07-09 23:44:30 +02:00
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"internet_sync": result,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
# Keep existing endpoints below...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========== TEMPLATE MANAGEMENT (must be before {invoice_id} route) ==========
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/templates")
|
|
|
|
|
|
async def list_templates():
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
"""Hent alle templates (både database og invoice2data YAML)"""
|
2025-12-08 09:15:52 +01:00
|
|
|
|
try:
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Get database templates
|
2025-12-08 09:15:52 +01:00
|
|
|
|
query = """
|
|
|
|
|
|
SELECT t.*, v.name as vendor_name
|
|
|
|
|
|
FROM supplier_invoice_templates t
|
|
|
|
|
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
|
|
|
|
|
WHERE t.is_active = true
|
|
|
|
|
|
ORDER BY t.created_at DESC
|
|
|
|
|
|
"""
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
db_templates = execute_query(query) or []
|
|
|
|
|
|
|
|
|
|
|
|
# Get invoice2data templates
|
|
|
|
|
|
invoice2data_service = get_invoice2data_service()
|
|
|
|
|
|
invoice2data_templates = []
|
|
|
|
|
|
|
|
|
|
|
|
for template_name, template_data in invoice2data_service.templates.items():
|
|
|
|
|
|
# Extract vendor CVR from keywords
|
|
|
|
|
|
vendor_cvr = None
|
|
|
|
|
|
keywords = template_data.get('keywords', [])
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
|
if isinstance(keyword, str) and keyword.isdigit() and len(keyword) == 8:
|
|
|
|
|
|
vendor_cvr = keyword
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# Get vendor info from database if CVR found
|
|
|
|
|
|
vendor_name = template_data.get('issuer', 'Ukendt')
|
|
|
|
|
|
vendor_id = None
|
|
|
|
|
|
if vendor_cvr:
|
|
|
|
|
|
vendor = execute_query(
|
|
|
|
|
|
"SELECT id, name FROM vendors WHERE cvr_number = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(vendor_cvr,))
|
2026-01-25 03:29:28 +01:00
|
|
|
|
if vendor and len(vendor) > 0:
|
|
|
|
|
|
vendor_data = vendor[0]
|
|
|
|
|
|
vendor_id = vendor_data['id']
|
|
|
|
|
|
vendor_name = vendor_data['name']
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
|
|
|
|
|
|
invoice2data_templates.append({
|
|
|
|
|
|
'template_id': -1, # Negative ID to distinguish from DB templates
|
|
|
|
|
|
'template_name': f"Invoice2Data: {template_name}",
|
|
|
|
|
|
'template_type': 'invoice2data',
|
|
|
|
|
|
'yaml_filename': template_name,
|
|
|
|
|
|
'vendor_id': vendor_id,
|
|
|
|
|
|
'vendor_name': vendor_name,
|
|
|
|
|
|
'vendor_cvr': vendor_cvr,
|
|
|
|
|
|
'default_product_category': template_data.get('default_product_category', 'varesalg'),
|
|
|
|
|
|
'default_product_group_number': template_data.get('default_product_group_number', 1),
|
|
|
|
|
|
'usage_count': 0, # Could track this separately
|
|
|
|
|
|
'is_active': True,
|
|
|
|
|
|
'detection_patterns': keywords,
|
|
|
|
|
|
'field_mappings': template_data.get('fields', {}),
|
|
|
|
|
|
'created_at': None
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Combine both types
|
|
|
|
|
|
all_templates = db_templates + invoice2data_templates
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
return all_templates
|
2025-12-08 09:15:52 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to list templates: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 23:46:18 +01:00
|
|
|
|
@router.get("/supplier-invoices/templates/{template_id}")
|
|
|
|
|
|
async def get_template(template_id: int):
|
|
|
|
|
|
"""Hent et specifikt template med vendor info"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = """
|
|
|
|
|
|
SELECT t.*, v.name as vendor_name, v.cvr_number as vendor_cvr
|
|
|
|
|
|
FROM supplier_invoice_templates t
|
|
|
|
|
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
|
|
|
|
|
WHERE t.template_id = %s AND t.is_active = true
|
|
|
|
|
|
"""
|
2025-12-16 15:36:11 +01:00
|
|
|
|
template = execute_query_single(query, (template_id,))
|
2025-12-08 23:46:18 +01:00
|
|
|
|
|
|
|
|
|
|
if not template:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
|
|
|
|
|
|
|
|
|
|
return template
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get template {template_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
@router.post("/supplier-invoices/search-vendor")
|
|
|
|
|
|
async def search_vendor_by_info(request: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Søg efter vendor baseret på navn, CVR, eller opret ny
|
|
|
|
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
{
|
|
|
|
|
|
"vendor_name": "DCS ApS",
|
|
|
|
|
|
"vendor_cvr": "12345678",
|
|
|
|
|
|
"vendor_address": "Vej 1, 2000 By",
|
|
|
|
|
|
"create_if_missing": true
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
vendor_name = request.get('vendor_name')
|
|
|
|
|
|
vendor_cvr = request.get('vendor_cvr')
|
|
|
|
|
|
vendor_address = request.get('vendor_address')
|
|
|
|
|
|
create_if_missing = request.get('create_if_missing', False)
|
|
|
|
|
|
|
|
|
|
|
|
# Search by CVR first (most accurate)
|
|
|
|
|
|
if vendor_cvr:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendor = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(vendor_cvr,))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
if vendor:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"found": True,
|
|
|
|
|
|
"vendor_id": vendor['id'],
|
|
|
|
|
|
"vendor_name": vendor['name'],
|
|
|
|
|
|
"source": "cvr_match"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Search by name (fuzzy)
|
|
|
|
|
|
if vendor_name:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendors = execute_query_single(
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"SELECT id, name, cvr_number FROM vendors WHERE LOWER(name) LIKE LOWER(%s) LIMIT 5",
|
|
|
|
|
|
(f"%{vendor_name}%",)
|
|
|
|
|
|
)
|
|
|
|
|
|
if vendors:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"found": True,
|
|
|
|
|
|
"matches": vendors,
|
|
|
|
|
|
"source": "name_search",
|
|
|
|
|
|
"message": "Flere mulige matches - vælg en eller opret ny"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Create new vendor if requested
|
|
|
|
|
|
if create_if_missing and vendor_name:
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
# Validate not creating vendor with own CVR
|
|
|
|
|
|
if vendor_cvr and settings.OWN_CVR in vendor_cvr:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Kan ikke oprette vendor med eget CVR ({settings.OWN_CVR})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
new_vendor_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO vendors (name, cvr_number, address, created_at)
|
|
|
|
|
|
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)""",
|
|
|
|
|
|
(vendor_name, vendor_cvr, vendor_address)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created new vendor: {vendor_name} (ID: {new_vendor_id})")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"found": False,
|
|
|
|
|
|
"created": True,
|
|
|
|
|
|
"vendor_id": new_vendor_id,
|
|
|
|
|
|
"vendor_name": vendor_name,
|
|
|
|
|
|
"source": "newly_created"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"found": False,
|
|
|
|
|
|
"message": "Ingen vendor fundet - angiv create_if_missing=true for at oprette"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Vendor search failed: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/ai/analyze")
|
|
|
|
|
|
async def ai_analyze_invoice(request: Dict):
|
|
|
|
|
|
"""Brug AI til at analysere faktura og foreslå template felter"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
pdf_text = request.get('pdf_text', '')
|
|
|
|
|
|
vendor_id = request.get('vendor_id')
|
|
|
|
|
|
|
|
|
|
|
|
if not pdf_text:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Ingen PDF tekst angivet")
|
|
|
|
|
|
|
|
|
|
|
|
# Build enhanced PDF text with instruction
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
enhanced_text = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
|
|
|
|
|
|
|
|
|
|
|
|
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
|
|
|
|
|
|
|
|
|
|
|
|
REQUIRED STRUKTUR (alle felter skal med):
|
|
|
|
|
|
{{
|
|
|
|
|
|
"invoice_number": "5082481",
|
|
|
|
|
|
"invoice_date": "24/10-25",
|
|
|
|
|
|
"total_amount": "1471.20",
|
|
|
|
|
|
"cvr": "29522790",
|
|
|
|
|
|
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
|
|
|
|
|
|
"lines_start": "Nr.VarenrTekst",
|
|
|
|
|
|
"lines_end": "Subtotal"
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
FIND FØLGENDE:
|
|
|
|
|
|
1. invoice_number: Fakturanummer (efter "Nummer", "Faktura nr", "Invoice")
|
|
|
|
|
|
2. invoice_date: Dato (format DD/MM-YY eller DD-MM-YYYY)
|
|
|
|
|
|
3. total_amount: Total beløb
|
|
|
|
|
|
- Søg efter "Total", "I alt", "Totalbeløb"
|
|
|
|
|
|
- Hvis beløbet er på næste linje, match sidste tal
|
|
|
|
|
|
- Format: [\d.,]+ (f.eks. 1.471,20 eller 1471.20)
|
|
|
|
|
|
4. cvr: CVR nummer (8 cifre efter "CVR", "Momsnr", "DK")
|
|
|
|
|
|
- IGNORER CVR {settings.OWN_CVR} - dette er KØBERS CVR, ikke leverandør!
|
|
|
|
|
|
- Find LEVERANDØRENS CVR (normalt i toppen/header)
|
|
|
|
|
|
5. detection_patterns: 3-5 UNIKKE tekststrenge der identificerer leverandøren
|
|
|
|
|
|
- Leverandørens navn (f.eks. "DCS ApS", "ALSO A/S")
|
|
|
|
|
|
- Website eller email (f.eks. "WWW.DCS.DK")
|
|
|
|
|
|
- Adresse element (f.eks. "Høgemosevænget", "Mårkærvej")
|
|
|
|
|
|
- UNDGÅ generiske ord som "Faktura", "Danmark", "Side"
|
|
|
|
|
|
6. lines_start: Tekst LIGE FØR varelinjer (f.eks. "Nr.VarenrTekst", "Position Varenr")
|
|
|
|
|
|
7. lines_end: Tekst EFTER varelinjer (f.eks. "Subtotal", "I alt", "Side 1 af")
|
|
|
|
|
|
|
|
|
|
|
|
VIGTIGT:
|
|
|
|
|
|
- detection_patterns SKAL være mindst 3 specifikke tekststrenge
|
|
|
|
|
|
- Vælg tekststrenge der er UNIKKE for denne leverandør
|
|
|
|
|
|
- CVR SKAL være leverandørens - IKKE {settings.OWN_CVR} (det er køber)
|
|
|
|
|
|
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk rå data
|
|
|
|
|
|
|
|
|
|
|
|
PDF TEKST:
|
|
|
|
|
|
{pdf_text[:2000]}
|
|
|
|
|
|
|
|
|
|
|
|
RETURNER KUN JSON - intet andet!"""
|
|
|
|
|
|
|
|
|
|
|
|
# Call Ollama
|
|
|
|
|
|
logger.info(f"🤖 Starter AI analyse af {len(pdf_text)} tegn PDF tekst")
|
|
|
|
|
|
result = await ollama_service.extract_from_text(enhanced_text)
|
|
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen")
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
logger.info(f"✅ AI analyse gennemført: {result}")
|
|
|
|
|
|
return result
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-12-08 09:15:52 +01:00
|
|
|
|
logger.error(f"❌ AI analyse fejlede: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}")
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
@router.post("/supplier-invoices/templates")
|
|
|
|
|
|
async def create_template(request: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Opret ny template
|
|
|
|
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
{
|
|
|
|
|
|
"vendor_id": 1,
|
|
|
|
|
|
"template_name": "Test Template",
|
|
|
|
|
|
"detection_patterns": [{"type": "text", "pattern": "BMC Denmark", "weight": 0.5}],
|
|
|
|
|
|
"field_mappings": {"invoice_number": {"pattern": r"Nummer\s*(\d+)", "group": 1}}
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
2025-12-07 03:29:54 +01:00
|
|
|
|
try:
|
2025-12-08 09:15:52 +01:00
|
|
|
|
import json
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
vendor_id = request.get('vendor_id')
|
|
|
|
|
|
template_name = request.get('template_name')
|
|
|
|
|
|
detection_patterns = request.get('detection_patterns', [])
|
|
|
|
|
|
field_mappings = request.get('field_mappings', {})
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
default_product_category = request.get('default_product_category', 'varesalg')
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
|
|
|
|
|
if not vendor_id or not template_name:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="vendor_id og template_name er påkrævet")
|
|
|
|
|
|
|
|
|
|
|
|
# Validate that vendor CVR is not own company
|
|
|
|
|
|
vendor_cvr_mapping = field_mappings.get('vendor_cvr', {})
|
|
|
|
|
|
if vendor_cvr_mapping:
|
|
|
|
|
|
# Extract CVR value from pattern or value field
|
|
|
|
|
|
cvr_value = vendor_cvr_mapping.get('value') or vendor_cvr_mapping.get('pattern', '')
|
|
|
|
|
|
if settings.OWN_CVR in str(cvr_value):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"CVR {cvr_value} matcher egen virksomhed ({settings.OWN_CVR}). Brug leverandørens CVR, ikke købers!"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Insert template and get template_id
|
2025-12-07 03:29:54 +01:00
|
|
|
|
query = """
|
2025-12-08 09:15:52 +01:00
|
|
|
|
INSERT INTO supplier_invoice_templates
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
(vendor_id, template_name, detection_patterns, field_mappings, default_product_category)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
RETURNING template_id
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
result = execute_query(query, (vendor_id, template_name, json.dumps(detection_patterns), json.dumps(field_mappings), default_product_category))
|
2025-12-08 09:15:52 +01:00
|
|
|
|
template_id = result[0]['template_id'] if result else None
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
if not template_id:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Kunne ikke oprette template")
|
|
|
|
|
|
|
|
|
|
|
|
# Reload templates in cache
|
|
|
|
|
|
template_service.reload_templates()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Template created: {template_name} (ID: {template_id}) for vendor {vendor_id}")
|
|
|
|
|
|
return {"template_id": template_id, "message": "Template oprettet"}
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
2025-12-07 03:29:54 +01:00
|
|
|
|
except Exception as e:
|
2025-12-08 09:15:52 +01:00
|
|
|
|
logger.error(f"❌ Failed to create template: {e}", exc_info=True)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/{invoice_id}")
|
|
|
|
|
|
async def get_supplier_invoice(invoice_id: int):
|
|
|
|
|
|
"""Get single supplier invoice with lines"""
|
|
|
|
|
|
try:
|
2026-01-07 10:32:41 +01:00
|
|
|
|
invoice_result = execute_query(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""SELECT si.*, v.name as vendor_full_name, v.economic_supplier_number as vendor_economic_id
|
|
|
|
|
|
FROM supplier_invoices si
|
|
|
|
|
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
|
|
|
|
|
WHERE si.id = %s""",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(invoice_id,))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
if not invoice_result:
|
2025-12-07 03:29:54 +01:00
|
|
|
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
|
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
invoice = invoice_result[0]
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
# Get lines
|
2025-12-16 15:36:11 +01:00
|
|
|
|
lines = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
|
|
|
|
|
(invoice_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
invoice['lines'] = lines
|
|
|
|
|
|
|
|
|
|
|
|
return invoice
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get supplier invoice {invoice_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices")
|
|
|
|
|
|
async def create_supplier_invoice(data: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create new supplier invoice
|
|
|
|
|
|
|
|
|
|
|
|
Required fields:
|
|
|
|
|
|
- invoice_number: str
|
|
|
|
|
|
- vendor_id: int
|
|
|
|
|
|
- invoice_date: str (YYYY-MM-DD)
|
|
|
|
|
|
- total_amount: float
|
|
|
|
|
|
|
|
|
|
|
|
Optional fields:
|
|
|
|
|
|
- due_date: str (YYYY-MM-DD) - defaults to invoice_date + 30 days
|
|
|
|
|
|
- vat_amount: float
|
|
|
|
|
|
- net_amount: float
|
|
|
|
|
|
- currency: str (default 'DKK')
|
|
|
|
|
|
- description: str
|
|
|
|
|
|
- notes: str
|
|
|
|
|
|
- lines: List[Dict] with line items
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Validate required fields
|
|
|
|
|
|
required = ['invoice_number', 'vendor_id', 'invoice_date', 'total_amount']
|
|
|
|
|
|
missing = [f for f in required if f not in data]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Missing required fields: {', '.join(missing)}")
|
|
|
|
|
|
|
|
|
|
|
|
# Calculate due_date if not provided (30 days default)
|
|
|
|
|
|
invoice_date = datetime.fromisoformat(data['invoice_date'])
|
|
|
|
|
|
due_date = data.get('due_date')
|
|
|
|
|
|
if not due_date:
|
|
|
|
|
|
due_date = (invoice_date + timedelta(days=30)).strftime('%Y-%m-%d')
|
|
|
|
|
|
|
2025-12-08 09:15:52 +01:00
|
|
|
|
# Determine invoice type (default to invoice)
|
|
|
|
|
|
invoice_type = data.get('invoice_type', 'invoice')
|
|
|
|
|
|
if invoice_type not in ['invoice', 'credit_note']:
|
|
|
|
|
|
invoice_type = 'invoice'
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
# Insert supplier invoice
|
|
|
|
|
|
invoice_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO supplier_invoices
|
|
|
|
|
|
(invoice_number, vendor_id, vendor_name, invoice_date, due_date,
|
|
|
|
|
|
total_amount, vat_amount, net_amount, currency, description, notes,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
status, workflow_status_v2, created_by, invoice_type)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id""",
|
2025-12-07 03:29:54 +01:00
|
|
|
|
(
|
|
|
|
|
|
data['invoice_number'],
|
|
|
|
|
|
data['vendor_id'],
|
|
|
|
|
|
data.get('vendor_name'),
|
|
|
|
|
|
data['invoice_date'],
|
|
|
|
|
|
due_date,
|
|
|
|
|
|
data['total_amount'],
|
|
|
|
|
|
data.get('vat_amount', 0),
|
|
|
|
|
|
data.get('net_amount', data['total_amount']),
|
|
|
|
|
|
data.get('currency', 'DKK'),
|
|
|
|
|
|
data.get('description'),
|
|
|
|
|
|
data.get('notes'),
|
2026-04-15 09:34:26 +02:00
|
|
|
|
'cancelled' if invoice_type == 'credit_note' else 'pending',
|
|
|
|
|
|
'afvist' if invoice_type == 'credit_note' else 'modtaget',
|
2025-12-08 09:15:52 +01:00
|
|
|
|
data.get('created_by'),
|
|
|
|
|
|
invoice_type
|
2025-12-07 03:29:54 +01:00
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-04-15 09:34:26 +02:00
|
|
|
|
|
|
|
|
|
|
_record_supplier_invoice_event(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
event_type="invoice_created",
|
|
|
|
|
|
from_status=None,
|
|
|
|
|
|
to_status='afvist' if invoice_type == 'credit_note' else 'modtaget',
|
|
|
|
|
|
payload={"source": "manual_create", "invoice_type": invoice_type},
|
|
|
|
|
|
)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
# Insert lines if provided
|
|
|
|
|
|
if data.get('lines'):
|
|
|
|
|
|
for idx, line in enumerate(data['lines'], start=1):
|
2025-12-08 23:46:18 +01:00
|
|
|
|
# Map vat_code: I52 for reverse charge, I25 for standard
|
|
|
|
|
|
vat_code = line.get('vat_code', 'I25')
|
2026-04-15 09:34:26 +02:00
|
|
|
|
quantity = _to_decimal(line.get('quantity'), Decimal('1'))
|
|
|
|
|
|
unit_price = _to_decimal(line.get('unit_price'))
|
|
|
|
|
|
line_total = _to_decimal(line.get('line_total'))
|
|
|
|
|
|
if line_total <= 0:
|
|
|
|
|
|
line_total = quantity * unit_price
|
|
|
|
|
|
vat_rate = _to_decimal(line.get('vat_rate'), Decimal('25.00'))
|
|
|
|
|
|
vat_amount = _to_decimal(line.get('vat_amount'))
|
|
|
|
|
|
sku = line.get('sku')
|
|
|
|
|
|
product_id = line.get('product_id')
|
|
|
|
|
|
if not product_id:
|
|
|
|
|
|
product_id = _ensure_product_for_supplier_line(
|
|
|
|
|
|
vendor_id=data.get('vendor_id'),
|
|
|
|
|
|
vendor_name=data.get('vendor_name'),
|
|
|
|
|
|
description=line.get('description'),
|
|
|
|
|
|
sku=sku,
|
|
|
|
|
|
unit_price=unit_price,
|
|
|
|
|
|
currency=data.get('currency', 'DKK'),
|
|
|
|
|
|
vat_rate=vat_rate,
|
|
|
|
|
|
)
|
2025-12-08 23:46:18 +01:00
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
execute_update(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""INSERT INTO supplier_invoice_lines
|
|
|
|
|
|
(supplier_invoice_id, line_number, description, quantity, unit_price,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
line_total, vat_code, vat_rate, vat_amount, contra_account, product_id, sku)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
2025-12-07 03:29:54 +01:00
|
|
|
|
(
|
|
|
|
|
|
invoice_id,
|
|
|
|
|
|
line.get('line_number', idx),
|
|
|
|
|
|
line.get('description'),
|
2026-04-15 09:34:26 +02:00
|
|
|
|
quantity,
|
|
|
|
|
|
unit_price,
|
|
|
|
|
|
line_total,
|
2025-12-08 23:46:18 +01:00
|
|
|
|
vat_code,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
vat_rate,
|
|
|
|
|
|
vat_amount,
|
2025-12-07 03:29:54 +01:00
|
|
|
|
line.get('contra_account', '5810'),
|
2026-04-15 09:34:26 +02:00
|
|
|
|
product_id,
|
|
|
|
|
|
sku,
|
2025-12-07 03:29:54 +01:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created supplier invoice: {data['invoice_number']} (ID: {invoice_id})")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"invoice_number": data['invoice_number'],
|
|
|
|
|
|
"due_date": due_date
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to create supplier invoice: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/supplier-invoices/{invoice_id}")
|
|
|
|
|
|
async def update_supplier_invoice(invoice_id: int, data: Dict):
|
|
|
|
|
|
"""Update supplier invoice details"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Check if invoice exists
|
|
|
|
|
|
existing = execute_query(
|
|
|
|
|
|
"SELECT id, status FROM supplier_invoices WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(invoice_id,))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if not existing:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
|
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
existing_invoice = existing[0]
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
# Don't allow editing if already sent to e-conomic
|
2026-01-25 03:29:28 +01:00
|
|
|
|
if existing_invoice['status'] == 'sent_to_economic':
|
2025-12-07 03:29:54 +01:00
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Cannot edit invoice that has been sent to e-conomic"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Build update query dynamically based on provided fields
|
|
|
|
|
|
update_fields = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
allowed_fields = ['invoice_number', 'vendor_id', 'vendor_name', 'invoice_date',
|
|
|
|
|
|
'due_date', 'total_amount', 'vat_amount', 'net_amount',
|
|
|
|
|
|
'currency', 'description', 'notes', 'status']
|
|
|
|
|
|
|
|
|
|
|
|
for field in allowed_fields:
|
|
|
|
|
|
if field in data:
|
|
|
|
|
|
update_fields.append(f"{field} = %s")
|
|
|
|
|
|
params.append(data[field])
|
|
|
|
|
|
|
|
|
|
|
|
if not update_fields:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
|
|
|
|
|
|
|
|
params.append(invoice_id)
|
|
|
|
|
|
|
|
|
|
|
|
query = f"""
|
|
|
|
|
|
UPDATE supplier_invoices
|
|
|
|
|
|
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(query, tuple(params))
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated supplier invoice {invoice_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {"success": True, "invoice_id": invoice_id}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to update supplier invoice {invoice_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
@router.patch("/supplier-invoices/{invoice_id}/lines/{line_id}")
|
|
|
|
|
|
async def update_invoice_line(invoice_id: int, line_id: int, data: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update supplier invoice line item
|
|
|
|
|
|
|
|
|
|
|
|
Supports updating: contra_account, line_purpose, resale_customer_id, resale_order_number
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Check if invoice exists and is not sent to e-conomic
|
|
|
|
|
|
invoice = execute_query(
|
|
|
|
|
|
"SELECT id, status FROM supplier_invoices WHERE id = %s",
|
|
|
|
|
|
(invoice_id,))
|
|
|
|
|
|
|
|
|
|
|
|
if not invoice:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
invoice_data = invoice[0]
|
|
|
|
|
|
|
|
|
|
|
|
# Don't allow editing if already sent to e-conomic
|
|
|
|
|
|
if invoice_data['status'] == 'sent_to_economic':
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Cannot edit invoice line that has been sent to e-conomic"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Check if line exists
|
|
|
|
|
|
line = execute_query(
|
|
|
|
|
|
"SELECT id FROM supplier_invoice_lines WHERE id = %s AND supplier_invoice_id = %s",
|
|
|
|
|
|
(line_id, invoice_id))
|
|
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
if not line or len(line) == 0:
|
2026-01-07 10:32:41 +01:00
|
|
|
|
raise HTTPException(status_code=404, detail=f"Line {line_id} not found in invoice {invoice_id}")
|
|
|
|
|
|
|
|
|
|
|
|
# Build update query
|
|
|
|
|
|
update_fields = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
allowed_fields = ['contra_account', 'line_purpose', 'resale_customer_id',
|
|
|
|
|
|
'resale_order_number', 'description', 'quantity',
|
|
|
|
|
|
'unit_price', 'vat_rate', 'total_amount']
|
|
|
|
|
|
|
|
|
|
|
|
for field in allowed_fields:
|
|
|
|
|
|
if field in data:
|
|
|
|
|
|
update_fields.append(f"{field} = %s")
|
|
|
|
|
|
params.append(data[field])
|
|
|
|
|
|
|
|
|
|
|
|
if not update_fields:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
|
|
|
|
|
|
|
|
params.append(line_id)
|
|
|
|
|
|
|
|
|
|
|
|
query = f"""
|
|
|
|
|
|
UPDATE supplier_invoice_lines
|
|
|
|
|
|
SET {', '.join(update_fields)}
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(query, tuple(params))
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated invoice line {line_id} (Invoice {invoice_id})")
|
|
|
|
|
|
|
|
|
|
|
|
return {"success": True, "line_id": line_id}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to update invoice line {line_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
@router.delete("/supplier-invoices/{invoice_id}")
|
|
|
|
|
|
async def delete_supplier_invoice(invoice_id: int):
|
|
|
|
|
|
"""Delete supplier invoice (soft delete if integrated with e-conomic)"""
|
|
|
|
|
|
try:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
invoice = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(invoice_id,))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if not invoice:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
# If sent to e-conomic, only mark as cancelled (don't delete)
|
|
|
|
|
|
if invoice.get('economic_voucher_number'):
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE supplier_invoices SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
|
|
|
|
|
(invoice_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"⚠️ Marked supplier invoice {invoice['invoice_number']} as cancelled (sent to e-conomic)")
|
|
|
|
|
|
return {"success": True, "message": "Invoice marked as cancelled", "invoice_id": invoice_id}
|
|
|
|
|
|
|
|
|
|
|
|
# Otherwise, delete invoice and lines
|
|
|
|
|
|
execute_update("DELETE FROM supplier_invoice_lines WHERE supplier_invoice_id = %s", (invoice_id,))
|
|
|
|
|
|
execute_update("DELETE FROM supplier_invoices WHERE id = %s", (invoice_id,))
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🗑️ Deleted supplier invoice {invoice['invoice_number']} (ID: {invoice_id})")
|
|
|
|
|
|
|
|
|
|
|
|
return {"success": True, "message": "Invoice deleted", "invoice_id": invoice_id}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to delete supplier invoice {invoice_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========== E-CONOMIC INTEGRATION ==========
|
|
|
|
|
|
|
2025-12-15 12:28:12 +01:00
|
|
|
|
class ApproveRequest(BaseModel):
|
|
|
|
|
|
approved_by: str
|
|
|
|
|
|
|
2026-03-18 07:14:28 +01:00
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
class RejectRequest(BaseModel):
|
|
|
|
|
|
rejected_by: str
|
|
|
|
|
|
reason: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 07:14:28 +01:00
|
|
|
|
class MarkPaidRequest(BaseModel):
|
|
|
|
|
|
paid_date: Optional[date] = None
|
2026-04-15 09:34:26 +02:00
|
|
|
|
amount: Optional[Decimal] = None
|
|
|
|
|
|
payment_method: Optional[str] = None
|
|
|
|
|
|
payment_reference: Optional[str] = None
|
|
|
|
|
|
notes: Optional[str] = None
|
|
|
|
|
|
paid_by: Optional[str] = None
|
2026-03-18 07:14:28 +01:00
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
@router.post("/supplier-invoices/{invoice_id}/approve")
|
2025-12-15 12:28:12 +01:00
|
|
|
|
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
2026-04-15 09:34:26 +02:00
|
|
|
|
"""Approve supplier invoice for payment (v2 status flow)."""
|
2025-12-07 03:29:54 +01:00
|
|
|
|
try:
|
2026-04-15 09:34:26 +02:00
|
|
|
|
transition = _transition_invoice_status_v2(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
new_status_v2="godkendt",
|
|
|
|
|
|
actor=request.approved_by,
|
2025-12-07 03:29:54 +01:00
|
|
|
|
)
|
2026-04-15 09:34:26 +02:00
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"✅ Approved supplier invoice %s by %s (%s -> %s)",
|
|
|
|
|
|
transition.get("invoice_number"),
|
|
|
|
|
|
request.approved_by,
|
|
|
|
|
|
transition.get("from_status"),
|
|
|
|
|
|
transition.get("to_status"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"approved_by": request.approved_by,
|
|
|
|
|
|
"status_v2": transition.get("to_status"),
|
|
|
|
|
|
"changed": transition.get("changed", False),
|
|
|
|
|
|
}
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to approve invoice {invoice_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
@router.post("/supplier-invoices/{invoice_id}/reject")
|
|
|
|
|
|
async def reject_supplier_invoice(invoice_id: int, request: RejectRequest):
|
|
|
|
|
|
"""Reject supplier invoice in v2 workflow."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
transition = _transition_invoice_status_v2(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
new_status_v2="afvist",
|
|
|
|
|
|
actor=request.rejected_by,
|
|
|
|
|
|
reason=request.reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"❌ Rejected supplier invoice %s by %s (%s -> %s)",
|
|
|
|
|
|
transition.get("invoice_number"),
|
|
|
|
|
|
request.rejected_by,
|
|
|
|
|
|
transition.get("from_status"),
|
|
|
|
|
|
transition.get("to_status"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"rejected_by": request.rejected_by,
|
|
|
|
|
|
"status_v2": transition.get("to_status"),
|
|
|
|
|
|
"changed": transition.get("changed", False),
|
|
|
|
|
|
}
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to reject invoice {invoice_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/{invoice_id}/payments")
|
|
|
|
|
|
async def get_supplier_invoice_payments(invoice_id: int):
|
|
|
|
|
|
"""Return all registered payments and remaining balance for a supplier invoice."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
invoice = execute_query_single(
|
|
|
|
|
|
"SELECT id, total_amount, currency FROM supplier_invoices WHERE id = %s",
|
|
|
|
|
|
(invoice_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not invoice:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
payments = execute_query(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, payment_date, amount, currency, payment_method, payment_reference, notes, paid_by, created_at
|
|
|
|
|
|
FROM supplier_invoice_payments
|
|
|
|
|
|
WHERE supplier_invoice_id = %s
|
|
|
|
|
|
ORDER BY payment_date ASC, id ASC
|
|
|
|
|
|
""",
|
|
|
|
|
|
(invoice_id,),
|
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
|
|
paid_total = sum(Decimal(str(p.get("amount") or 0)) for p in payments)
|
|
|
|
|
|
total_amount = Decimal(str(invoice.get("total_amount") or 0))
|
|
|
|
|
|
remaining = total_amount - paid_total
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"currency": invoice.get("currency") or "DKK",
|
|
|
|
|
|
"total_amount": float(total_amount),
|
|
|
|
|
|
"paid_total": float(paid_total),
|
|
|
|
|
|
"remaining": float(max(remaining, Decimal("0"))),
|
|
|
|
|
|
"payments": payments,
|
|
|
|
|
|
}
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("❌ Failed to fetch supplier payments for invoice %s: %s", invoice_id, e)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/{invoice_id}/events")
|
|
|
|
|
|
async def get_supplier_invoice_events(invoice_id: int, limit: int = 100):
|
|
|
|
|
|
"""Return lifecycle events for supplier invoice (outbox/event-log view)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
exists = execute_query_single("SELECT id FROM supplier_invoices WHERE id = %s", (invoice_id,))
|
|
|
|
|
|
if not exists:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, event_type, from_status, to_status, payload_json, webhook_status, created_at, processed_at
|
|
|
|
|
|
FROM supplier_invoice_events
|
|
|
|
|
|
WHERE supplier_invoice_id = %s
|
|
|
|
|
|
ORDER BY created_at DESC, id DESC
|
|
|
|
|
|
LIMIT %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(invoice_id, limit),
|
|
|
|
|
|
) or []
|
|
|
|
|
|
return rows
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("❌ Failed to fetch supplier events for invoice %s: %s", invoice_id, e)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 07:14:28 +01:00
|
|
|
|
@router.post("/supplier-invoices/{invoice_id}/mark-paid")
|
|
|
|
|
|
async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
|
2026-04-15 09:34:26 +02:00
|
|
|
|
"""Register payment (supports split payments) and mark as paid when fully covered."""
|
2026-03-18 07:14:28 +01:00
|
|
|
|
try:
|
|
|
|
|
|
invoice = execute_query_single(
|
2026-04-15 09:34:26 +02:00
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, invoice_number, status, workflow_status_v2, total_amount, currency
|
|
|
|
|
|
FROM supplier_invoices
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
2026-03-18 07:14:28 +01:00
|
|
|
|
(invoice_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not invoice:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
status_v2 = _get_invoice_status_v2(invoice)
|
|
|
|
|
|
if status_v2 != 'godkendt':
|
2026-03-18 07:14:28 +01:00
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=(
|
2026-04-15 09:34:26 +02:00
|
|
|
|
f"Faktura har status '{status_v2}' - "
|
|
|
|
|
|
"kun godkendte fakturaer kan registrere betaling"
|
2026-03-18 07:14:28 +01:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
total_amount = Decimal(str(invoice.get("total_amount") or 0))
|
|
|
|
|
|
payment_date = request.paid_date or date.today()
|
|
|
|
|
|
|
|
|
|
|
|
paid_sum_row = execute_query_single(
|
|
|
|
|
|
"SELECT COALESCE(SUM(amount), 0) AS paid_sum FROM supplier_invoice_payments WHERE supplier_invoice_id = %s",
|
|
|
|
|
|
(invoice_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
already_paid = Decimal(str((paid_sum_row or {}).get("paid_sum") or 0))
|
|
|
|
|
|
|
|
|
|
|
|
remaining = total_amount - already_paid
|
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
|
transition = _transition_invoice_status_v2(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
new_status_v2="betalt",
|
|
|
|
|
|
actor=request.paid_by,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"status_v2": transition.get("to_status"),
|
|
|
|
|
|
"paid_total": float(already_paid),
|
|
|
|
|
|
"remaining": 0.0,
|
|
|
|
|
|
"message": "Faktura er allerede fuldt betalt",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
amount = request.amount if request.amount is not None else remaining
|
|
|
|
|
|
amount = Decimal(str(amount))
|
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="amount skal være større end 0")
|
|
|
|
|
|
if amount > remaining:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"amount ({amount}) overstiger restbeløb ({remaining})",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-18 07:14:28 +01:00
|
|
|
|
execute_update(
|
2026-04-15 09:34:26 +02:00
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO supplier_invoice_payments (
|
|
|
|
|
|
supplier_invoice_id,
|
|
|
|
|
|
payment_date,
|
|
|
|
|
|
amount,
|
|
|
|
|
|
currency,
|
|
|
|
|
|
payment_method,
|
|
|
|
|
|
payment_reference,
|
|
|
|
|
|
notes,
|
|
|
|
|
|
paid_by
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
invoice_id,
|
|
|
|
|
|
payment_date,
|
|
|
|
|
|
amount,
|
|
|
|
|
|
invoice.get("currency") or "DKK",
|
|
|
|
|
|
request.payment_method,
|
|
|
|
|
|
request.payment_reference,
|
|
|
|
|
|
request.notes,
|
|
|
|
|
|
request.paid_by,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_record_supplier_invoice_event(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
event_type="payment_registered",
|
|
|
|
|
|
from_status=status_v2,
|
|
|
|
|
|
to_status=status_v2,
|
|
|
|
|
|
payload={
|
|
|
|
|
|
"amount": str(amount),
|
|
|
|
|
|
"payment_date": str(payment_date),
|
|
|
|
|
|
"payment_method": request.payment_method,
|
|
|
|
|
|
"payment_reference": request.payment_reference,
|
|
|
|
|
|
"paid_by": request.paid_by,
|
|
|
|
|
|
},
|
2026-03-18 07:14:28 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
new_paid_sum = already_paid + amount
|
|
|
|
|
|
new_remaining = total_amount - new_paid_sum
|
|
|
|
|
|
|
|
|
|
|
|
if new_remaining <= 0:
|
|
|
|
|
|
transition = _transition_invoice_status_v2(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
new_status_v2="betalt",
|
|
|
|
|
|
actor=request.paid_by,
|
|
|
|
|
|
)
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE supplier_invoices
|
|
|
|
|
|
SET paid_date = %s,
|
|
|
|
|
|
payment_reference = COALESCE(%s, payment_reference),
|
|
|
|
|
|
payment_method = COALESCE(%s, payment_method),
|
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(payment_date, request.payment_reference, request.payment_method, invoice_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
status_v2 = transition.get("to_status") or "betalt"
|
|
|
|
|
|
|
2026-03-18 07:14:28 +01:00
|
|
|
|
logger.info(
|
2026-04-15 09:34:26 +02:00
|
|
|
|
"✅ Registered supplier payment for invoice %s (ID: %s): amount=%s, remaining=%s",
|
2026-03-18 07:14:28 +01:00
|
|
|
|
invoice['invoice_number'],
|
|
|
|
|
|
invoice_id,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
amount,
|
|
|
|
|
|
max(new_remaining, Decimal('0')),
|
2026-03-18 07:14:28 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
"status_v2": status_v2,
|
|
|
|
|
|
"payment_date": payment_date,
|
|
|
|
|
|
"payment_amount": float(amount),
|
|
|
|
|
|
"paid_total": float(new_paid_sum),
|
|
|
|
|
|
"remaining": float(max(new_remaining, Decimal('0'))),
|
2026-03-18 07:14:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to mark invoice {invoice_id} as paid: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
|
|
|
|
|
|
async def send_to_economic(invoice_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Send approved supplier invoice to e-conomic kassekladde
|
|
|
|
|
|
Creates voucher entry in e-conomic journals
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get invoice with lines
|
2025-12-16 15:36:11 +01:00
|
|
|
|
invoice = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""SELECT si.*, v.economic_supplier_number as vendor_economic_id, v.name as vendor_full_name
|
|
|
|
|
|
FROM supplier_invoices si
|
|
|
|
|
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
|
|
|
|
|
WHERE si.id = %s""",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(invoice_id,))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if not invoice:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
if invoice['status'] != 'approved':
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invoice must be approved before sending to e-conomic")
|
|
|
|
|
|
|
|
|
|
|
|
if invoice.get('economic_voucher_number'):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invoice already sent to e-conomic")
|
|
|
|
|
|
|
|
|
|
|
|
# Get lines
|
2025-12-16 15:36:11 +01:00
|
|
|
|
lines = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
|
|
|
|
|
(invoice_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not lines:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invoice must have at least one line item")
|
|
|
|
|
|
|
|
|
|
|
|
# Check if vendor exists in e-conomic
|
|
|
|
|
|
economic = get_economic_service()
|
|
|
|
|
|
|
|
|
|
|
|
vendor_economic_id = invoice.get('vendor_economic_id')
|
|
|
|
|
|
|
|
|
|
|
|
# If vendor not in e-conomic, create it
|
|
|
|
|
|
if not vendor_economic_id:
|
|
|
|
|
|
vendor_result = await economic.search_supplier_by_name(invoice.get('vendor_full_name') or invoice.get('vendor_name'))
|
|
|
|
|
|
|
|
|
|
|
|
if vendor_result:
|
|
|
|
|
|
vendor_economic_id = vendor_result['supplierNumber']
|
|
|
|
|
|
# Update local vendor record
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE vendors SET economic_supplier_number = %s WHERE id = %s",
|
|
|
|
|
|
(vendor_economic_id, invoice['vendor_id'])
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Create new supplier in e-conomic
|
|
|
|
|
|
new_supplier = await economic.create_supplier({
|
|
|
|
|
|
'name': invoice.get('vendor_full_name') or invoice.get('vendor_name'),
|
|
|
|
|
|
'currency': invoice.get('currency', 'DKK')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if new_supplier and new_supplier.get('supplierNumber'):
|
|
|
|
|
|
vendor_economic_id = new_supplier['supplierNumber']
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create supplier in e-conomic")
|
|
|
|
|
|
|
|
|
|
|
|
# Get default journal number from settings
|
|
|
|
|
|
journal_setting = execute_query(
|
2025-12-16 15:36:11 +01:00
|
|
|
|
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'")
|
2026-01-25 03:29:28 +01:00
|
|
|
|
journal_number = int(journal_setting[0]['setting_value']) if journal_setting and len(journal_setting) > 0 else 1
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
# Build VAT breakdown from lines
|
|
|
|
|
|
vat_breakdown = {}
|
|
|
|
|
|
line_items = []
|
|
|
|
|
|
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
|
vat_code = line.get('vat_code', 'I25')
|
|
|
|
|
|
|
|
|
|
|
|
if vat_code not in vat_breakdown:
|
|
|
|
|
|
vat_breakdown[vat_code] = {
|
|
|
|
|
|
'net': 0,
|
|
|
|
|
|
'vat': 0,
|
|
|
|
|
|
'gross': 0,
|
|
|
|
|
|
'rate': line.get('vat_rate', 25.00)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
line_total = float(line.get('line_total', 0))
|
|
|
|
|
|
vat_amount = float(line.get('vat_amount', 0))
|
|
|
|
|
|
net_amount = line_total - vat_amount
|
|
|
|
|
|
|
|
|
|
|
|
vat_breakdown[vat_code]['net'] += net_amount
|
|
|
|
|
|
vat_breakdown[vat_code]['vat'] += vat_amount
|
|
|
|
|
|
vat_breakdown[vat_code]['gross'] += line_total
|
|
|
|
|
|
|
|
|
|
|
|
line_items.append({
|
|
|
|
|
|
'description': line.get('description'),
|
|
|
|
|
|
'quantity': float(line.get('quantity', 1)),
|
|
|
|
|
|
'unit_price': float(line.get('unit_price', 0)),
|
|
|
|
|
|
'line_total': line_total,
|
|
|
|
|
|
'vat_code': vat_code,
|
|
|
|
|
|
'vat_amount': vat_amount,
|
|
|
|
|
|
'contra_account': line.get('contra_account', '5810'),
|
|
|
|
|
|
'sku': line.get('sku')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Send to e-conomic
|
|
|
|
|
|
result = await economic.create_journal_supplier_invoice(
|
|
|
|
|
|
journal_number=journal_number,
|
|
|
|
|
|
supplier_number=vendor_economic_id,
|
|
|
|
|
|
invoice_number=invoice['invoice_number'],
|
|
|
|
|
|
invoice_date=invoice['invoice_date'].isoformat() if isinstance(invoice['invoice_date'], date) else invoice['invoice_date'],
|
|
|
|
|
|
total_amount=float(invoice['total_amount']),
|
|
|
|
|
|
vat_breakdown=vat_breakdown,
|
|
|
|
|
|
line_items=line_items,
|
|
|
|
|
|
due_date=invoice['due_date'].isoformat() if invoice.get('due_date') and isinstance(invoice['due_date'], date) else invoice.get('due_date'),
|
|
|
|
|
|
text=invoice.get('description') or f"Supplier invoice {invoice['invoice_number']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if result.get('error'):
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=result.get('message', 'Failed to create voucher in e-conomic'))
|
|
|
|
|
|
|
|
|
|
|
|
# Update invoice with e-conomic details
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""UPDATE supplier_invoices
|
|
|
|
|
|
SET status = 'sent_to_economic',
|
|
|
|
|
|
economic_supplier_number = %s,
|
|
|
|
|
|
economic_journal_number = %s,
|
|
|
|
|
|
economic_voucher_number = %s,
|
|
|
|
|
|
economic_accounting_year = %s,
|
|
|
|
|
|
sent_to_economic_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s""",
|
|
|
|
|
|
(
|
|
|
|
|
|
vendor_economic_id,
|
|
|
|
|
|
result['journal_number'],
|
|
|
|
|
|
result['voucher_number'],
|
|
|
|
|
|
result['accounting_year'],
|
|
|
|
|
|
invoice_id
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Upload attachment if file_path exists
|
|
|
|
|
|
if invoice.get('file_path') and os.path.exists(invoice['file_path']):
|
|
|
|
|
|
attachment_result = await economic.upload_voucher_attachment(
|
|
|
|
|
|
journal_number=result['journal_number'],
|
|
|
|
|
|
accounting_year=result['accounting_year'],
|
|
|
|
|
|
voucher_number=result['voucher_number'],
|
|
|
|
|
|
pdf_path=invoice['file_path'],
|
|
|
|
|
|
filename=f"{invoice['invoice_number']}.pdf"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if attachment_result.get('success'):
|
|
|
|
|
|
logger.info(f"📎 Uploaded attachment for voucher {result['voucher_number']}")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Sent supplier invoice {invoice['invoice_number']} to e-conomic (voucher #{result['voucher_number']})")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"voucher_number": result['voucher_number'],
|
|
|
|
|
|
"journal_number": result['journal_number'],
|
|
|
|
|
|
"accounting_year": result['accounting_year']
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to send invoice {invoice_id} to e-conomic: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/economic/journals")
|
|
|
|
|
|
async def get_economic_journals():
|
|
|
|
|
|
"""Get available e-conomic journals (kassekladder)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
economic = get_economic_service()
|
|
|
|
|
|
journals = await economic.get_supplier_invoice_journals()
|
|
|
|
|
|
return {"journals": journals}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get e-conomic journals: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-07 10:32:41 +01:00
|
|
|
|
@router.get("/supplier-invoices/economic/accounts")
|
|
|
|
|
|
async def get_economic_accounts(refresh: bool = False):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get e-conomic chart of accounts (kontoplan) from cache
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
refresh: If True, fetch fresh data from e-conomic API
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of accounts with accountNumber, name, accountType, vatCode
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# If refresh requested, sync from e-conomic first
|
|
|
|
|
|
if refresh:
|
|
|
|
|
|
economic = get_economic_service()
|
|
|
|
|
|
count = await economic.sync_accounts_to_database()
|
|
|
|
|
|
logger.info(f"✅ Refreshed {count} accounts from e-conomic")
|
|
|
|
|
|
|
|
|
|
|
|
# Fetch from database cache
|
|
|
|
|
|
accounts = execute_query("""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
account_number as "accountNumber",
|
|
|
|
|
|
name,
|
|
|
|
|
|
account_type as "accountType",
|
|
|
|
|
|
vat_code as "vatCode",
|
|
|
|
|
|
balance,
|
|
|
|
|
|
last_synced as "lastSynced"
|
|
|
|
|
|
FROM economic_accounts
|
|
|
|
|
|
WHERE is_active = TRUE
|
|
|
|
|
|
ORDER BY account_number
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
# If no accounts in cache and not already refreshed, try syncing
|
|
|
|
|
|
if not accounts and not refresh:
|
|
|
|
|
|
economic = get_economic_service()
|
|
|
|
|
|
count = await economic.sync_accounts_to_database()
|
|
|
|
|
|
logger.info(f"✅ Initial sync: {count} accounts from e-conomic")
|
|
|
|
|
|
|
|
|
|
|
|
# Retry fetch
|
|
|
|
|
|
accounts = execute_query("""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
account_number as "accountNumber",
|
|
|
|
|
|
name,
|
|
|
|
|
|
account_type as "accountType",
|
|
|
|
|
|
vat_code as "vatCode",
|
|
|
|
|
|
balance,
|
|
|
|
|
|
last_synced as "lastSynced"
|
|
|
|
|
|
FROM economic_accounts
|
|
|
|
|
|
WHERE is_active = TRUE
|
|
|
|
|
|
ORDER BY account_number
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
return {"accounts": accounts}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get e-conomic accounts: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
# ========== STATISTICS & REPORTS ==========
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/stats/overview")
|
|
|
|
|
|
async def get_payment_overview():
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get overview of supplier invoices payment status
|
|
|
|
|
|
|
|
|
|
|
|
Returns stats for total, paid, overdue, due soon, and pending invoices
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
today = date.today().isoformat()
|
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
|
stats = execute_query_single("""
|
2025-12-07 03:29:54 +01:00
|
|
|
|
SELECT
|
|
|
|
|
|
COUNT(*) as total_count,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NOT NULL THEN 1 ELSE 0 END) as paid_count,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NULL AND due_date < %s THEN 1 ELSE 0 END) as overdue_count,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NULL AND due_date >= %s AND due_date <= (%s::date + INTERVAL '7 days') THEN 1 ELSE 0 END) as due_soon_count,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NULL AND (due_date IS NULL OR due_date > (%s::date + INTERVAL '7 days')) THEN 1 ELSE 0 END) as pending_count,
|
|
|
|
|
|
SUM(total_amount) as total_amount,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NOT NULL THEN total_amount ELSE 0 END) as paid_amount,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NULL THEN total_amount ELSE 0 END) as unpaid_amount,
|
|
|
|
|
|
SUM(CASE WHEN paid_date IS NULL AND due_date < %s THEN total_amount ELSE 0 END) as overdue_amount
|
|
|
|
|
|
FROM supplier_invoices
|
|
|
|
|
|
WHERE status != 'cancelled'
|
2025-12-16 15:36:11 +01:00
|
|
|
|
""", (today, today, today, today, today))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"total_invoices": stats.get('total_count', 0) if stats else 0,
|
|
|
|
|
|
"paid_count": stats.get('paid_count', 0) if stats else 0,
|
|
|
|
|
|
"overdue_count": stats.get('overdue_count', 0) if stats else 0,
|
|
|
|
|
|
"due_soon_count": stats.get('due_soon_count', 0) if stats else 0,
|
|
|
|
|
|
"pending_count": stats.get('pending_count', 0) if stats else 0,
|
|
|
|
|
|
"total_amount": float(stats.get('total_amount', 0) or 0) if stats else 0,
|
|
|
|
|
|
"paid_amount": float(stats.get('paid_amount', 0) or 0) if stats else 0,
|
|
|
|
|
|
"unpaid_amount": float(stats.get('unpaid_amount', 0) or 0) if stats else 0,
|
|
|
|
|
|
"overdue_amount": float(stats.get('overdue_amount', 0) or 0) if stats else 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get payment overview: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/stats/by-vendor")
|
|
|
|
|
|
async def get_stats_by_vendor():
|
|
|
|
|
|
"""Get supplier invoice statistics grouped by vendor"""
|
|
|
|
|
|
try:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
stats = execute_query_single("""
|
2025-12-07 03:29:54 +01:00
|
|
|
|
SELECT
|
|
|
|
|
|
v.id as vendor_id,
|
|
|
|
|
|
v.name as vendor_name,
|
|
|
|
|
|
COUNT(si.id) as invoice_count,
|
|
|
|
|
|
SUM(si.total_amount) as total_amount,
|
|
|
|
|
|
SUM(CASE WHEN si.paid_date IS NULL THEN si.total_amount ELSE 0 END) as unpaid_amount,
|
|
|
|
|
|
MAX(si.due_date) as latest_due_date
|
|
|
|
|
|
FROM vendors v
|
|
|
|
|
|
LEFT JOIN supplier_invoices si ON v.id = si.vendor_id
|
|
|
|
|
|
WHERE si.status != 'cancelled' OR si.status IS NULL
|
|
|
|
|
|
GROUP BY v.id, v.name
|
|
|
|
|
|
HAVING COUNT(si.id) > 0
|
|
|
|
|
|
ORDER BY unpaid_amount DESC
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
return {"vendor_stats": stats}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to get vendor stats: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========== UPLOAD & AI EXTRACTION ==========
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/upload")
|
|
|
|
|
|
async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Upload supplier invoice (PDF/image) and extract data using templates
|
|
|
|
|
|
|
|
|
|
|
|
Process:
|
|
|
|
|
|
1. Validate file type and size
|
|
|
|
|
|
2. Calculate SHA256 checksum for duplicate detection
|
|
|
|
|
|
3. Save file to uploads directory
|
|
|
|
|
|
4. Extract text (PDF/OCR)
|
|
|
|
|
|
5. Match template based on PDF content
|
|
|
|
|
|
6. Extract fields using template regex patterns
|
|
|
|
|
|
7. Show form with pre-filled data for user review
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{
|
|
|
|
|
|
"status": "success|duplicate|needs_review",
|
|
|
|
|
|
"file_id": int,
|
|
|
|
|
|
"template_matched": bool,
|
|
|
|
|
|
"template_id": int,
|
|
|
|
|
|
"extracted_fields": dict,
|
|
|
|
|
|
"confidence": float,
|
|
|
|
|
|
"pdf_text": str # For manual review
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
2025-12-08 09:15:52 +01:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
try:
|
|
|
|
|
|
# Validate file extension
|
|
|
|
|
|
suffix = Path(file.filename).suffix.lower()
|
2026-03-01 20:01:11 +01:00
|
|
|
|
suffix_clean = suffix.lstrip('.')
|
|
|
|
|
|
# Build allowed set — guard against pydantic parsing CSV as a single element
|
|
|
|
|
|
raw = settings.ALLOWED_EXTENSIONS
|
|
|
|
|
|
if len(raw) == 1 and ',' in raw[0]:
|
|
|
|
|
|
raw = [e.strip() for e in raw[0].split(',')]
|
|
|
|
|
|
allowed_clean = {ext.lower().lstrip('.') for ext in raw}
|
|
|
|
|
|
if suffix_clean not in allowed_clean:
|
2025-12-07 03:29:54 +01:00
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
2026-03-01 20:01:11 +01:00
|
|
|
|
detail=f"Filtype {suffix} ikke tilladt. Tilladte: {', '.join(sorted(allowed_clean))}"
|
2025-12-07 03:29:54 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Create upload directory
|
|
|
|
|
|
upload_dir = Path(settings.UPLOAD_DIR)
|
|
|
|
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Save file temporarily to calculate checksum
|
|
|
|
|
|
temp_path = upload_dir / f"temp_{datetime.now().timestamp()}_{file.filename}"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Validate file size while saving
|
2026-03-01 20:15:40 +01:00
|
|
|
|
max_size = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
2025-12-07 03:29:54 +01:00
|
|
|
|
total_size = 0
|
|
|
|
|
|
|
|
|
|
|
|
with open(temp_path, "wb") as buffer:
|
|
|
|
|
|
while chunk := await file.read(8192):
|
|
|
|
|
|
total_size += len(chunk)
|
|
|
|
|
|
if total_size > max_size:
|
|
|
|
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=413,
|
2026-03-01 20:15:40 +01:00
|
|
|
|
detail=f"Fil for stor (max {settings.EMAIL_MAX_UPLOAD_SIZE_MB}MB)"
|
2025-12-07 03:29:54 +01:00
|
|
|
|
)
|
|
|
|
|
|
buffer.write(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"📥 Uploaded file: {file.filename} ({total_size} bytes)")
|
|
|
|
|
|
|
|
|
|
|
|
# Calculate SHA256 checksum
|
|
|
|
|
|
checksum = ollama_service.calculate_file_checksum(temp_path)
|
|
|
|
|
|
|
|
|
|
|
|
# Check for duplicate file
|
2026-03-02 00:05:24 +01:00
|
|
|
|
existing_file = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(checksum,))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if existing_file:
|
|
|
|
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
logger.warning(f"⚠️ Duplicate file detected: {checksum[:16]}...")
|
|
|
|
|
|
|
|
|
|
|
|
# Get existing invoice if linked
|
2025-12-16 15:36:11 +01:00
|
|
|
|
existing_invoice = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""SELECT si.* FROM supplier_invoices si
|
|
|
|
|
|
JOIN extractions e ON si.extraction_id = e.extraction_id
|
|
|
|
|
|
WHERE e.file_id = %s""",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(existing_file['file_id'],))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "duplicate",
|
|
|
|
|
|
"message": "Denne fil er allerede uploadet",
|
|
|
|
|
|
"file_id": existing_file['file_id'],
|
|
|
|
|
|
"invoice_id": existing_invoice['id'] if existing_invoice else None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Rename to permanent name
|
|
|
|
|
|
final_path = upload_dir / file.filename
|
|
|
|
|
|
counter = 1
|
|
|
|
|
|
while final_path.exists():
|
|
|
|
|
|
final_path = upload_dir / f"{final_path.stem}_{counter}{final_path.suffix}"
|
|
|
|
|
|
counter += 1
|
|
|
|
|
|
|
|
|
|
|
|
temp_path.rename(final_path)
|
|
|
|
|
|
logger.info(f"💾 Saved file as: {final_path.name}")
|
|
|
|
|
|
|
|
|
|
|
|
# Insert file record
|
2025-12-16 15:36:11 +01:00
|
|
|
|
file_record = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"""INSERT INTO incoming_files
|
|
|
|
|
|
(filename, original_filename, file_path, file_size, mime_type, checksum, status)
|
2026-01-25 03:29:28 +01:00
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'pending') RETURNING file_id""",
|
2025-12-07 03:29:54 +01:00
|
|
|
|
(final_path.name, file.filename, str(final_path), total_size,
|
2025-12-16 15:36:11 +01:00
|
|
|
|
ollama_service._get_mime_type(final_path), checksum))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
file_id = file_record['file_id']
|
|
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
logger.info(f"✅ File uploaded successfully - ready for batch analysis")
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
2026-01-25 03:29:28 +01:00
|
|
|
|
# Return simple response - all extraction happens in batch analyze
|
2025-12-07 03:29:54 +01:00
|
|
|
|
return {
|
2026-01-25 03:29:28 +01:00
|
|
|
|
"status": "uploaded",
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"file_id": file_id,
|
2026-01-25 03:29:28 +01:00
|
|
|
|
"filename": file.filename,
|
|
|
|
|
|
"message": "Fil uploadet - klik 'Analyser alle' for at behandle"
|
2025-12-07 03:29:54 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
except HTTPException as he:
|
|
|
|
|
|
# Mark file as failed if we have file_id
|
|
|
|
|
|
if 'file_id' in locals():
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""UPDATE incoming_files
|
|
|
|
|
|
SET status = 'failed',
|
|
|
|
|
|
error_message = %s,
|
|
|
|
|
|
processed_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE file_id = %s""",
|
|
|
|
|
|
(str(he.detail), file_id)
|
|
|
|
|
|
)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Upload failed (inner): {e}", exc_info=True)
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Mark file as failed if we have file_id
|
|
|
|
|
|
if 'file_id' in locals():
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""UPDATE incoming_files
|
|
|
|
|
|
SET status = 'failed',
|
|
|
|
|
|
error_message = %s,
|
|
|
|
|
|
processed_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE file_id = %s""",
|
|
|
|
|
|
(str(e), file_id)
|
|
|
|
|
|
)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
raise HTTPException(status_code=500, detail=f"Upload fejlede: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Upload failed (outer): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Upload fejlede: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========== ECONOMIC SYNC ==========
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 07:14:28 +01:00
|
|
|
|
@router.post("/supplier-invoices/{invoice_id}/send-to-economic-legacy-unimplemented")
|
2025-12-07 03:29:54 +01:00
|
|
|
|
async def send_invoice_to_economic(invoice_id: int):
|
|
|
|
|
|
"""Send supplier invoice to e-conomic - requires separate implementation"""
|
|
|
|
|
|
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/reprocess/{file_id}")
|
|
|
|
|
|
async def reprocess_uploaded_file(file_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Genbehandl en uploadet fil med template matching
|
|
|
|
|
|
Bruges til at behandle filer der fejlede eller ikke blev færdigbehandlet
|
|
|
|
|
|
"""
|
2025-12-08 09:15:52 +01:00
|
|
|
|
import json
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
try:
|
|
|
|
|
|
# Get file record
|
2025-12-16 15:36:11 +01:00
|
|
|
|
file_record = execute_query_single(
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(file_id,))
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
|
|
|
|
|
if not file_record:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Fil {file_id} ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
file_path = Path(file_record['file_path'])
|
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Fil ikke fundet på disk: {file_path}")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"<EFBFBD><EFBFBD> Genbehandler fil {file_id}: {file_record['filename']}")
|
|
|
|
|
|
|
|
|
|
|
|
# Extract text from file
|
|
|
|
|
|
text = await ollama_service._extract_text_from_file(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
# Try template matching
|
|
|
|
|
|
template_id, confidence = template_service.match_template(text)
|
|
|
|
|
|
|
|
|
|
|
|
extracted_fields = {}
|
|
|
|
|
|
vendor_id = None
|
|
|
|
|
|
|
|
|
|
|
|
if template_id and confidence >= 0.5:
|
|
|
|
|
|
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
|
|
|
|
|
|
extracted_fields = template_service.extract_fields(text, template_id)
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Check if this is an invoice2data template (ID -1)
|
|
|
|
|
|
is_invoice2data = (template_id == -1)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
if is_invoice2data:
|
2025-12-15 12:28:12 +01:00
|
|
|
|
def _to_numeric(value):
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, (int, float, Decimal)):
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
cleaned = value.strip().replace(' ', '')
|
|
|
|
|
|
if not cleaned:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Common Danish formatting: 25.000,00 or 1.530,00
|
|
|
|
|
|
if ',' in cleaned:
|
|
|
|
|
|
cleaned = cleaned.replace('.', '').replace(',', '.')
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(cleaned)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _clean_document_id(value):
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
cleaned = value.strip()
|
|
|
|
|
|
return cleaned if cleaned and cleaned.lower() != 'none' else None
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Invoice2data doesn't have vendor in cache
|
|
|
|
|
|
logger.info(f"📋 Using invoice2data template")
|
|
|
|
|
|
# Try to find vendor from extracted CVR
|
|
|
|
|
|
if extracted_fields.get('vendor_vat'):
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendor = execute_query_single(
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
"SELECT id FROM vendors WHERE cvr_number = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(extracted_fields['vendor_vat'],))
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
if vendor:
|
|
|
|
|
|
vendor_id = vendor['id']
|
2025-12-15 12:28:12 +01:00
|
|
|
|
|
|
|
|
|
|
# Fallback: use vendor detected during quick analysis (incoming_files.detected_vendor_id)
|
|
|
|
|
|
if vendor_id is None:
|
|
|
|
|
|
vendor_id = file_record.get('detected_vendor_id')
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: match by issuer name
|
|
|
|
|
|
if vendor_id is None and extracted_fields.get('issuer'):
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendor = execute_query_single(
|
2025-12-15 12:28:12 +01:00
|
|
|
|
"SELECT id FROM vendors WHERE name ILIKE %s ORDER BY id LIMIT 1",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(extracted_fields['issuer'],))
|
2025-12-15 12:28:12 +01:00
|
|
|
|
if vendor:
|
|
|
|
|
|
vendor_id = vendor['id']
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
|
|
|
|
|
|
# Store invoice2data extraction in database
|
|
|
|
|
|
extraction_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO extractions
|
|
|
|
|
|
(file_id, vendor_matched_id, vendor_name, vendor_cvr,
|
|
|
|
|
|
document_id, document_date, due_date, document_type, document_type_detected,
|
|
|
|
|
|
total_amount, currency, confidence, llm_response_json, status)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING extraction_id""",
|
|
|
|
|
|
(file_id, vendor_id,
|
|
|
|
|
|
extracted_fields.get('issuer'), # vendor_name
|
|
|
|
|
|
extracted_fields.get('vendor_vat'), # vendor_cvr
|
2025-12-15 12:28:12 +01:00
|
|
|
|
_clean_document_id(extracted_fields.get('invoice_number')), # document_id
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
extracted_fields.get('invoice_date'), # document_date
|
|
|
|
|
|
extracted_fields.get('due_date'),
|
|
|
|
|
|
'invoice', # document_type
|
|
|
|
|
|
'invoice', # document_type_detected
|
2025-12-15 12:28:12 +01:00
|
|
|
|
_to_numeric(extracted_fields.get('amount_total') if extracted_fields.get('amount_total') is not None else extracted_fields.get('total_amount')),
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
extracted_fields.get('currency', 'DKK'),
|
|
|
|
|
|
1.0, # invoice2data always 100% confidence
|
|
|
|
|
|
json.dumps(extracted_fields), # llm_response_json
|
|
|
|
|
|
'extracted') # status
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Insert line items if extracted
|
|
|
|
|
|
if extracted_fields.get('lines'):
|
|
|
|
|
|
for idx, line in enumerate(extracted_fields['lines'], start=1):
|
2025-12-15 12:28:12 +01:00
|
|
|
|
line_total = _to_numeric(line.get('line_total'))
|
|
|
|
|
|
unit_price = _to_numeric(line.get('unit_price'))
|
|
|
|
|
|
quantity = _to_numeric(line.get('quantity'))
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
execute_insert(
|
|
|
|
|
|
"""INSERT INTO extraction_lines
|
|
|
|
|
|
(extraction_id, line_number, description, quantity, unit_price,
|
|
|
|
|
|
line_total, vat_rate, vat_note, confidence,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
ip_address, contract_number, provider_reference, customer_reference, circuit_id,
|
|
|
|
|
|
end_customer_name, period_start, period_end, service_address,
|
|
|
|
|
|
location_street, location_zip, location_city)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
RETURNING line_id""",
|
|
|
|
|
|
(extraction_id, idx, line.get('description'),
|
2025-12-15 12:28:12 +01:00
|
|
|
|
quantity, unit_price,
|
|
|
|
|
|
line_total, None, None, 1.0,
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
line.get('ip_address'), line.get('contract_number'),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
line.get('provider_reference'), line.get('customer_reference'), line.get('circuit_id'),
|
|
|
|
|
|
line.get('end_customer_name'), line.get('period_start'), line.get('period_end'), line.get('service_address'),
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
line.get('location_street'), line.get('location_zip'), line.get('location_city'))
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"✅ Saved {len(extracted_fields['lines'])} line items")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Custom template from database
|
|
|
|
|
|
template = template_service.templates_cache.get(template_id)
|
|
|
|
|
|
if template:
|
|
|
|
|
|
vendor_id = template.get('vendor_id')
|
|
|
|
|
|
|
|
|
|
|
|
template_service.log_usage(template_id, file_id, True, confidence, extracted_fields)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Update file - use NULL for invoice2data templates to avoid FK constraint
|
|
|
|
|
|
db_template_id = None if is_invoice2data else template_id
|
2025-12-07 03:29:54 +01:00
|
|
|
|
execute_update(
|
|
|
|
|
|
"""UPDATE incoming_files
|
|
|
|
|
|
SET status = 'processed', template_id = %s, processed_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE file_id = %s""",
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
(db_template_id, file_id)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# FALLBACK TO AI EXTRACTION
|
|
|
|
|
|
logger.info(f"⚠️ Ingen template match (confidence: {confidence:.0%}) - bruger AI extraction")
|
|
|
|
|
|
|
|
|
|
|
|
# Use detected vendor from quick analysis if available
|
|
|
|
|
|
vendor_id = file_record.get('detected_vendor_id')
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Call Ollama for full extraction
|
|
|
|
|
|
logger.info(f"🤖 Calling Ollama for AI extraction...")
|
|
|
|
|
|
llm_result = await ollama_service.extract_from_text(text)
|
|
|
|
|
|
|
2025-12-15 12:28:12 +01:00
|
|
|
|
# Handle both dict and string error responses
|
|
|
|
|
|
if not llm_result or isinstance(llm_result, str) or (isinstance(llm_result, dict) and 'error' in llm_result):
|
|
|
|
|
|
if isinstance(llm_result, dict):
|
|
|
|
|
|
error_msg = llm_result.get('error', 'AI extraction fejlede')
|
|
|
|
|
|
elif isinstance(llm_result, str):
|
|
|
|
|
|
error_msg = llm_result # Error message returned as string
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_msg = 'AI extraction fejlede'
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
logger.error(f"❌ AI extraction failed: {error_msg}")
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""UPDATE incoming_files
|
|
|
|
|
|
SET status = 'failed',
|
|
|
|
|
|
error_message = %s,
|
|
|
|
|
|
processed_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE file_id = %s""",
|
|
|
|
|
|
(f"AI extraction fejlede: {error_msg}", file_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}")
|
|
|
|
|
|
|
|
|
|
|
|
extracted_fields = llm_result
|
|
|
|
|
|
confidence = llm_result.get('confidence', 0.75)
|
2026-03-02 09:01:43 +01:00
|
|
|
|
|
2026-03-02 12:59:17 +01:00
|
|
|
|
# Post-process: clear own CVR(s) if AI mistakenly returned them
|
2026-03-02 09:01:43 +01:00
|
|
|
|
extracted_cvr = llm_result.get('vendor_cvr')
|
2026-03-02 12:59:17 +01:00
|
|
|
|
own_cvr = getattr(settings, 'OWN_CVR', '29522790')
|
|
|
|
|
|
OWN_CVRS = {str(own_cvr).strip(), '29522790', '14416285'} # alle BMC CVR numre
|
|
|
|
|
|
extracted_cvr_clean = str(extracted_cvr).replace('DK', '').strip() if extracted_cvr else ''
|
|
|
|
|
|
if extracted_cvr_clean and extracted_cvr_clean in OWN_CVRS:
|
|
|
|
|
|
logger.warning(f"⚠️ AI returned own CVR ({extracted_cvr_clean}) as vendor_cvr - clearing it")
|
2026-03-02 09:01:43 +01:00
|
|
|
|
llm_result['vendor_cvr'] = None
|
|
|
|
|
|
extracted_cvr = None
|
2026-03-02 12:59:17 +01:00
|
|
|
|
# Also clear vendor_name if it looks like BMC
|
|
|
|
|
|
vendor_name = llm_result.get('vendor_name', '') or ''
|
|
|
|
|
|
if 'BMC' in vendor_name.upper() and 'DENMARK' in vendor_name.upper():
|
|
|
|
|
|
logger.warning(f"⚠️ AI returned own company name '{vendor_name}' as vendor_name - clearing it")
|
|
|
|
|
|
llm_result['vendor_name'] = None
|
2026-03-02 09:01:43 +01:00
|
|
|
|
|
|
|
|
|
|
# Try to find vendor in DB by extracted CVR or name (overrides detected_vendor_id)
|
|
|
|
|
|
if extracted_cvr:
|
|
|
|
|
|
cvr_clean = str(extracted_cvr).replace('DK', '').strip()
|
|
|
|
|
|
vendor_row = execute_query_single(
|
|
|
|
|
|
"SELECT id FROM vendors WHERE cvr_number = %s AND is_active = true",
|
|
|
|
|
|
(cvr_clean,))
|
|
|
|
|
|
if vendor_row:
|
|
|
|
|
|
vendor_id = vendor_row['id']
|
|
|
|
|
|
logger.info(f"✅ Matched vendor by CVR {cvr_clean}: vendor_id={vendor_id}")
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s",
|
|
|
|
|
|
(vendor_id, file_id))
|
|
|
|
|
|
if not vendor_id and llm_result.get('vendor_name'):
|
|
|
|
|
|
vendor_row = execute_query_single(
|
|
|
|
|
|
"SELECT id FROM vendors WHERE name ILIKE %s AND is_active = true ORDER BY id LIMIT 1",
|
|
|
|
|
|
(f"%{llm_result['vendor_name']}%",))
|
|
|
|
|
|
if vendor_row:
|
|
|
|
|
|
vendor_id = vendor_row['id']
|
|
|
|
|
|
logger.info(f"✅ Matched vendor by name '{llm_result['vendor_name']}': vendor_id={vendor_id}")
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s",
|
|
|
|
|
|
(vendor_id, file_id))
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Store AI extracted data in extractions table
|
|
|
|
|
|
extraction_id = execute_insert(
|
2026-03-02 08:48:03 +01:00
|
|
|
|
"""INSERT INTO extractions
|
|
|
|
|
|
(file_id, vendor_matched_id, vendor_name, vendor_cvr,
|
|
|
|
|
|
document_id, document_date, due_date,
|
|
|
|
|
|
total_amount, currency, document_type, document_type_detected,
|
|
|
|
|
|
confidence, llm_response_json, status)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING extraction_id""",
|
|
|
|
|
|
(file_id, vendor_id,
|
|
|
|
|
|
llm_result.get('vendor_name'),
|
|
|
|
|
|
llm_result.get('vendor_cvr'),
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
llm_result.get('invoice_number'),
|
|
|
|
|
|
llm_result.get('invoice_date'),
|
|
|
|
|
|
llm_result.get('due_date'),
|
|
|
|
|
|
llm_result.get('total_amount'),
|
|
|
|
|
|
llm_result.get('currency', 'DKK'),
|
2026-03-02 08:48:03 +01:00
|
|
|
|
llm_result.get('document_type', 'invoice'),
|
|
|
|
|
|
llm_result.get('document_type', 'invoice'),
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
confidence,
|
2026-03-02 08:48:03 +01:00
|
|
|
|
json.dumps(llm_result),
|
|
|
|
|
|
'extracted')
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Insert line items if extracted
|
|
|
|
|
|
if llm_result.get('lines'):
|
|
|
|
|
|
for idx, line in enumerate(llm_result['lines'], start=1):
|
|
|
|
|
|
execute_insert(
|
|
|
|
|
|
"""INSERT INTO extraction_lines
|
|
|
|
|
|
(extraction_id, line_number, description, quantity, unit_price,
|
2026-07-09 23:44:30 +02:00
|
|
|
|
line_total, vat_rate, confidence,
|
|
|
|
|
|
ip_address, contract_number, provider_reference, customer_reference, circuit_id,
|
|
|
|
|
|
end_customer_name, period_start, period_end, service_address,
|
|
|
|
|
|
location_street, location_zip, location_city)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
RETURNING line_id""",
|
|
|
|
|
|
(extraction_id, idx, line.get('description'),
|
|
|
|
|
|
line.get('quantity'), line.get('unit_price'),
|
2026-03-02 08:54:14 +01:00
|
|
|
|
line.get('line_total'), line.get('vat_rate'),
|
2026-07-09 23:44:30 +02:00
|
|
|
|
confidence,
|
|
|
|
|
|
line.get('ip_address'), line.get('contract_number'),
|
|
|
|
|
|
line.get('provider_reference'), line.get('customer_reference'), line.get('circuit_id'),
|
|
|
|
|
|
line.get('end_customer_name'), line.get('period_start'), line.get('period_end'), line.get('service_address'),
|
|
|
|
|
|
line.get('location_street'), line.get('location_zip'), line.get('location_city'))
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Update file status to ai_extracted
|
2025-12-07 03:29:54 +01:00
|
|
|
|
execute_update(
|
|
|
|
|
|
"""UPDATE incoming_files
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
SET status = 'ai_extracted', processed_at = CURRENT_TIMESTAMP
|
2025-12-07 03:29:54 +01:00
|
|
|
|
WHERE file_id = %s""",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
2025-12-08 09:15:52 +01:00
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
logger.info(f"✅ AI extraction completed for file {file_id}")
|
2025-12-07 03:29:54 +01:00
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Return success with template data or AI extraction result
|
2025-12-15 12:28:12 +01:00
|
|
|
|
# Determine confidence value safely
|
|
|
|
|
|
if template_id:
|
|
|
|
|
|
final_confidence = confidence
|
|
|
|
|
|
elif 'llm_result' in locals() and isinstance(llm_result, dict):
|
|
|
|
|
|
final_confidence = llm_result.get('confidence', 0.75)
|
|
|
|
|
|
else:
|
|
|
|
|
|
final_confidence = 0.0
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
result = {
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"status": "success",
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": file_record['filename'],
|
|
|
|
|
|
"template_matched": template_id is not None,
|
|
|
|
|
|
"template_id": template_id,
|
|
|
|
|
|
"vendor_id": vendor_id,
|
2025-12-15 12:28:12 +01:00
|
|
|
|
"confidence": final_confidence,
|
2025-12-07 03:29:54 +01:00
|
|
|
|
"extracted_fields": extracted_fields,
|
2025-12-08 09:15:52 +01:00
|
|
|
|
"pdf_text": text[:1000] if not template_id else text
|
2025-12-07 03:29:54 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
# Add warning if no template exists
|
|
|
|
|
|
if not template_id and vendor_id:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
vendor = execute_query_single(
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
"SELECT name FROM vendors WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(vendor_id,))
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
if vendor:
|
|
|
|
|
|
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"
|
2026-07-28 14:18:24 +02:00
|
|
|
|
|
|
|
|
|
|
# GlobalConnect invoices must update the internet module immediately after
|
|
|
|
|
|
# extraction. Previously this only happened after a separate manual
|
|
|
|
|
|
# conversion to supplier_invoice, leaving valid extracted invoices queued.
|
|
|
|
|
|
if "extraction_id" in locals() and extraction_id:
|
|
|
|
|
|
latest_extraction = execute_query_single(
|
|
|
|
|
|
"SELECT * FROM extractions WHERE extraction_id = %s",
|
|
|
|
|
|
(extraction_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
if latest_extraction and _is_globalconnect_extraction(latest_extraction):
|
|
|
|
|
|
result["internet_sync"] = _sync_globalconnect_extraction_to_internet(latest_extraction)
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Genbehandling fejlede: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 13:48:14 +01:00
|
|
|
|
@router.post("/supplier-invoices/files/batch-analyze")
|
|
|
|
|
|
async def batch_analyze_files(background_tasks: BackgroundTasks):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Kør AI-analyse på alle ubehandlede filer i baggrunden.
|
|
|
|
|
|
Returnerer øjeblikkeligt – filer behandles async.
|
|
|
|
|
|
"""
|
|
|
|
|
|
pending = execute_query(
|
|
|
|
|
|
"""SELECT file_id, filename FROM incoming_files
|
|
|
|
|
|
WHERE status IN ('pending', 'requires_vendor_selection', 'uploaded', 'failed')
|
|
|
|
|
|
ORDER BY uploaded_at DESC
|
|
|
|
|
|
LIMIT 100""",
|
|
|
|
|
|
()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not pending:
|
|
|
|
|
|
return {"started": 0, "message": "Ingen filer at behandle"}
|
|
|
|
|
|
|
|
|
|
|
|
file_ids = [r['file_id'] for r in pending]
|
|
|
|
|
|
logger.info(f"🚀 Batch-analyse startet for {len(file_ids)} filer")
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_batch(ids):
|
|
|
|
|
|
ok = err = 0
|
|
|
|
|
|
for fid in ids:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await reprocess_uploaded_file(fid)
|
|
|
|
|
|
ok += 1
|
|
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
logger.error(f"❌ Batch fejl file {fid}: {ex}")
|
|
|
|
|
|
err += 1
|
|
|
|
|
|
logger.info(f"✅ Batch færdig: {ok} ok, {err} fejlet")
|
|
|
|
|
|
|
|
|
|
|
|
background_tasks.add_task(_run_batch, file_ids)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"started": len(file_ids),
|
|
|
|
|
|
"message": f"{len(file_ids)} filer sendt til analyse i baggrunden. Opdater siden om lidt.",
|
|
|
|
|
|
"analyzed": 0,
|
|
|
|
|
|
"requires_vendor_selection": 0,
|
|
|
|
|
|
"failed": 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
@router.put("/supplier-invoices/templates/{template_id}")
|
|
|
|
|
|
async def update_template(
|
|
|
|
|
|
template_id: int,
|
|
|
|
|
|
template_name: Optional[str] = None,
|
|
|
|
|
|
detection_patterns: Optional[List[Dict]] = None,
|
|
|
|
|
|
field_mappings: Optional[Dict] = None,
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
default_product_category: Optional[str] = None,
|
2025-12-07 03:29:54 +01:00
|
|
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Opdater eksisterende template"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
updates = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if template_name:
|
|
|
|
|
|
updates.append("template_name = %s")
|
|
|
|
|
|
params.append(template_name)
|
|
|
|
|
|
if detection_patterns is not None:
|
|
|
|
|
|
updates.append("detection_patterns = %s")
|
|
|
|
|
|
params.append(json.dumps(detection_patterns))
|
|
|
|
|
|
if field_mappings is not None:
|
|
|
|
|
|
updates.append("field_mappings = %s")
|
|
|
|
|
|
params.append(json.dumps(field_mappings))
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
if default_product_category is not None:
|
|
|
|
|
|
updates.append("default_product_category = %s")
|
|
|
|
|
|
params.append(default_product_category)
|
2025-12-07 03:29:54 +01:00
|
|
|
|
if is_active is not None:
|
|
|
|
|
|
updates.append("is_active = %s")
|
|
|
|
|
|
params.append(is_active)
|
|
|
|
|
|
|
|
|
|
|
|
if not updates:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Ingen opdateringer angivet")
|
|
|
|
|
|
|
|
|
|
|
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
|
|
|
|
params.append(template_id)
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
f"UPDATE supplier_invoice_templates SET {', '.join(updates)} WHERE template_id = %s",
|
|
|
|
|
|
tuple(params)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Reload templates
|
|
|
|
|
|
template_service.reload_templates()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Template {template_id} opdateret")
|
|
|
|
|
|
return {"message": "Template opdateret"}
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to update template: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
@router.post("/supplier-invoices/templates/invoice2data/{template_name}/test")
|
|
|
|
|
|
async def test_invoice2data_template(template_name: str, request: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Test invoice2data YAML template mod PDF tekst
|
|
|
|
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
{
|
|
|
|
|
|
"pdf_text": "Full PDF text content..."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Returns samme format som test_template endpoint
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
pdf_text = request.get('pdf_text', '')
|
|
|
|
|
|
if not pdf_text:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="pdf_text er påkrævet")
|
|
|
|
|
|
|
|
|
|
|
|
# Get invoice2data service
|
|
|
|
|
|
invoice2data_service = get_invoice2data_service()
|
|
|
|
|
|
|
|
|
|
|
|
# Check if template exists
|
|
|
|
|
|
if template_name not in invoice2data_service.templates:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Template '{template_name}' ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
template_data = invoice2data_service.templates[template_name]
|
|
|
|
|
|
|
|
|
|
|
|
# Test extraction
|
|
|
|
|
|
result = invoice2data_service.extract_with_template(pdf_text, template_name)
|
|
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
# Template didn't match
|
|
|
|
|
|
keywords = template_data.get('keywords', [])
|
|
|
|
|
|
detection_results = []
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
|
found = str(keyword).lower() in pdf_text.lower()
|
|
|
|
|
|
detection_results.append({
|
|
|
|
|
|
"pattern": str(keyword),
|
|
|
|
|
|
"type": "keyword",
|
|
|
|
|
|
"found": found,
|
|
|
|
|
|
"weight": 0.5
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"matched": False,
|
|
|
|
|
|
"confidence": 0.0,
|
|
|
|
|
|
"extracted_fields": {},
|
|
|
|
|
|
"line_items": [],
|
|
|
|
|
|
"detection_results": detection_results,
|
|
|
|
|
|
"template_name": template_name,
|
|
|
|
|
|
"error": "Template matchede ikke PDF'en"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Extract line items
|
|
|
|
|
|
line_items = []
|
|
|
|
|
|
if 'lines' in result:
|
|
|
|
|
|
for line in result['lines']:
|
|
|
|
|
|
line_items.append({
|
|
|
|
|
|
"line_number": line.get('line_number', ''),
|
|
|
|
|
|
"item_number": line.get('item_number', ''),
|
|
|
|
|
|
"description": line.get('description_raw', '') or line.get('description', ''),
|
|
|
|
|
|
"quantity": line.get('quantity', ''),
|
|
|
|
|
|
"unit_price": line.get('unit_price', ''),
|
|
|
|
|
|
"line_total": line.get('line_total', ''),
|
|
|
|
|
|
# Context fields (circuit/location info)
|
|
|
|
|
|
"circuit_id": line.get('circuit_id', ''),
|
|
|
|
|
|
"ip_address": line.get('ip_address', ''),
|
|
|
|
|
|
"contract_number": line.get('contract_number', ''),
|
|
|
|
|
|
"location_street": line.get('location_street', ''),
|
|
|
|
|
|
"location_zip": line.get('location_zip', ''),
|
|
|
|
|
|
"location_city": line.get('location_city', ''),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Build detection results
|
|
|
|
|
|
keywords = template_data.get('keywords', [])
|
|
|
|
|
|
detection_results = []
|
|
|
|
|
|
matched_count = 0
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
|
found = str(keyword).lower() in pdf_text.lower()
|
|
|
|
|
|
if found:
|
|
|
|
|
|
matched_count += 1
|
|
|
|
|
|
detection_results.append({
|
|
|
|
|
|
"pattern": str(keyword),
|
|
|
|
|
|
"type": "keyword",
|
|
|
|
|
|
"found": found,
|
|
|
|
|
|
"weight": 0.5
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
confidence = matched_count / len(keywords) if keywords else 1.0
|
|
|
|
|
|
|
|
|
|
|
|
# Remove 'lines' from extracted_fields to avoid duplication
|
|
|
|
|
|
extracted_fields = {k: v for k, v in result.items() if k != 'lines'}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"matched": True,
|
|
|
|
|
|
"confidence": confidence,
|
|
|
|
|
|
"extracted_fields": extracted_fields,
|
|
|
|
|
|
"line_items": line_items,
|
|
|
|
|
|
"detection_results": detection_results,
|
|
|
|
|
|
"template_name": template_name
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Invoice2data template test failed: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
@router.post("/supplier-invoices/templates/{template_id}/test")
|
|
|
|
|
|
async def test_template(template_id: int, request: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Test template mod PDF tekst
|
|
|
|
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
{
|
|
|
|
|
|
"pdf_text": "Full PDF text content..."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{
|
|
|
|
|
|
"matched": true/false,
|
|
|
|
|
|
"confidence": 0.85,
|
|
|
|
|
|
"extracted_fields": {
|
|
|
|
|
|
"invoice_number": "12345",
|
|
|
|
|
|
"invoice_date": "01/12-25",
|
|
|
|
|
|
"total_amount": "1234.56",
|
|
|
|
|
|
"vendor_cvr": "12345678"
|
|
|
|
|
|
},
|
|
|
|
|
|
"detection_results": [
|
|
|
|
|
|
{"pattern": "BMC Denmark ApS", "found": true, "weight": 0.5}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import re
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
pdf_text = request.get('pdf_text', '')
|
|
|
|
|
|
if not pdf_text:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="pdf_text er påkrævet")
|
|
|
|
|
|
|
|
|
|
|
|
# Fetch template
|
|
|
|
|
|
query = "SELECT * FROM supplier_invoice_templates WHERE template_id = %s"
|
|
|
|
|
|
template = execute_query(query, (template_id,))
|
|
|
|
|
|
if not template:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Template ikke fundet")
|
|
|
|
|
|
|
|
|
|
|
|
template = template[0]
|
|
|
|
|
|
detection_patterns = template.get('detection_patterns', [])
|
|
|
|
|
|
field_mappings = template.get('field_mappings', {})
|
|
|
|
|
|
|
|
|
|
|
|
# Test detection patterns
|
2025-12-08 09:15:52 +01:00
|
|
|
|
total_score = 0.0
|
2025-12-07 03:29:54 +01:00
|
|
|
|
max_score = 0.0
|
|
|
|
|
|
detection_results = []
|
|
|
|
|
|
|
|
|
|
|
|
for pattern in detection_patterns:
|
|
|
|
|
|
pattern_type = pattern.get('type', 'text')
|
|
|
|
|
|
pattern_value = pattern.get('pattern', '')
|
|
|
|
|
|
weight = float(pattern.get('weight', 0.5))
|
|
|
|
|
|
max_score += weight
|
|
|
|
|
|
|
|
|
|
|
|
found = False
|
|
|
|
|
|
if pattern_type == 'text' and pattern_value in pdf_text:
|
|
|
|
|
|
found = True
|
|
|
|
|
|
total_score += weight
|
|
|
|
|
|
|
|
|
|
|
|
detection_results.append({
|
|
|
|
|
|
"pattern": pattern_value,
|
|
|
|
|
|
"type": pattern_type,
|
|
|
|
|
|
"found": found,
|
|
|
|
|
|
"weight": weight
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
confidence = (total_score / max_score) if max_score > 0 else 0.0
|
|
|
|
|
|
matched = confidence >= 0.7 # Match threshold
|
|
|
|
|
|
|
|
|
|
|
|
# Extract fields if matched
|
|
|
|
|
|
extracted_fields = {}
|
|
|
|
|
|
if matched:
|
|
|
|
|
|
for field_name, field_config in field_mappings.items():
|
|
|
|
|
|
pattern = field_config.get('pattern', '')
|
|
|
|
|
|
group = field_config.get('group', 1)
|
|
|
|
|
|
|
|
|
|
|
|
# Skip non-field patterns (lines_start, lines_end, line_item)
|
|
|
|
|
|
if field_name in ['lines_start', 'lines_end', 'line_item']:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
match = re.search(pattern, pdf_text, re.IGNORECASE | re.MULTILINE)
|
|
|
|
|
|
if match and len(match.groups()) >= group:
|
|
|
|
|
|
extracted_fields[field_name] = match.group(group).strip()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Pattern match failed for {field_name}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Extract line items if matched
|
|
|
|
|
|
line_items = []
|
|
|
|
|
|
if matched:
|
|
|
|
|
|
# Extract line items using smart extraction
|
|
|
|
|
|
lines_start = field_mappings.get('lines_start', {}).get('pattern')
|
|
|
|
|
|
lines_end = field_mappings.get('lines_end', {}).get('pattern')
|
|
|
|
|
|
line_pattern = field_mappings.get('line_item', {}).get('pattern')
|
|
|
|
|
|
line_fields = field_mappings.get('line_item', {}).get('fields', [])
|
|
|
|
|
|
|
|
|
|
|
|
if line_pattern or lines_start:
|
|
|
|
|
|
# Extract section between start and end markers
|
|
|
|
|
|
text_section = pdf_text
|
|
|
|
|
|
if lines_start:
|
|
|
|
|
|
try:
|
|
|
|
|
|
start_match = re.search(lines_start, pdf_text, re.IGNORECASE)
|
|
|
|
|
|
if start_match:
|
|
|
|
|
|
text_section = pdf_text[start_match.end():]
|
|
|
|
|
|
logger.debug(f"Found lines_start at position {start_match.end()}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Failed to find lines_start: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
if lines_end:
|
|
|
|
|
|
try:
|
|
|
|
|
|
end_match = re.search(lines_end, text_section, re.IGNORECASE)
|
|
|
|
|
|
if end_match:
|
|
|
|
|
|
text_section = text_section[:end_match.start()]
|
|
|
|
|
|
logger.debug(f"Found lines_end at position {end_match.start()}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Failed to find lines_end: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Try pattern first, then smart extraction
|
|
|
|
|
|
if line_pattern:
|
|
|
|
|
|
try:
|
|
|
|
|
|
for match in re.finditer(line_pattern, text_section, re.MULTILINE):
|
|
|
|
|
|
line_data = {
|
|
|
|
|
|
'line_number': len(line_items) + 1,
|
|
|
|
|
|
'raw_text': match.group(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for idx, field_name in enumerate(line_fields, start=1):
|
|
|
|
|
|
if idx <= len(match.groups()):
|
|
|
|
|
|
line_data[field_name] = match.group(idx).strip()
|
|
|
|
|
|
|
|
|
|
|
|
line_items.append(line_data)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Pattern extraction failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback to smart extraction if no lines found
|
|
|
|
|
|
if not line_items:
|
|
|
|
|
|
logger.info("🧠 Trying smart extraction...")
|
|
|
|
|
|
logger.debug(f"Text section length: {len(text_section)}, first 500 chars: {text_section[:500]}")
|
|
|
|
|
|
line_items = _smart_extract_lines(text_section)
|
|
|
|
|
|
logger.info(f"🧠 Smart extraction returned {len(line_items)} items")
|
|
|
|
|
|
|
|
|
|
|
|
if line_items:
|
|
|
|
|
|
logger.info(f"📦 Extracted {len(line_items)} line items from test")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"⚠️ No line items matched. Section length: {len(text_section)} chars")
|
|
|
|
|
|
logger.debug(f"Section preview: {text_section[:300]}")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🧪 Template {template_id} test: matched={matched}, confidence={confidence:.2f}, lines={len(line_items)}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"matched": matched,
|
|
|
|
|
|
"confidence": round(confidence, 2),
|
|
|
|
|
|
"extracted_fields": extracted_fields,
|
|
|
|
|
|
"line_items": line_items,
|
|
|
|
|
|
"detection_results": detection_results,
|
|
|
|
|
|
"template_name": template.get('template_name', '')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Template test failed: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
|
|
|
|
@router.put("/supplier-invoices/templates/invoice2data/{template_name}/category")
|
|
|
|
|
|
async def update_yaml_category(template_name: str, request: Dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Opdater default_product_category i YAML template fil
|
|
|
|
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
{
|
|
|
|
|
|
"category": "drift" // varesalg, drift, anlæg, abonnement, lager, udlejning
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
new_category = request.get('category')
|
|
|
|
|
|
if not new_category:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="category er påkrævet")
|
|
|
|
|
|
|
|
|
|
|
|
# Validate category
|
|
|
|
|
|
valid_categories = ['varesalg', 'drift', 'anlæg', 'abonnement', 'lager', 'udlejning']
|
|
|
|
|
|
if new_category not in valid_categories:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Ugyldig kategori. Skal være en af: {', '.join(valid_categories)}")
|
|
|
|
|
|
|
|
|
|
|
|
# Find YAML file
|
|
|
|
|
|
templates_dir = Path(__file__).parent.parent.parent.parent / 'data' / 'invoice_templates'
|
|
|
|
|
|
yaml_file = templates_dir / f"{template_name}.yml"
|
|
|
|
|
|
|
|
|
|
|
|
if not yaml_file.exists():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"YAML fil ikke fundet: {template_name}.yml")
|
|
|
|
|
|
|
|
|
|
|
|
# Load YAML
|
|
|
|
|
|
with open(yaml_file, 'r', encoding='utf-8') as f:
|
|
|
|
|
|
template_data = yaml.safe_load(f)
|
|
|
|
|
|
|
|
|
|
|
|
# Update category
|
|
|
|
|
|
template_data['default_product_category'] = new_category
|
|
|
|
|
|
|
|
|
|
|
|
# Save YAML with preserved formatting
|
|
|
|
|
|
with open(yaml_file, 'w', encoding='utf-8') as f:
|
|
|
|
|
|
yaml.dump(template_data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
|
|
|
|
|
|
|
|
|
|
# Reload invoice2data service to pick up changes
|
|
|
|
|
|
invoice2data_service = get_invoice2data_service()
|
|
|
|
|
|
invoice2data_service.__init__() # Reinitialize to reload templates
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated category for {template_name}.yml to {new_category}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "Kategori opdateret",
|
|
|
|
|
|
"template_name": template_name,
|
|
|
|
|
|
"new_category": new_category
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to update YAML category: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/templates/invoice2data/{template_name}/content")
|
|
|
|
|
|
async def get_yaml_content(template_name: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Hent råt YAML indhold fra template fil
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{
|
|
|
|
|
|
"content": "issuer: DCS ApS\nkeywords: ..."
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
# Find template file
|
|
|
|
|
|
template_dir = Path("data/invoice_templates")
|
|
|
|
|
|
template_file = template_dir / f"{template_name}.yml"
|
|
|
|
|
|
|
|
|
|
|
|
if not template_file.exists():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Template fil ikke fundet: {template_name}.yml")
|
|
|
|
|
|
|
|
|
|
|
|
# Read file content
|
|
|
|
|
|
content = template_file.read_text(encoding='utf-8')
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"template_name": template_name,
|
|
|
|
|
|
"filename": f"{template_name}.yml",
|
|
|
|
|
|
"content": content
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to read YAML content: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 03:29:54 +01:00
|
|
|
|
@router.delete("/supplier-invoices/templates/{template_id}")
|
|
|
|
|
|
async def delete_template(template_id: int):
|
|
|
|
|
|
"""Slet template (soft delete - sæt is_active=false)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE supplier_invoice_templates SET is_active = false WHERE template_id = %s",
|
|
|
|
|
|
(template_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
template_service.reload_templates()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Template {template_id} deaktiveret")
|
|
|
|
|
|
return {"message": "Template slettet"}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to delete template: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2026-01-25 03:29:28 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Helper function for creating invoice from file
|
|
|
|
|
|
async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
|
|
|
|
|
|
"""Create a minimal supplier invoice from file without full extraction"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
file_info = execute_query_single(
|
|
|
|
|
|
"SELECT filename, file_path FROM incoming_files WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise ValueError(f"File {file_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Create minimal invoice record
|
|
|
|
|
|
invoice_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO supplier_invoices (
|
|
|
|
|
|
vendor_id, invoice_number, invoice_date, due_date,
|
2026-04-15 09:34:26 +02:00
|
|
|
|
total_amount, currency, status, workflow_status_v2, notes
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2026-01-25 03:29:28 +01:00
|
|
|
|
RETURNING id""",
|
|
|
|
|
|
(
|
|
|
|
|
|
vendor_id,
|
|
|
|
|
|
f"PENDING-{file_id}", # Temporary invoice number
|
|
|
|
|
|
datetime.now().date(), # Use today as placeholder
|
|
|
|
|
|
(datetime.now() + timedelta(days=30)).date(), # Due in 30 days
|
|
|
|
|
|
0.00, # Amount to be filled manually
|
|
|
|
|
|
'DKK',
|
2026-04-15 09:34:26 +02:00
|
|
|
|
'pending',
|
|
|
|
|
|
'modtaget',
|
2026-01-25 03:29:28 +01:00
|
|
|
|
f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})"
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-04-12 09:26:35 +02:00
|
|
|
|
|
2026-04-15 09:34:26 +02:00
|
|
|
|
_record_supplier_invoice_event(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
event_type="invoice_created",
|
|
|
|
|
|
from_status=None,
|
|
|
|
|
|
to_status="modtaget",
|
|
|
|
|
|
payload={"source": "from_file", "file_id": file_id},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-12 09:26:35 +02:00
|
|
|
|
_ensure_case_for_supplier_invoice(
|
|
|
|
|
|
invoice_id=invoice_id,
|
|
|
|
|
|
invoice_number=f"PENDING-{file_id}",
|
|
|
|
|
|
vendor_name=None,
|
|
|
|
|
|
total_amount=0,
|
|
|
|
|
|
currency="DKK",
|
|
|
|
|
|
file_id=file_id,
|
|
|
|
|
|
)
|
2026-01-25 03:29:28 +01:00
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created minimal invoice {invoice_id} for file {file_id}, vendor {vendor_id}")
|
|
|
|
|
|
return invoice_id
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to create invoice from file: {e}")
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/files/{file_id}/match-vendor")
|
|
|
|
|
|
async def match_vendor_for_file(file_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Match vendor for uploaded file with confidence scoring
|
|
|
|
|
|
|
|
|
|
|
|
Returns list of vendors with confidence scores:
|
|
|
|
|
|
- 100% = Exact CVR match
|
|
|
|
|
|
- 90% = Email domain match
|
|
|
|
|
|
- 70% = Fuzzy name match
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get file info with detected CVR and vendor
|
|
|
|
|
|
file_info = execute_query(
|
|
|
|
|
|
"""SELECT file_id, detected_cvr, detected_vendor_id, filename
|
|
|
|
|
|
FROM incoming_files WHERE file_id = %s""",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
file_data = file_info[0]
|
|
|
|
|
|
detected_cvr = file_data.get('detected_cvr')
|
|
|
|
|
|
detected_vendor_id = file_data.get('detected_vendor_id')
|
|
|
|
|
|
|
|
|
|
|
|
vendor_matches = []
|
|
|
|
|
|
|
|
|
|
|
|
# Get all active vendors
|
|
|
|
|
|
vendors = execute_query("SELECT id, name, cvr_number, email, domain FROM vendors WHERE is_active = true ORDER BY name")
|
|
|
|
|
|
|
|
|
|
|
|
if not vendors:
|
|
|
|
|
|
vendors = []
|
|
|
|
|
|
|
|
|
|
|
|
# If file already has detected_vendor_id, use it as 100% match
|
|
|
|
|
|
if detected_vendor_id:
|
|
|
|
|
|
matched_vendor = next((v for v in vendors if v['id'] == detected_vendor_id), None)
|
|
|
|
|
|
if matched_vendor:
|
|
|
|
|
|
vendor_matches.append({
|
|
|
|
|
|
"vendor_id": matched_vendor['id'],
|
|
|
|
|
|
"vendor_name": matched_vendor['name'],
|
|
|
|
|
|
"cvr_number": matched_vendor.get('cvr_number'),
|
|
|
|
|
|
"confidence": 100,
|
|
|
|
|
|
"match_reason": "Automatically detected from email",
|
|
|
|
|
|
"is_exact_match": True
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Auto-select this vendor and create invoice
|
|
|
|
|
|
logger.info(f"✅ Auto-selected vendor {matched_vendor['name']} (ID: {detected_vendor_id}) from detected_vendor_id")
|
|
|
|
|
|
|
|
|
|
|
|
# Create supplier invoice directly
|
|
|
|
|
|
invoice_id = await create_invoice_from_file(file_id, detected_vendor_id)
|
|
|
|
|
|
|
|
|
|
|
|
# Update file status
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = 'analyzed' WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": file_data['filename'],
|
|
|
|
|
|
"detected_cvr": detected_cvr,
|
|
|
|
|
|
"matches": vendor_matches,
|
|
|
|
|
|
"auto_selected": vendor_matches[0],
|
|
|
|
|
|
"requires_manual_selection": False,
|
|
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
|
|
"message": f"Leverandør auto-valgt: {matched_vendor['name']}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for vendor in vendors:
|
|
|
|
|
|
# Skip if already matched by detected_vendor_id
|
|
|
|
|
|
if detected_vendor_id and vendor['id'] == detected_vendor_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
confidence = 0
|
|
|
|
|
|
match_reason = []
|
|
|
|
|
|
|
|
|
|
|
|
# 100% = Exact CVR match
|
|
|
|
|
|
if detected_cvr and vendor.get('cvr_number'):
|
|
|
|
|
|
if detected_cvr.strip() == str(vendor['cvr_number']).strip():
|
|
|
|
|
|
confidence = 100
|
|
|
|
|
|
match_reason.append("Exact CVR match")
|
|
|
|
|
|
|
|
|
|
|
|
# 90% = Email domain match (if we have extracted sender email from file metadata)
|
|
|
|
|
|
# Note: This requires additional extraction logic - placeholder for now
|
|
|
|
|
|
|
|
|
|
|
|
# 70% = Fuzzy name match (simple contains check for now)
|
|
|
|
|
|
if confidence == 0:
|
|
|
|
|
|
filename_lower = file_data['filename'].lower()
|
|
|
|
|
|
vendor_name_lower = vendor['name'].lower()
|
|
|
|
|
|
|
|
|
|
|
|
# Check if vendor name appears in filename
|
|
|
|
|
|
if vendor_name_lower in filename_lower or filename_lower in vendor_name_lower:
|
|
|
|
|
|
confidence = 70
|
|
|
|
|
|
match_reason.append("Name appears in filename")
|
|
|
|
|
|
|
|
|
|
|
|
if confidence > 0:
|
|
|
|
|
|
vendor_matches.append({
|
|
|
|
|
|
"vendor_id": vendor['id'],
|
|
|
|
|
|
"vendor_name": vendor['name'],
|
|
|
|
|
|
"cvr_number": vendor.get('cvr_number'),
|
|
|
|
|
|
"confidence": confidence,
|
|
|
|
|
|
"match_reason": ", ".join(match_reason),
|
|
|
|
|
|
"is_exact_match": confidence == 100
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Sort by confidence descending
|
|
|
|
|
|
vendor_matches.sort(key=lambda x: x['confidence'], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
# If we have a 100% match, auto-select it
|
|
|
|
|
|
auto_selected = None
|
|
|
|
|
|
if vendor_matches and vendor_matches[0]['confidence'] == 100:
|
|
|
|
|
|
auto_selected = vendor_matches[0]
|
|
|
|
|
|
|
|
|
|
|
|
# Update file with detected vendor
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s",
|
|
|
|
|
|
(auto_selected['vendor_id'], file_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Auto-matched vendor {auto_selected['vendor_name']} (100% CVR match) for file {file_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": file_data['filename'],
|
|
|
|
|
|
"detected_cvr": detected_cvr,
|
|
|
|
|
|
"matches": vendor_matches,
|
|
|
|
|
|
"auto_selected": auto_selected,
|
|
|
|
|
|
"requires_manual_selection": auto_selected is None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Vendor matching failed for file {file_id}: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/supplier-invoices/suggest-line-codes")
|
|
|
|
|
|
async def suggest_line_codes(vendor_id: int, description: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Suggest VAT code, contra account, and line purpose based on historical data
|
|
|
|
|
|
|
|
|
|
|
|
Uses weighted scoring: score = match_count × (1.0 + 1.0/(days_old + 1))
|
|
|
|
|
|
Newer matches are weighted higher. Requires minimum 3 matches to return suggestion.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from difflib import SequenceMatcher
|
|
|
|
|
|
|
|
|
|
|
|
# Get all lines from this vendor with vat_code, contra_account, line_purpose set
|
|
|
|
|
|
history_lines = execute_query(
|
|
|
|
|
|
"""SELECT sil.description, sil.vat_code, sil.contra_account, sil.line_purpose,
|
|
|
|
|
|
si.invoice_date, si.created_at
|
|
|
|
|
|
FROM supplier_invoice_lines sil
|
|
|
|
|
|
JOIN supplier_invoices si ON sil.supplier_invoice_id = si.id
|
|
|
|
|
|
WHERE si.vendor_id = %s
|
|
|
|
|
|
AND sil.vat_code IS NOT NULL
|
|
|
|
|
|
ORDER BY si.created_at DESC
|
|
|
|
|
|
LIMIT 500""",
|
|
|
|
|
|
(vendor_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not history_lines:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"vendor_id": vendor_id,
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
"suggestions": [],
|
|
|
|
|
|
"has_suggestions": False,
|
|
|
|
|
|
"note": "No historical data found for this vendor"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Score each unique combination
|
|
|
|
|
|
combination_scores = {}
|
|
|
|
|
|
|
|
|
|
|
|
description_lower = description.lower().strip()
|
|
|
|
|
|
|
|
|
|
|
|
for line in history_lines:
|
|
|
|
|
|
hist_desc = (line.get('description') or '').lower().strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Fuzzy match descriptions
|
|
|
|
|
|
similarity = SequenceMatcher(None, description_lower, hist_desc).ratio()
|
|
|
|
|
|
|
|
|
|
|
|
# Only consider matches with >60% similarity
|
|
|
|
|
|
if similarity < 0.6:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Calculate recency weight
|
|
|
|
|
|
created_at = line.get('created_at') or line.get('invoice_date')
|
|
|
|
|
|
if isinstance(created_at, str):
|
|
|
|
|
|
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
|
|
|
|
|
|
|
|
|
|
|
days_old = (datetime.now() - created_at).days if created_at else 365
|
|
|
|
|
|
recency_weight = 1.0 + (1.0 / (days_old + 1))
|
|
|
|
|
|
|
|
|
|
|
|
# Create combination key
|
|
|
|
|
|
combo_key = (
|
|
|
|
|
|
line.get('vat_code'),
|
|
|
|
|
|
line.get('contra_account'),
|
|
|
|
|
|
line.get('line_purpose')
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if combo_key not in combination_scores:
|
|
|
|
|
|
combination_scores[combo_key] = {
|
|
|
|
|
|
'vat_code': line.get('vat_code'),
|
|
|
|
|
|
'contra_account': line.get('contra_account'),
|
|
|
|
|
|
'line_purpose': line.get('line_purpose'),
|
|
|
|
|
|
'match_count': 0,
|
|
|
|
|
|
'total_similarity': 0,
|
|
|
|
|
|
'weighted_score': 0,
|
|
|
|
|
|
'last_used': created_at,
|
|
|
|
|
|
'example_descriptions': []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
combination_scores[combo_key]['match_count'] += 1
|
|
|
|
|
|
combination_scores[combo_key]['total_similarity'] += similarity
|
|
|
|
|
|
combination_scores[combo_key]['weighted_score'] += similarity * recency_weight
|
|
|
|
|
|
|
|
|
|
|
|
if len(combination_scores[combo_key]['example_descriptions']) < 3:
|
|
|
|
|
|
combination_scores[combo_key]['example_descriptions'].append(line.get('description'))
|
|
|
|
|
|
|
|
|
|
|
|
# Update last_used if this is newer
|
|
|
|
|
|
if created_at and (not combination_scores[combo_key]['last_used'] or created_at > combination_scores[combo_key]['last_used']):
|
|
|
|
|
|
combination_scores[combo_key]['last_used'] = created_at
|
|
|
|
|
|
|
|
|
|
|
|
# Filter to combinations with ≥3 matches
|
|
|
|
|
|
valid_suggestions = [
|
|
|
|
|
|
combo for combo in combination_scores.values()
|
|
|
|
|
|
if combo['match_count'] >= 3
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# Sort by weighted score
|
|
|
|
|
|
valid_suggestions.sort(key=lambda x: x['weighted_score'], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Format suggestions
|
|
|
|
|
|
formatted_suggestions = []
|
|
|
|
|
|
for suggestion in valid_suggestions[:5]: # Top 5 suggestions
|
|
|
|
|
|
formatted_suggestions.append({
|
|
|
|
|
|
'vat_code': suggestion['vat_code'],
|
|
|
|
|
|
'contra_account': suggestion['contra_account'],
|
|
|
|
|
|
'line_purpose': suggestion['line_purpose'],
|
|
|
|
|
|
'match_count': suggestion['match_count'],
|
|
|
|
|
|
'confidence_score': round(suggestion['weighted_score'], 2),
|
|
|
|
|
|
'last_used': suggestion['last_used'].strftime('%Y-%m-%d') if suggestion['last_used'] else None,
|
|
|
|
|
|
'example_descriptions': suggestion['example_descriptions']
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"vendor_id": vendor_id,
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
"suggestions": formatted_suggestions,
|
|
|
|
|
|
"has_suggestions": len(formatted_suggestions) > 0,
|
|
|
|
|
|
"top_suggestion": formatted_suggestions[0] if formatted_suggestions else None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Line code suggestion failed: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/files/batch-analyze")
|
|
|
|
|
|
async def batch_analyze_files():
|
|
|
|
|
|
"""
|
|
|
|
|
|
Batch analyze all pending files using cascade extraction:
|
|
|
|
|
|
1. invoice2data (YAML templates) - fastest
|
|
|
|
|
|
2. template_service (regex patterns) - if invoice2data fails
|
|
|
|
|
|
3. ollama AI - as last backup
|
|
|
|
|
|
|
|
|
|
|
|
Auto-creates invoices for files with 100% vendor match.
|
|
|
|
|
|
Files with <100% match remain pending for manual vendor selection.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get all pending files
|
|
|
|
|
|
pending_files = execute_query(
|
|
|
|
|
|
"""SELECT file_id, filename, file_path, detected_vendor_id, detected_cvr
|
|
|
|
|
|
FROM incoming_files
|
|
|
|
|
|
WHERE status IN ('pending', 'extraction_failed')
|
|
|
|
|
|
ORDER BY uploaded_at DESC"""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not pending_files:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "No pending files to analyze",
|
|
|
|
|
|
"analyzed": 0,
|
|
|
|
|
|
"invoices_created": 0,
|
|
|
|
|
|
"failed": 0,
|
|
|
|
|
|
"requires_vendor_selection": 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
results = {
|
|
|
|
|
|
"analyzed": 0,
|
|
|
|
|
|
"invoices_created": 0,
|
|
|
|
|
|
"failed": 0,
|
|
|
|
|
|
"requires_vendor_selection": 0,
|
|
|
|
|
|
"details": []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for file_data in pending_files:
|
|
|
|
|
|
file_id = file_data['file_id']
|
|
|
|
|
|
filename = file_data['filename']
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Run vendor matching first
|
|
|
|
|
|
vendor_match_result = await match_vendor_for_file(file_id)
|
|
|
|
|
|
|
|
|
|
|
|
# If no 100% match, skip extraction and mark for manual selection
|
|
|
|
|
|
if vendor_match_result.get('requires_manual_selection'):
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = 'requires_vendor_selection' WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
results['requires_vendor_selection'] += 1
|
|
|
|
|
|
results['details'].append({
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": filename,
|
|
|
|
|
|
"status": "requires_vendor_selection",
|
|
|
|
|
|
"vendor_matches": len(vendor_match_result.get('matches', []))
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# We have 100% vendor match - proceed with extraction cascade
|
|
|
|
|
|
vendor_id = vendor_match_result['auto_selected']['vendor_id']
|
|
|
|
|
|
|
|
|
|
|
|
# Try extraction cascade (this logic should be moved to a helper function)
|
|
|
|
|
|
# For now, mark as analyzed
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = 'analyzed', detected_vendor_id = %s WHERE file_id = %s",
|
|
|
|
|
|
(vendor_id, file_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
results['analyzed'] += 1
|
|
|
|
|
|
results['details'].append({
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": filename,
|
|
|
|
|
|
"status": "analyzed",
|
|
|
|
|
|
"vendor_id": vendor_id,
|
|
|
|
|
|
"vendor_name": vendor_match_result['auto_selected']['vendor_name']
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Analyzed file {file_id}: {filename}")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Batch analysis failed for file {file_id}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = 'extraction_failed' WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
results['failed'] += 1
|
|
|
|
|
|
results['details'].append({
|
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
|
"filename": filename,
|
|
|
|
|
|
"status": "extraction_failed",
|
|
|
|
|
|
"error": str(e)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Batch analysis complete: {results['analyzed']} analyzed, {results['invoices_created']} invoices created, {results['failed']} failed")
|
|
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Batch analysis failed: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/supplier-invoices/files/{file_id}/retry")
|
|
|
|
|
|
async def retry_extraction(file_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Retry extraction for a failed file
|
|
|
|
|
|
Re-runs the cascade: invoice2data → template_service → ollama AI
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Check if file exists
|
|
|
|
|
|
file_info = execute_query(
|
|
|
|
|
|
"SELECT file_id, filename, file_path, status FROM incoming_files WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not file_info:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
file_data = file_info[0]
|
|
|
|
|
|
|
|
|
|
|
|
# Reset status to pending
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE incoming_files SET status = 'pending' WHERE file_id = %s",
|
|
|
|
|
|
(file_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🔄 Retrying extraction for file {file_id}: {file_data['filename']}")
|
2026-03-02 06:22:33 +01:00
|
|
|
|
|
|
|
|
|
|
# Run full extraction cascade immediately
|
|
|
|
|
|
result = await reprocess_uploaded_file(file_id)
|
|
|
|
|
|
return result
|
2026-01-25 03:29:28 +01:00
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Retry extraction failed for file {file_id}: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|