diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py
index 4f19e91..aa76d79 100644
--- a/app/billing/backend/supplier_invoices.py
+++ b/app/billing/backend/supplier_invoices.py
@@ -18,12 +18,180 @@ from app.services.invoice2data_service import get_invoice2data_service
import logging
import os
import re
+import json
logger = logging.getLogger(__name__)
router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb"
+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,
+ }
+
def _resolve_procurement_customer_id() -> int:
"""Find customer used for internally-owned procurement cases."""
@@ -94,14 +262,27 @@ def _ensure_case_for_supplier_invoice(
f"Fil ID: {file_id or '-'}"
)
- case_row = execute_query_single(
- """
- INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id)
- VALUES (%s, %s, %s, %s, %s)
- RETURNING id
- """,
- (case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id)
- )
+ 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)
+ )
if not case_row:
return None
@@ -138,6 +319,196 @@ def _ensure_case_for_supplier_invoice(
return sag_id
+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
+
+
+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,
+ }
+ )
+ 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(
+ """
+ SELECT line_number, sku, description, quantity, unit_price, line_total, vat_rate, vat_amount
+ 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)
+
+
+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)
+
+
def _smart_extract_lines(text: str) -> List[Dict]:
"""
Universal line extraction using pdfplumber layout mode.
@@ -278,6 +649,7 @@ def _smart_extract_lines(text: str) -> List[Dict]:
async def list_supplier_invoices(
status: Optional[str] = None,
vendor_id: Optional[int] = None,
+ sag_id: Optional[int] = None,
overdue_only: bool = False
):
"""
@@ -298,7 +670,16 @@ async def list_supplier_invoices(
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
- END as computed_status
+ 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
FROM supplier_invoices si
LEFT JOIN vendors v ON si.vendor_id = v.id
WHERE 1=1
@@ -306,12 +687,31 @@ async def list_supplier_invoices(
params = []
if status:
- query += " AND si.status = %s"
- params.append(status)
+ 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)
if vendor_id:
query += " AND si.vendor_id = %s"
params.append(vendor_id)
+
+ if sag_id:
+ query += " AND si.sag_id = %s"
+ params.append(sag_id)
if overdue_only:
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
@@ -1089,13 +1489,8 @@ async def create_invoice_from_extraction(file_id: int):
if existing:
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
- # Get extraction lines
- lines = execute_query(
- """SELECT * FROM extraction_lines
- WHERE extraction_id = %s
- ORDER BY line_number""",
- (extraction_data['extraction_id'],)
- )
+ # Get extracted lines from DB, fallback to LLM payload lines.
+ lines = _load_extraction_lines(extraction_data)
# Parse LLM response JSON if it's a string
import json
@@ -1135,8 +1530,8 @@ async def create_invoice_from_extraction(file_id: int):
invoice_id = execute_insert(
"""INSERT INTO supplier_invoices (
vendor_id, invoice_number, invoice_date, due_date,
- total_amount, currency, status, extraction_id, notes, invoice_type
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ total_amount, currency, status, workflow_status_v2, extraction_id, notes, invoice_type
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
extraction_data['vendor_matched_id'],
@@ -1145,13 +1540,22 @@ async def create_invoice_from_extraction(file_id: int):
due_date,
extraction_data['total_amount'],
extraction_data['currency'],
- 'credited' if invoice_type == 'credit_note' else 'unpaid',
+ 'cancelled' if invoice_type == 'credit_note' else 'pending',
+ 'afvist' if invoice_type == 'credit_note' else 'modtaget',
extraction_data['extraction_id'],
f"Oprettet fra AI extraction (file_id: {file_id})",
invoice_type
)
)
+ _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},
+ )
+
sag_id = _ensure_case_for_supplier_invoice(
invoice_id=invoice_id,
invoice_number=invoice_number,
@@ -1164,19 +1568,40 @@ async def create_invoice_from_extraction(file_id: int):
# Create invoice lines
if lines:
for line in lines:
+ 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'),
+ description=line.get('description'),
+ sku=sku,
+ unit_price=unit_price,
+ currency=extraction_data.get('currency'),
+ vat_rate=vat_rate,
+ )
+
execute_update(
"""INSERT INTO supplier_invoice_lines (
- supplier_invoice_id, description, quantity, unit_price,
- line_total, vat_rate, vat_amount
- ) VALUES (%s, %s, %s, %s, %s, %s, %s)""",
+ 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)""",
(
invoice_id,
+ line.get('line_number'),
line['description'],
- line.get('quantity') or 1,
- line.get('unit_price') or 0,
- line.get('line_total') or 0,
- line.get('vat_rate') or 25.00, # Default 25% Danish VAT if NULL
- line.get('vat_amount')
+ quantity,
+ unit_price,
+ line_total,
+ vat_rate,
+ vat_amount,
+ product_id,
+ sku,
)
)
@@ -1598,8 +2023,9 @@ async def create_supplier_invoice(data: Dict):
"""INSERT INTO supplier_invoices
(invoice_number, vendor_id, vendor_name, invoice_date, due_date,
total_amount, vat_amount, net_amount, currency, description, notes,
- status, created_by, invoice_type)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
+ 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""",
(
data['invoice_number'],
data['vendor_id'],
@@ -1612,35 +2038,64 @@ async def create_supplier_invoice(data: Dict):
data.get('currency', 'DKK'),
data.get('description'),
data.get('notes'),
- 'credited' if invoice_type == 'credit_note' else 'pending',
+ 'cancelled' if invoice_type == 'credit_note' else 'pending',
+ 'afvist' if invoice_type == 'credit_note' else 'modtaget',
data.get('created_by'),
invoice_type
)
)
+
+ _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},
+ )
# Insert lines if provided
if data.get('lines'):
for idx, line in enumerate(data['lines'], start=1):
# Map vat_code: I52 for reverse charge, I25 for standard
vat_code = line.get('vat_code', 'I25')
+ 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,
+ )
- execute_insert(
+ execute_update(
"""INSERT INTO supplier_invoice_lines
(supplier_invoice_id, line_number, description, quantity, unit_price,
- line_total, vat_code, vat_rate, vat_amount, contra_account, sku)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
+ 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)""",
(
invoice_id,
line.get('line_number', idx),
line.get('description'),
- line.get('quantity', 1),
- line.get('unit_price', 0),
- line.get('line_total', 0),
+ quantity,
+ unit_price,
+ line_total,
vat_code,
- line.get('vat_rate', 25.00),
- line.get('vat_amount', 0),
+ vat_rate,
+ vat_amount,
line.get('contra_account', '5810'),
- line.get('sku')
+ product_id,
+ sku,
)
)
@@ -1829,33 +2284,44 @@ class ApproveRequest(BaseModel):
approved_by: str
+class RejectRequest(BaseModel):
+ rejected_by: str
+ reason: Optional[str] = None
+
+
class MarkPaidRequest(BaseModel):
paid_date: Optional[date] = None
+ amount: Optional[Decimal] = None
+ payment_method: Optional[str] = None
+ payment_reference: Optional[str] = None
+ notes: Optional[str] = None
+ paid_by: Optional[str] = None
@router.post("/supplier-invoices/{invoice_id}/approve")
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
- """Approve supplier invoice for payment"""
+ """Approve supplier invoice for payment (v2 status flow)."""
try:
- invoice = execute_query_single(
- "SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
- (invoice_id,))
-
- if not invoice:
- raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
-
- if invoice['status'] != 'pending':
- raise HTTPException(status_code=400, detail=f"Faktura har allerede status '{invoice['status']}' - kan kun godkende fakturaer med status 'pending'")
-
- execute_update(
- """UPDATE supplier_invoices
- SET status = 'approved', approved_by = %s, approved_at = CURRENT_TIMESTAMP
- WHERE id = %s""",
- (request.approved_by, invoice_id)
+ transition = _transition_invoice_status_v2(
+ invoice_id=invoice_id,
+ new_status_v2="godkendt",
+ actor=request.approved_by,
)
-
- logger.info(f"✅ Approved supplier invoice {invoice['invoice_number']} by {request.approved_by}")
-
- return {"success": True, "invoice_id": invoice_id, "approved_by": request.approved_by}
+
+ 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),
+ }
except HTTPException:
raise
@@ -1864,49 +2330,243 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
raise HTTPException(status_code=500, detail=str(e))
-@router.post("/supplier-invoices/{invoice_id}/mark-paid")
-async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
- """Mark supplier invoice as paid."""
+@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, invoice_number, status FROM supplier_invoices WHERE id = %s",
+ "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))
+
+
+@router.post("/supplier-invoices/{invoice_id}/mark-paid")
+async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
+ """Register payment (supports split payments) and mark as paid when fully covered."""
+ try:
+ invoice = execute_query_single(
+ """
+ SELECT id, invoice_number, status, workflow_status_v2, 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")
- if invoice['status'] == 'paid':
- return {"success": True, "invoice_id": invoice_id, "status": "paid"}
-
- if invoice['status'] not in ('approved', 'sent_to_economic'):
+ status_v2 = _get_invoice_status_v2(invoice)
+ if status_v2 != 'godkendt':
raise HTTPException(
status_code=400,
detail=(
- f"Faktura har status '{invoice['status']}' - "
- "kun 'approved' eller 'sent_to_economic' kan markeres som betalt"
+ f"Faktura har status '{status_v2}' - "
+ "kun godkendte fakturaer kan registrere betaling"
)
)
+ 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})",
+ )
+
execute_update(
- """UPDATE supplier_invoices
- SET status = 'paid', updated_at = CURRENT_TIMESTAMP
- WHERE id = %s""",
- (invoice_id,)
+ """
+ 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,
+ },
+ )
+
+ 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"
+
logger.info(
- "✅ Marked supplier invoice %s (ID: %s) as paid (date: %s)",
+ "✅ Registered supplier payment for invoice %s (ID: %s): amount=%s, remaining=%s",
invoice['invoice_number'],
invoice_id,
- request.paid_date,
+ amount,
+ max(new_remaining, Decimal('0')),
)
return {
"success": True,
"invoice_id": invoice_id,
- "status": "paid",
- "paid_date": request.paid_date,
+ "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'))),
}
except HTTPException:
@@ -3199,8 +3859,8 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
invoice_id = execute_insert(
"""INSERT INTO supplier_invoices (
vendor_id, invoice_number, invoice_date, due_date,
- total_amount, currency, status, notes
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ total_amount, currency, status, workflow_status_v2, notes
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
vendor_id,
@@ -3209,11 +3869,20 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
(datetime.now() + timedelta(days=30)).date(), # Due in 30 days
0.00, # Amount to be filled manually
'DKK',
- 'unpaid',
+ 'pending',
+ 'modtaget',
f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})"
)
)
+ _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},
+ )
+
_ensure_case_for_supplier_invoice(
invoice_id=invoice_id,
invoice_number=f"PENDING-{file_id}",
diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py
index 58be2c4..6365428 100644
--- a/app/customers/backend/router.py
+++ b/app/customers/backend/router.py
@@ -23,6 +23,42 @@ logger = logging.getLogger(__name__)
router = APIRouter()
+def _ensure_customer_supplier_tag(customer_id: int) -> None:
+ """Ensure linked customers are tagged as suppliers."""
+ try:
+ tag = execute_query_single(
+ "SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1"
+ )
+ if tag and tag.get("id") is not None:
+ tag_id = int(tag["id"])
+ else:
+ created = execute_query_single(
+ """
+ INSERT INTO tags (name, type, description, color, is_active)
+ VALUES (%s, %s, %s, %s, %s)
+ ON CONFLICT (name, type)
+ DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
+ RETURNING id
+ """,
+ ("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True),
+ )
+ tag_id = int(created["id"]) if created and created.get("id") is not None else None
+
+ if not tag_id:
+ return
+
+ execute_query(
+ """
+ INSERT INTO entity_tags (entity_type, entity_id, tag_id)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
+ """,
+ ("customer", customer_id, tag_id),
+ )
+ except Exception as tag_error:
+ logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
+
+
# Pydantic Models
class CustomerBase(BaseModel):
name: str
@@ -517,6 +553,78 @@ async def get_customer_utility_company(customer_id: int):
"supplier": supplier
}
+
+@router.get("/customers/{customer_id}/vendors")
+async def list_customer_vendors(customer_id: int):
+ """List vendors linked to a customer."""
+ customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ rows = execute_query(
+ """
+ SELECT
+ l.id,
+ l.customer_id,
+ l.vendor_id,
+ l.relationship_type,
+ l.created_at,
+ l.updated_at,
+ v.name AS vendor_name,
+ v.email AS vendor_email,
+ v.cvr_number AS vendor_cvr
+ FROM customer_vendor_links l
+ JOIN vendors v ON v.id = l.vendor_id
+ WHERE l.customer_id = %s
+ ORDER BY v.name ASC, l.id ASC
+ """,
+ (customer_id,),
+ ) or []
+ return rows
+
+
+@router.post("/customers/{customer_id}/vendors/{vendor_id}")
+async def link_customer_to_vendor(customer_id: int, vendor_id: int, relationship_type: str = Query("supplier")):
+ """Create or update a customer-vendor link."""
+ customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
+ if not vendor:
+ raise HTTPException(status_code=404, detail="Vendor not found")
+
+ rel = str(relationship_type or "supplier").strip().lower()
+ if rel not in {"supplier", "reseller", "partner"}:
+ raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
+
+ row = execute_query_single(
+ """
+ INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (customer_id, vendor_id)
+ DO UPDATE SET
+ relationship_type = EXCLUDED.relationship_type,
+ updated_at = CURRENT_TIMESTAMP
+ RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
+ """,
+ (customer_id, vendor_id, rel),
+ )
+ _ensure_customer_supplier_tag(int(customer_id))
+ return row
+
+
+@router.delete("/customers/{customer_id}/vendors/{vendor_id}")
+async def unlink_customer_from_vendor(customer_id: int, vendor_id: int):
+ """Remove customer-vendor link."""
+ deleted = execute_update(
+ "DELETE FROM customer_vendor_links WHERE customer_id = %s AND vendor_id = %s",
+ (customer_id, vendor_id),
+ )
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Link not found")
+ return {"success": True, "customer_id": customer_id, "vendor_id": vendor_id}
+
@router.post("/customers")
async def create_customer(customer: CustomerCreate):
"""Create a new customer"""
@@ -1096,7 +1204,69 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
raise HTTPException(status_code=404, detail="Customer not found")
try:
- # Create contact
+ normalized_email = (contact.email or "").strip().lower() or None
+ existing_contact = None
+
+ # Prefer exact email match scoped to this customer, then global email match.
+ if normalized_email:
+ existing_contact = execute_query_single(
+ """
+ SELECT c.*
+ FROM contacts c
+ JOIN contact_companies cc ON cc.contact_id = c.id
+ WHERE cc.customer_id = %s
+ AND LOWER(COALESCE(c.email, '')) = %s
+ ORDER BY c.id ASC
+ LIMIT 1
+ """,
+ (customer_id, normalized_email),
+ )
+ if not existing_contact:
+ existing_contact = execute_query_single(
+ """
+ SELECT c.*
+ FROM contacts c
+ WHERE LOWER(COALESCE(c.email, '')) = %s
+ ORDER BY c.id ASC
+ LIMIT 1
+ """,
+ (normalized_email,),
+ )
+
+ # Fallback dedupe by full name within same customer when email is missing.
+ if not existing_contact and not normalized_email:
+ existing_contact = execute_query_single(
+ """
+ SELECT c.*
+ FROM contacts c
+ JOIN contact_companies cc ON cc.contact_id = c.id
+ WHERE cc.customer_id = %s
+ AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s)
+ AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s)
+ ORDER BY c.id ASC
+ LIMIT 1
+ """,
+ (customer_id, contact.first_name, contact.last_name),
+ )
+
+ if existing_contact:
+ contact_id = int(existing_contact["id"])
+ execute_update(
+ """
+ INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
+ VALUES (%s, %s, %s, %s)
+ ON CONFLICT (contact_id, customer_id)
+ DO UPDATE SET
+ is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
+ role = COALESCE(contact_companies.role, EXCLUDED.role)
+ """,
+ (contact_id, customer_id, bool(contact.is_primary), contact.role),
+ )
+
+ logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id)
+ return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
+
+ # Create contact when no reusable match exists.
contact_id = execute_insert(
"""INSERT INTO contacts
(first_name, last_name, email, phone, mobile, title, department)
@@ -1105,7 +1275,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
(
contact.first_name,
contact.last_name,
- contact.email,
+ normalized_email,
contact.phone,
contact.mobile,
contact.title,
@@ -1114,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
)
# Link contact to customer
- execute_insert(
+ execute_update(
"""INSERT INTO contact_companies
(contact_id, customer_id, is_primary, role)
- VALUES (%s, %s, %s, %s)""",
- (contact_id, customer_id, contact.is_primary, contact.role)
+ VALUES (%s, %s, %s, %s)
+ ON CONFLICT (contact_id, customer_id) DO NOTHING""",
+ (contact_id, customer_id, bool(contact.is_primary), contact.role)
)
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index f51c304..1dd76a4 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -498,6 +498,32 @@
Ingen tags tilføjet endnu.
+
+
+
+
+
Leverandørrelationer
+ 0
+
+
+
+
+
+
+ Knyt kunde til leverandør
+
+
+
+
+
Ingen linked leverandører endnu.
+
+
@@ -1423,6 +1449,7 @@ async function loadCustomer() {
await loadUtilityCompany();
await loadCustomerTags();
+ await loadCustomerVendorLinks();
// Check data consistency
await checkDataConsistency();
@@ -1433,6 +1460,141 @@ async function loadCustomer() {
}
}
+async function loadCustomerVendorLinks() {
+ const container = document.getElementById('customerVendorLinksContainer');
+ const empty = document.getElementById('customerVendorLinksEmpty');
+ const countEl = document.getElementById('customerVendorsCount');
+
+ if (!container || !empty || !countEl) return;
+
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/vendors`);
+ if (!response.ok) throw new Error('Kunne ikke hente leverandørlinks');
+ const links = await response.json();
+ const rows = Array.isArray(links) ? links : [];
+
+ countEl.textContent = String(rows.length);
+ if (!rows.length) {
+ container.innerHTML = '';
+ empty.classList.remove('d-none');
+ return;
+ }
+
+ empty.classList.add('d-none');
+ container.innerHTML = rows.map((row) => `
+
+
+
${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}
+
+ ${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''}
+ ${row.vendor_email ? escapeHtml(row.vendor_email) : '-'}
+
+
+
+
${escapeHtml(row.relationship_type || 'supplier')}
+
+
+
+
+
+
+ `).join('');
+ } catch (error) {
+ console.error('Failed to load customer vendor links:', error);
+ container.innerHTML = '';
+ empty.classList.remove('d-none');
+ }
+}
+
+let vendorSearchDebounce = null;
+async function searchVendorsForCustomer(query) {
+ const resultsEl = document.getElementById('customerVendorSearchResults');
+ if (!resultsEl) return;
+
+ const q = String(query || '').trim();
+ if (!q) {
+ resultsEl.style.display = 'none';
+ resultsEl.innerHTML = '';
+ return;
+ }
+
+ if (vendorSearchDebounce) window.clearTimeout(vendorSearchDebounce);
+ vendorSearchDebounce = window.setTimeout(async () => {
+ try {
+ const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(q)}&limit=10`);
+ if (!response.ok) throw new Error('Søgning fejlede');
+ const vendors = await response.json();
+ const rows = Array.isArray(vendors) ? vendors : [];
+
+ if (!rows.length) {
+ resultsEl.innerHTML = 'Ingen leverandører fundet
';
+ resultsEl.style.display = 'block';
+ return;
+ }
+
+ resultsEl.innerHTML = rows.map((v) => `
+
+
+
${escapeHtml(v.name || '')}
+
${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}
+
+
+
+ `).join('');
+ resultsEl.style.display = 'block';
+ } catch (error) {
+ console.error('Vendor search failed:', error);
+ resultsEl.innerHTML = 'Søgning fejlede
';
+ resultsEl.style.display = 'block';
+ }
+ }, 220);
+}
+
+async function linkVendorToCustomerFromUI(vendorId) {
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}?relationship_type=supplier`, {
+ method: 'POST'
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({}));
+ throw new Error(payload.detail || 'Kunne ikke linke leverandør');
+ }
+
+ const input = document.getElementById('customerVendorSearch');
+ const results = document.getElementById('customerVendorSearchResults');
+ if (input) input.value = '';
+ if (results) {
+ results.innerHTML = '';
+ results.style.display = 'none';
+ }
+
+ await loadCustomerVendorLinks();
+ await loadCustomerTags();
+ } catch (error) {
+ alert(error.message || 'Kunne ikke linke leverandør');
+ }
+}
+
+async function unlinkVendorFromCustomer(vendorId) {
+ if (!confirm('Fjern link mellem kunde og leverandør?')) return;
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}`, {
+ method: 'DELETE'
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({}));
+ throw new Error(payload.detail || 'Kunne ikke fjerne link');
+ }
+ await loadCustomerVendorLinks();
+ } catch (error) {
+ alert(error.message || 'Kunne ikke fjerne link');
+ }
+}
+
function displayCustomer(customer) {
// Update page title
document.title = `${customer.name} - BMC Hub`;
diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py
index 7abe5f5..1fe4d51 100644
--- a/app/emails/backend/router.py
+++ b/app/emails/backend/router.py
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime, date
+import unicodedata
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.services.email_processor_service import EmailProcessorService
@@ -20,6 +21,218 @@ router = APIRouter()
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
+IGNORED_SENDER_DOMAINS = {
+ "bmcnetworks.dk",
+ "bmchub.local",
+ "outlook.com",
+ "hotmail.com",
+ "gmail.com",
+ "icloud.com",
+ "yahoo.com",
+ "live.com",
+}
+
+
+def _normalize_domain(value: Optional[str]) -> str:
+ domain = str(value or "").strip().lower()
+ if not domain:
+ return ""
+ if domain.startswith("www."):
+ return domain[4:]
+ return domain
+
+
+def _extract_sender_domain(sender_email: Optional[str]) -> str:
+ sender = str(sender_email or "").strip().lower()
+ if "@" not in sender:
+ return ""
+ return _normalize_domain(sender.split("@", 1)[1])
+
+
+def _is_ignored_sender_domain(domain: str) -> bool:
+ if not domain:
+ return True
+ return domain in IGNORED_SENDER_DOMAINS or "bmc" in domain
+
+
+def _upsert_domain_mapping(domain: str, customer_id: int, source: str = "manual") -> None:
+ if not domain or not customer_id:
+ return
+ try:
+ execute_update(
+ """
+ INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (domain)
+ DO UPDATE SET
+ customer_id = EXCLUDED.customer_id,
+ source = EXCLUDED.source,
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ (domain, customer_id, source),
+ )
+ except Exception as e:
+ # Keep linking flow operational even if mapping table is not migrated yet.
+ logger.warning("⚠️ Could not upsert domain mapping for %s: %s", domain, e)
+
+
+def _resolve_procurement_customer_id() -> Optional[int]:
+ """Resolve a fallback customer for supplier/procurement case creation."""
+ 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"])
+
+ return None
+
+
+def _normalize_case_type(value: Optional[str]) -> str:
+ raw = str(value or "").strip().lower()
+ if not raw:
+ return "support"
+ normalized = unicodedata.normalize("NFKD", raw)
+ ascii_value = normalized.encode("ascii", "ignore").decode("ascii").strip().lower()
+ return ascii_value or raw
+
+
+def _is_supplier_case_type(case_type: Optional[str]) -> bool:
+ value = _normalize_case_type(case_type)
+ if value in {
+ "indkob",
+ "indkoeb",
+ "supplier",
+ "leverandor",
+ "leverandoer",
+ "vendor",
+ "procurement",
+ "purchase",
+ }:
+ return True
+ return "indk" in value or "leverand" in value or "supplier" in value
+
+
+def _extract_domain_from_email(email: Optional[str]) -> str:
+ sender = str(email or "").strip().lower()
+ if "@" not in sender:
+ return ""
+ return _normalize_domain(sender.split("@", 1)[1])
+
+
+def _find_customer_for_vendor(vendor: Dict) -> Optional[int]:
+ cvr = str(vendor.get("cvr_number") or "").strip()
+ if cvr:
+ row = execute_query_single(
+ "SELECT id FROM customers WHERE cvr_number = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
+ (cvr,),
+ )
+ if row:
+ return int(row["id"])
+
+ email = str(vendor.get("email") or "").strip().lower()
+ if email:
+ row = execute_query_single(
+ "SELECT id FROM customers WHERE LOWER(TRIM(email)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
+ (email,),
+ )
+ if row:
+ return int(row["id"])
+
+ domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email")))
+ if domain:
+ row = execute_query_single(
+ """
+ SELECT id
+ FROM customers
+ WHERE COALESCE(is_active, true) = true
+ AND (
+ LOWER(TRIM(COALESCE(email_domain, ''))) = %s
+ OR LOWER(TRIM(COALESCE(email_domain, ''))) = %s
+ )
+ ORDER BY id
+ LIMIT 1
+ """,
+ (domain, f"www.{domain}"),
+ )
+ if row:
+ return int(row["id"])
+
+ name = str(vendor.get("name") or "").strip().lower()
+ if name:
+ row = execute_query_single(
+ "SELECT id FROM customers WHERE LOWER(TRIM(name)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
+ (name,),
+ )
+ if row:
+ return int(row["id"])
+
+ return None
+
+
+def _ensure_customer_from_vendor(vendor_id: Optional[int]) -> Optional[int]:
+ if not vendor_id:
+ return None
+
+ vendor = execute_query_single(
+ """
+ SELECT id, name, email, phone, address, cvr_number, domain, city, postal_code, country, website
+ FROM vendors
+ WHERE id = %s AND is_active = true
+ """,
+ (vendor_id,),
+ )
+ if not vendor:
+ return None
+
+ existing_customer_id = _find_customer_for_vendor(vendor)
+ if existing_customer_id:
+ return existing_customer_id
+
+ name = str(vendor.get("name") or "").strip()
+ if not name:
+ return None
+
+ domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email"))) or None
+
+ try:
+ created_id = execute_insert(
+ """
+ INSERT INTO customers (name, email, phone, address, cvr_number, email_domain, city, postal_code, country, website, is_active)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
+ RETURNING id
+ """,
+ (
+ name,
+ vendor.get("email"),
+ vendor.get("phone"),
+ vendor.get("address"),
+ vendor.get("cvr_number"),
+ domain,
+ vendor.get("city"),
+ vendor.get("postal_code"),
+ vendor.get("country") or "DK",
+ vendor.get("website"),
+ ),
+ )
+ return int(created_id)
+ except Exception:
+ # Handle potential race/unique conflict by resolving again.
+ return _find_customer_for_vendor(vendor)
+
# Pydantic Models
class EmailListItem(BaseModel):
@@ -225,6 +438,12 @@ class RewriteEmailTextResponse(BaseModel):
context: Optional[str] = None
+class DomainMappingUpsertRequest(BaseModel):
+ domain: str
+ customer_id: int
+ source: Optional[str] = "manual"
+
+
@router.get("/emails/sag-options")
async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI."""
@@ -294,6 +513,222 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e))
+@router.get("/emails/{email_id}/domain-customer-suggestion")
+async def get_domain_customer_suggestion(email_id: int):
+ """Suggest customer based on sender domain for mails without known contact/customer."""
+ try:
+ email_row = execute_query_single(
+ "SELECT id, sender_email, customer_id FROM email_messages WHERE id = %s AND deleted_at IS NULL",
+ (email_id,),
+ )
+ if not email_row:
+ raise HTTPException(status_code=404, detail="Email not found")
+
+ if email_row.get("customer_id"):
+ return {
+ "email_id": email_id,
+ "domain": None,
+ "has_customer": True,
+ "ignored": False,
+ "suggestion": None,
+ }
+
+ sender_email = str(email_row.get("sender_email") or "").strip().lower()
+ if sender_email:
+ contact_match = execute_query_single(
+ """
+ SELECT
+ c.id,
+ c.name,
+ c.email_domain,
+ c.cvr_number,
+ ct.id AS contact_id,
+ ct.first_name,
+ ct.last_name
+ FROM contacts ct
+ JOIN contact_companies cc ON cc.contact_id = ct.id
+ JOIN customers c ON c.id = cc.customer_id
+ WHERE c.is_active = true
+ AND LOWER(TRIM(COALESCE(ct.email, ''))) = %s
+ ORDER BY cc.is_primary DESC, c.id ASC
+ LIMIT 1
+ """,
+ (sender_email,),
+ )
+ if contact_match:
+ contact_name = " ".join(
+ part for part in [contact_match.get("first_name"), contact_match.get("last_name")] if part
+ ).strip()
+ return {
+ "email_id": email_id,
+ "domain": _extract_sender_domain(sender_email),
+ "has_customer": False,
+ "ignored": False,
+ "suggestion": {
+ "customer_id": contact_match["id"],
+ "customer_name": contact_match["name"],
+ "email_domain": contact_match.get("email_domain"),
+ "cvr_number": contact_match.get("cvr_number"),
+ "confidence": "high",
+ "score": 110,
+ "source": f"contact_email:{contact_name or sender_email}",
+ },
+ }
+
+ sender_domain = _extract_sender_domain(email_row.get("sender_email"))
+ if not sender_domain:
+ return {
+ "email_id": email_id,
+ "domain": None,
+ "has_customer": False,
+ "ignored": True,
+ "reason": "no_domain",
+ "suggestion": None,
+ }
+
+ if _is_ignored_sender_domain(sender_domain):
+ return {
+ "email_id": email_id,
+ "domain": sender_domain,
+ "has_customer": False,
+ "ignored": True,
+ "reason": "ignored_domain",
+ "suggestion": None,
+ }
+
+ mapped = execute_query_single(
+ """
+ SELECT c.id, c.name, c.email_domain, c.cvr_number, m.source
+ FROM email_domain_customer_mappings m
+ JOIN customers c ON c.id = m.customer_id
+ WHERE m.domain = %s
+ AND c.is_active = true
+ LIMIT 1
+ """,
+ (sender_domain,),
+ )
+
+ if mapped:
+ return {
+ "email_id": email_id,
+ "domain": sender_domain,
+ "has_customer": False,
+ "ignored": False,
+ "suggestion": {
+ "customer_id": mapped["id"],
+ "customer_name": mapped["name"],
+ "email_domain": mapped.get("email_domain"),
+ "cvr_number": mapped.get("cvr_number"),
+ "confidence": "high",
+ "score": 100,
+ "source": f"mapping:{mapped.get('source') or 'manual'}",
+ },
+ }
+
+ exact = execute_query_single(
+ """
+ SELECT id, name, email_domain, cvr_number
+ FROM customers
+ WHERE is_active = true
+ AND (
+ LOWER(TRIM(email_domain)) = %s
+ OR LOWER(TRIM(email_domain)) = %s
+ )
+ ORDER BY id ASC
+ LIMIT 1
+ """,
+ (sender_domain, f"www.{sender_domain}"),
+ )
+
+ if exact:
+ return {
+ "email_id": email_id,
+ "domain": sender_domain,
+ "has_customer": False,
+ "ignored": False,
+ "suggestion": {
+ "customer_id": exact["id"],
+ "customer_name": exact["name"],
+ "email_domain": exact.get("email_domain"),
+ "cvr_number": exact.get("cvr_number"),
+ "confidence": "high",
+ "score": 95,
+ "source": "exact_domain",
+ },
+ }
+
+ partial = execute_query_single(
+ """
+ SELECT id, name, email_domain, cvr_number
+ FROM customers
+ WHERE is_active = true
+ AND COALESCE(email_domain, '') ILIKE %s
+ ORDER BY name ASC
+ LIMIT 1
+ """,
+ (f"%{sender_domain}%",),
+ )
+
+ if partial:
+ return {
+ "email_id": email_id,
+ "domain": sender_domain,
+ "has_customer": False,
+ "ignored": False,
+ "suggestion": {
+ "customer_id": partial["id"],
+ "customer_name": partial["name"],
+ "email_domain": partial.get("email_domain"),
+ "cvr_number": partial.get("cvr_number"),
+ "confidence": "medium",
+ "score": 70,
+ "source": "partial_domain",
+ },
+ }
+
+ return {
+ "email_id": email_id,
+ "domain": sender_domain,
+ "has_customer": False,
+ "ignored": False,
+ "suggestion": None,
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error getting domain customer suggestion: %s", e)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/emails/domain-customer-mapping")
+async def upsert_domain_customer_mapping(payload: DomainMappingUpsertRequest):
+ """Persist trusted mapping from sender domain to customer."""
+ try:
+ domain = _normalize_domain(payload.domain)
+ if not domain:
+ raise HTTPException(status_code=400, detail="domain is required")
+
+ customer = execute_query_single(
+ "SELECT id FROM customers WHERE id = %s AND is_active = true",
+ (payload.customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ _upsert_domain_mapping(domain, int(payload.customer_id), payload.source or "manual")
+ return {
+ "success": True,
+ "domain": domain,
+ "customer_id": int(payload.customer_id),
+ "source": payload.source or "manual",
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error upserting domain mapping: %s", e)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
async def rewrite_email_text(request: RewriteEmailTextRequest):
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
@@ -608,6 +1043,16 @@ async def link_email(email_id: int, payload: Dict):
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
execute_update(query, tuple(params))
+ customer_id = payload.get('customer_id')
+ if customer_id:
+ email_row = execute_query_single(
+ "SELECT sender_email FROM email_messages WHERE id = %s",
+ (email_id,),
+ )
+ sender_domain = _extract_sender_domain((email_row or {}).get("sender_email"))
+ if sender_domain and not _is_ignored_sender_domain(sender_domain):
+ _upsert_domain_mapping(sender_domain, int(customer_id), "auto_link")
+
logger.info(f"✅ Linked email {email_id}: {payload}")
return {"success": True, "message": "Email linket"}
@@ -630,13 +1075,59 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_row[0]
+
+ # Idempotent safeguard: repeated clicks should return existing linked case.
+ existing_sag_id = email_data.get('linked_case_id')
+ if existing_sag_id:
+ existing_sag = execute_query_single(
+ """
+ SELECT id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at
+ FROM sag_sager
+ WHERE id = %s AND deleted_at IS NULL
+ """,
+ (existing_sag_id,),
+ )
+ if existing_sag:
+ execute_update(
+ """
+ INSERT INTO sag_emails (sag_id, email_id)
+ VALUES (%s, %s)
+ ON CONFLICT (sag_id, email_id) DO NOTHING
+ """,
+ (existing_sag_id, email_id),
+ )
+ return {
+ "success": True,
+ "email_id": email_id,
+ "sag": existing_sag,
+ "idempotent": True,
+ "message": "E-mail er allerede knyttet til eksisterende SAG"
+ }
+
+ requested_case_type = _normalize_case_type(payload.case_type)
+
customer_id = payload.customer_id or email_data.get('customer_id')
+ if not customer_id and _is_supplier_case_type(requested_case_type):
+ customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
+ if not customer_id and _is_supplier_case_type(requested_case_type):
+ customer_id = _resolve_procurement_customer_id()
+
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
+ if not email_data.get('customer_id') and customer_id:
+ execute_update(
+ "UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
+ (customer_id, email_id),
+ )
+
+ sender_domain = _extract_sender_domain(email_data.get("sender_email"))
+ if sender_domain and not _is_ignored_sender_domain(sender_domain):
+ _upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
+
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
- template_key = (payload.case_type or 'support').strip().lower()[:50]
+ template_key = requested_case_type[:50]
priority = (payload.priority or 'normal').strip().lower()
if priority not in {'low', 'normal', 'high', 'urgent'}:
diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html
index e6f643b..6dbd463 100644
--- a/app/emails/frontend/emails.html
+++ b/app/emails/frontend/emails.html
@@ -283,6 +283,62 @@
overflow-x: hidden;
}
+ .quick-actions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
+ gap: 0.5rem;
+ width: 100%;
+ margin-bottom: 0.65rem;
+ }
+
+ .email-secondary-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ }
+
+ .email-secondary-actions .left,
+ .email-secondary-actions .right {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .domain-suggestion-card {
+ border: 1px dashed rgba(15, 76, 117, 0.35);
+ border-radius: 10px;
+ padding: 0.7rem 0.8rem;
+ background: rgba(15, 76, 117, 0.04);
+ margin-top: 0.65rem;
+ }
+
+ .domain-suggestion-card .title {
+ font-size: 0.83rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 0.35rem;
+ }
+
+ .domain-suggestion-meta {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+ }
+
+ .triage-priority-badge {
+ border: 1px solid rgba(15, 76, 117, 0.22);
+ background: rgba(15, 76, 117, 0.06);
+ color: var(--text-primary);
+ font-size: 0.75rem;
+ border-radius: 999px;
+ padding: 0.2rem 0.55rem;
+ font-weight: 600;
+ }
+
.attachment-chip {
max-width: 240px;
overflow: hidden;
@@ -300,6 +356,17 @@
background: #0a3a5c;
border-color: #0a3a5c;
}
+
+ .email-actions .btn-danger-soft {
+ background: rgba(220, 53, 69, 0.08);
+ color: #a72a37;
+ border-color: rgba(220, 53, 69, 0.28);
+ }
+
+ .email-actions .btn-danger-soft:hover {
+ background: rgba(220, 53, 69, 0.14);
+ color: #8e1f2d;
+ }
.email-body {
flex: 1;
@@ -1743,6 +1810,7 @@ function renderEmailList(emailList) {
${escapeHtml(preview)}
${!email.is_read ? '
' : ''}
+ ${getPriorityBadge(email)}
${formatClassification(classification)}
@@ -1793,6 +1861,7 @@ async function loadEmailDetail(emailId) {
renderEmailDetail(email);
renderEmailAnalysis(email);
+ await maybeSuggestCustomerByDomain(email);
if (!email.is_read) {
await markAsRead(emailId);
@@ -1805,6 +1874,176 @@ async function loadEmailDetail(emailId) {
}
}
+function withActionLoading(button, loadingText = 'Arbejder...') {
+ const btn = button || null;
+ if (!btn) {
+ return () => {};
+ }
+
+ const original = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = `
${loadingText}`;
+
+ return () => {
+ btn.disabled = false;
+ btn.innerHTML = original;
+ };
+}
+
+function normalizeDomain(value) {
+ const domain = String(value || '').trim().toLowerCase();
+ if (!domain) return '';
+ return domain.startsWith('www.') ? domain.slice(4) : domain;
+}
+
+function splitSenderName(name, email) {
+ const raw = String(name || '').trim();
+ if (!raw) {
+ const fallback = String(email || '').split('@')[0] || 'Kontakt';
+ return { firstName: fallback.slice(0, 40), lastName: 'Fra email' };
+ }
+
+ const cleaned = raw.replace(/[<>]/g, '').trim();
+ const parts = cleaned.split(/\s+/).filter(Boolean);
+ if (parts.length === 1) {
+ return { firstName: parts[0].slice(0, 40), lastName: '-' };
+ }
+ return {
+ firstName: parts[0].slice(0, 40),
+ lastName: parts.slice(1).join(' ').slice(0, 60)
+ };
+}
+
+function setCaseCustomerSelection(customerId, customerName) {
+ const hidden = document.getElementById('caseCustomerId');
+ const input = document.getElementById('caseCustomerSearch');
+ if (hidden) hidden.value = customerId ? String(customerId) : '';
+ if (input && customerName) input.value = customerName;
+}
+
+function renderDomainCustomerHint(html, visible = true) {
+ const hint = document.getElementById('domainCustomerHint');
+ if (!hint) return;
+ hint.innerHTML = html || '';
+ hint.style.display = visible ? 'block' : 'none';
+}
+
+async function linkEmailToCustomerQuick(customerId, customerName) {
+ if (!currentEmailId || !customerId) return;
+
+ try {
+ const resp = await fetch(`/api/v1/emails/${currentEmailId}/link`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ customer_id: customerId })
+ });
+ if (!resp.ok) throw new Error('Kunne ikke linke kunden');
+
+ try {
+ const senderEmail = String(document.querySelector('.sender-email')?.textContent || '').trim().toLowerCase();
+ const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
+ if (domain) {
+ await fetch('/api/v1/emails/domain-customer-mapping', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ domain,
+ customer_id: customerId,
+ source: 'manual'
+ })
+ });
+ }
+ } catch (mappingError) {
+ console.warn('Could not save domain mapping', mappingError);
+ }
+
+ setCaseCustomerSelection(customerId, customerName);
+ renderDomainCustomerHint(
+ `
Firma linket
+
Email er nu linket til ${escapeHtml(customerName || 'kunden')}.
`,
+ true
+ );
+ showSuccess(`Linket til ${customerName}`);
+ await loadEmailDetail(currentEmailId);
+ } catch (error) {
+ showError(error.message || 'Kunde-link fejlede');
+ }
+}
+
+async function maybeSuggestCustomerByDomain(email) {
+ if (!email || email.customer_id) {
+ renderDomainCustomerHint('', false);
+ return;
+ }
+
+ const senderEmail = String(email.sender_email || '').trim().toLowerCase();
+ const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
+ if (!domain) {
+ renderDomainCustomerHint('', false);
+ return;
+ }
+
+ renderDomainCustomerHint(
+ `
Søger firma på domæne…
+
Domæne: ${escapeHtml(domain)}
`,
+ true
+ );
+
+ try {
+ const resp = await fetch(`/api/v1/emails/${email.id}/domain-customer-suggestion`);
+ if (!resp.ok) throw new Error('Domæneopslag fejlede');
+ const payload = await resp.json();
+ const best = payload?.suggestion
+ ? {
+ id: payload.suggestion.customer_id,
+ name: payload.suggestion.customer_name,
+ email_domain: payload.suggestion.email_domain,
+ cvr_number: payload.suggestion.cvr_number,
+ source: payload.suggestion.source,
+ score: payload.suggestion.score,
+ confidence: payload.suggestion.confidence
+ }
+ : null;
+
+ if (!best) {
+ renderDomainCustomerHint(
+ `
Ingen firmamatch
+
Ingen kunde fundet for ${escapeHtml(domain)}. Brug “Opret firma/kontakt”.
`,
+ true
+ );
+ return;
+ }
+
+ const confidence = best.confidence === 'high' ? 'Høj' : 'Mellem';
+ renderDomainCustomerHint(
+ `
Muligt firma fundet (${confidence} confidence)
+
+ ${escapeHtml(best.name)}
+ ${best.email_domain ? `• ${escapeHtml(best.email_domain)}` : ''}
+ ${best.cvr_number ? `• CVR ${escapeHtml(best.cvr_number)}` : ''}
+ ${best.source ? `• Kilde: ${escapeHtml(best.source)}` : ''}
+
+
+
+
+
`,
+ true
+ );
+
+ setCaseCustomerSelection(best.id, best.name);
+ } catch (error) {
+ renderDomainCustomerHint(
+ `
Domæneopslag fejlede
+
${escapeHtml(error.message || 'Ukendt fejl')}
`,
+ true
+ );
+ }
+}
+
function getClassificationActions(email) {
const actions = [];
@@ -1918,31 +2157,51 @@ function renderEmailDetail(email) {
` : ''}
-
-
-