From daf2f294712e8a720d5b9677678d66de3c6bfb25 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 23 Mar 2026 20:35:15 +0100 Subject: [PATCH] feat: improve billing, sag, orders, and email workflows --- app/billing/backend/router.py | 473 ++++- app/billing/frontend/supplier_invoices.html | 3 + app/billing/frontend/sync_dashboard.html | 408 ++++ app/billing/frontend/views.py | 9 + app/emails/backend/router.py | 57 +- app/emails/frontend/emails.html | 59 +- app/jobs/process_subscriptions.py | 236 ++- app/jobs/reconcile_ordre_drafts.py | 119 ++ app/modules/orders/backend/router.py | 302 ++- app/modules/orders/templates/detail.html | 351 +++- app/modules/orders/templates/list.html | 274 ++- app/modules/sag/backend/router.py | 304 ++- app/modules/sag/frontend/views.py | 23 +- app/modules/sag/templates/detail.html | 1369 ++++++++++--- app/prepaid/backend/router.py | 8 +- app/prepaid/backend/views.py | 2 +- app/products/backend/router.py | 61 +- app/services/economic_service.py | 61 +- app/services/email_processor_service.py | 11 +- app/services/email_service.py | 505 ++++- app/services/email_workflow_service.py | 204 +- app/settings/backend/router.py | 110 +- app/settings/frontend/settings.html | 126 +- app/shared/frontend/base.html | 2 +- app/subscriptions/backend/router.py | 628 +++++- design_forslag_kompakt.html | 195 ++ design_forslag_sagsdetaljer.html | 338 ++++ design_forslag_top3_ny_side.html | 290 +++ forslag_kommentar.html | 197 ++ main.py | 26 +- .../1002_asset_first_subscription_billing.sql | 116 ++ .../1003_ordre_sync_audit_and_idempotency.sql | 25 + migrations/142_email_thread_key.sql | 40 + mine_3_anbefalinger.html | 324 +++ opgavebeskrivelse_mockup_3_forsog.html | 640 ++++++ samlet_forslag_oversigt.html | 500 +++++ ssag_muck_3_forslag.html | 1749 +++++++++++++++++ 37 files changed, 9618 insertions(+), 527 deletions(-) create mode 100644 app/billing/frontend/sync_dashboard.html create mode 100644 app/jobs/reconcile_ordre_drafts.py create mode 100644 design_forslag_kompakt.html create mode 100644 design_forslag_sagsdetaljer.html create mode 100644 design_forslag_top3_ny_side.html create mode 100644 forslag_kommentar.html create mode 100644 migrations/1002_asset_first_subscription_billing.sql create mode 100644 migrations/1003_ordre_sync_audit_and_idempotency.sql create mode 100644 migrations/142_email_thread_key.sql create mode 100644 mine_3_anbefalinger.html create mode 100644 opgavebeskrivelse_mockup_3_forsog.html create mode 100644 samlet_forslag_oversigt.html create mode 100644 ssag_muck_3_forslag.html diff --git a/app/billing/backend/router.py b/app/billing/backend/router.py index 3d72479..18dd886 100644 --- a/app/billing/backend/router.py +++ b/app/billing/backend/router.py @@ -3,7 +3,14 @@ Billing Router API endpoints for billing operations """ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException +from typing import Any, Dict, List +from datetime import datetime, date +import json +from dateutil.relativedelta import relativedelta +from app.core.database import execute_query, get_db_connection, release_db_connection +from psycopg2.extras import RealDictCursor +from app.jobs.reconcile_ordre_drafts import reconcile_ordre_drafts_sync_status from . import supplier_invoices router = APIRouter() @@ -12,6 +19,83 @@ router = APIRouter() router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"]) +@router.get("/billing/drafts/sync-dashboard") +async def get_draft_sync_dashboard(limit: int = 20): + """Operational dashboard data for ordre draft sync lifecycle.""" + try: + summary = execute_query( + """ + SELECT + COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, + COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count, + COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, + COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count, + COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count, + COUNT(*) AS total_count + FROM ordre_drafts + """, + (), + ) or [] + + attention = execute_query( + """ + SELECT + d.id, + d.title, + d.customer_id, + d.sync_status, + d.economic_order_number, + d.economic_invoice_number, + d.last_sync_at, + d.updated_at, + ev.event_type AS latest_event_type, + ev.created_at AS latest_event_at + FROM ordre_drafts d + LEFT JOIN LATERAL ( + SELECT event_type, created_at + FROM ordre_draft_sync_events + WHERE draft_id = d.id + ORDER BY created_at DESC, id DESC + LIMIT 1 + ) ev ON TRUE + WHERE d.sync_status IN ('pending', 'failed') + ORDER BY d.updated_at DESC + LIMIT %s + """, + (max(1, min(limit, 200)),), + ) or [] + + recent_events = execute_query( + """ + SELECT + ev.id, + ev.draft_id, + ev.event_type, + ev.from_status, + ev.to_status, + ev.event_payload, + ev.created_by_user_id, + ev.created_at, + d.title AS draft_title, + d.customer_id, + d.sync_status + FROM ordre_draft_sync_events ev + JOIN ordre_drafts d ON d.id = ev.draft_id + ORDER BY ev.created_at DESC, ev.id DESC + LIMIT %s + """, + (max(1, min(limit, 200)),), + ) or [] + + return { + "summary": summary[0] if summary else {}, + "attention_items": attention, + "recent_events": recent_events, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to load sync dashboard: {e}") + + @router.get("/billing/invoices") async def list_invoices(): """List all invoices""" @@ -22,3 +106,390 @@ async def list_invoices(): async def sync_to_economic(): """Sync data to e-conomic""" return {"message": "e-conomic sync coming soon"} + + +def _to_date(value: Any) -> date | None: + if value is None: + return None + if isinstance(value, date): + return value + if isinstance(value, datetime): + return value.date() + text = str(value).strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).date() + except ValueError: + return None + + +def _next_period(start: date, interval: str) -> date: + normalized = (interval or "monthly").strip().lower() + if normalized == "daily": + return start + relativedelta(days=1) + if normalized == "biweekly": + return start + relativedelta(weeks=2) + if normalized == "quarterly": + return start + relativedelta(months=3) + if normalized == "yearly": + return start + relativedelta(years=1) + return start + relativedelta(months=1) + + +@router.post("/billing/subscriptions/preview") +async def preview_subscription_billing(payload: Dict[str, Any]): + """ + Preview aggregated customer billing from due subscriptions. + Generates prorata suggestions for approved-but-not-applied price changes. + """ + try: + as_of = _to_date(payload.get("as_of")) or date.today() + customer_id = payload.get("customer_id") + + where = ["s.status = 'active'", "s.next_invoice_date <= %s", "COALESCE(s.billing_blocked, false) = false"] + params: List[Any] = [as_of] + if customer_id: + where.append("s.customer_id = %s") + params.append(customer_id) + + subscriptions = execute_query( + f""" + SELECT + s.id, + s.customer_id, + c.name AS customer_name, + s.product_name, + s.billing_interval, + s.billing_direction, + s.invoice_merge_key, + s.next_invoice_date, + s.period_start, + s.price, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', i.id, + 'description', i.description, + 'quantity', i.quantity, + 'unit_price', i.unit_price, + 'line_total', i.line_total, + 'asset_id', i.asset_id, + 'period_from', i.period_from, + 'period_to', i.period_to, + 'billing_blocked', i.billing_blocked + ) ORDER BY i.line_no ASC, i.id ASC + ) + FROM sag_subscription_items i + WHERE i.subscription_id = s.id + ), + '[]'::json + ) AS line_items + FROM sag_subscriptions s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE {' AND '.join(where)} + ORDER BY s.customer_id, s.next_invoice_date, s.id + """, + tuple(params), + ) or [] + + groups: Dict[str, Dict[str, Any]] = {} + for sub in subscriptions: + merge_key = sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}" + key = f"{sub['customer_id']}|{merge_key}|{sub.get('billing_direction') or 'forward'}|{sub.get('next_invoice_date')}" + grp = groups.setdefault( + key, + { + "customer_id": sub["customer_id"], + "customer_name": sub.get("customer_name"), + "merge_key": merge_key, + "billing_direction": sub.get("billing_direction") or "forward", + "invoice_date": str(sub.get("next_invoice_date")), + "coverage_start": None, + "coverage_end": None, + "subscription_ids": [], + "line_count": 0, + "amount_total": 0.0, + }, + ) + + sub_id = int(sub["id"]) + grp["subscription_ids"].append(sub_id) + start = _to_date(sub.get("period_start") or sub.get("next_invoice_date")) or as_of + end = _next_period(start, sub.get("billing_interval") or "monthly") + grp["coverage_start"] = str(start) if grp["coverage_start"] is None or str(start) < grp["coverage_start"] else grp["coverage_start"] + grp["coverage_end"] = str(end) if grp["coverage_end"] is None or str(end) > grp["coverage_end"] else grp["coverage_end"] + + for item in sub.get("line_items") or []: + if item.get("billing_blocked"): + continue + grp["line_count"] += 1 + grp["amount_total"] += float(item.get("line_total") or 0) + + price_changes = execute_query( + """ + SELECT + spc.id, + spc.subscription_id, + spc.subscription_item_id, + spc.old_unit_price, + spc.new_unit_price, + spc.effective_date, + spc.approval_status, + spc.reason, + s.period_start, + s.billing_interval + FROM subscription_price_changes spc + JOIN sag_subscriptions s ON s.id = spc.subscription_id + WHERE spc.deleted_at IS NULL + AND spc.approval_status IN ('approved', 'pending') + AND spc.effective_date <= %s + ORDER BY spc.effective_date ASC, spc.id ASC + """, + (as_of,), + ) or [] + + prorata_suggestions: List[Dict[str, Any]] = [] + for change in price_changes: + period_start = _to_date(change.get("period_start")) + if not period_start: + continue + period_end = _next_period(period_start, change.get("billing_interval") or "monthly") + eff = _to_date(change.get("effective_date")) + if not eff: + continue + if eff <= period_start or eff >= period_end: + continue + + total_days = max((period_end - period_start).days, 1) + remaining_days = max((period_end - eff).days, 0) + old_price = float(change.get("old_unit_price") or 0) + new_price = float(change.get("new_unit_price") or 0) + delta = new_price - old_price + prorata_amount = round(delta * (remaining_days / total_days), 2) + if prorata_amount == 0: + continue + + prorata_suggestions.append( + { + "price_change_id": change.get("id"), + "subscription_id": change.get("subscription_id"), + "subscription_item_id": change.get("subscription_item_id"), + "effective_date": str(eff), + "period_start": str(period_start), + "period_end": str(period_end), + "old_unit_price": old_price, + "new_unit_price": new_price, + "remaining_days": remaining_days, + "total_days": total_days, + "suggested_adjustment": prorata_amount, + "adjustment_type": "debit" if prorata_amount > 0 else "credit", + "reason": change.get("reason"), + "requires_manual_approval": True, + } + ) + + return { + "status": "preview", + "as_of": str(as_of), + "group_count": len(groups), + "groups": list(groups.values()), + "prorata_suggestions": prorata_suggestions, + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to preview subscription billing: {e}") + + +@router.post("/billing/prorata-adjustments/draft") +async def create_prorata_adjustment_draft(payload: Dict[str, Any]): + """ + Create a manual adjustment draft from an approved prorata suggestion. + Payload expects customer_id, subscription_id, amount, reason and optional effective dates. + """ + conn = get_db_connection() + try: + customer_id = payload.get("customer_id") + subscription_id = payload.get("subscription_id") + amount = float(payload.get("amount") or 0) + reason = (payload.get("reason") or "Prorata justering").strip() + effective_date = _to_date(payload.get("effective_date")) or date.today() + period_start = _to_date(payload.get("period_start")) + period_end = _to_date(payload.get("period_end")) + + if not customer_id: + raise HTTPException(status_code=400, detail="customer_id is required") + if not subscription_id: + raise HTTPException(status_code=400, detail="subscription_id is required") + if amount == 0: + raise HTTPException(status_code=400, detail="amount must be non-zero") + + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + """ + SELECT id, customer_id, product_name + FROM sag_subscriptions + WHERE id = %s + """, + (subscription_id,), + ) + sub = cursor.fetchone() + if not sub: + raise HTTPException(status_code=404, detail="Subscription not found") + if int(sub.get("customer_id") or 0) != int(customer_id): + raise HTTPException(status_code=400, detail="customer_id mismatch for subscription") + + adjustment_label = "Prorata tillæg" if amount > 0 else "Prorata kredit" + line = { + "product": { + "productNumber": "PRORATA", + "description": f"{adjustment_label}: {sub.get('product_name') or 'Abonnement'}" + }, + "quantity": 1, + "unitNetPrice": amount, + "totalNetAmount": amount, + "discountPercentage": 0, + "metadata": { + "subscription_id": subscription_id, + "effective_date": str(effective_date), + "period_start": str(period_start) if period_start else None, + "period_end": str(period_end) if period_end else None, + "reason": reason, + "manual_approval": True, + } + } + + cursor.execute( + """ + INSERT INTO ordre_drafts ( + title, + customer_id, + lines_json, + notes, + coverage_start, + coverage_end, + billing_direction, + source_subscription_ids, + invoice_aggregate_key, + layout_number, + created_by_user_id, + sync_status, + export_status_json, + updated_at + ) VALUES ( + %s, %s, %s::jsonb, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP + ) + RETURNING id, created_at + """, + ( + f"Manuel {adjustment_label}", + customer_id, + json.dumps([line], ensure_ascii=False), + reason, + period_start, + period_end, + "backward", + [subscription_id], + f"manual-prorata-{customer_id}", + 1, + payload.get("created_by_user_id"), + "pending", + json.dumps( + { + "source": "prorata_manual", + "subscription_id": subscription_id, + "effective_date": str(effective_date), + }, + ensure_ascii=False, + ), + ), + ) + created = cursor.fetchone() + + conn.commit() + return { + "status": "draft_created", + "draft_id": created.get("id") if created else None, + "created_at": created.get("created_at") if created else None, + "subscription_id": subscription_id, + "amount": amount, + } + except HTTPException: + conn.rollback() + raise + except Exception as e: + conn.rollback() + raise HTTPException(status_code=500, detail=f"Failed to create prorata adjustment draft: {e}") + finally: + release_db_connection(conn) + + +@router.post("/billing/drafts/reconcile-sync-status") +async def reconcile_draft_sync_status(payload: Dict[str, Any]): + """ + Reconcile ordre_drafts sync_status from known economic references. + Rules: + - pending/failed + economic_order_number -> exported + - exported + economic_invoice_number -> posted + - posted + mark_paid_ids contains draft id -> paid + """ + try: + apply_changes = bool(payload.get("apply", False)) + result = await reconcile_ordre_drafts_sync_status(apply_changes=apply_changes) + + mark_paid_ids = set(int(x) for x in (payload.get("mark_paid_ids") or []) if str(x).isdigit()) + if apply_changes and mark_paid_ids: + conn = get_db_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + for draft_id in mark_paid_ids: + cursor.execute("SELECT sync_status FROM ordre_drafts WHERE id = %s", (draft_id,)) + before = cursor.fetchone() + from_status = (before or {}).get("sync_status") + cursor.execute( + """ + UPDATE ordre_drafts + SET sync_status = 'paid', + last_sync_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + last_exported_at = CURRENT_TIMESTAMP + WHERE id = %s + AND sync_status = 'posted' + RETURNING id + """, + (draft_id,), + ) + updated = cursor.fetchone() + if updated: + cursor.execute( + """ + INSERT INTO ordre_draft_sync_events ( + draft_id, + event_type, + from_status, + to_status, + event_payload, + created_by_user_id + ) VALUES (%s, %s, %s, %s, %s::jsonb, NULL) + """, + ( + draft_id, + 'sync_status_manual_paid', + from_status, + 'paid', + '{"source":"billing_reconcile_endpoint"}', + ), + ) + conn.commit() + finally: + release_db_connection(conn) + + if mark_paid_ids: + result["mark_paid_ids"] = sorted(mark_paid_ids) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to reconcile draft sync status: {e}") diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html index a5e09f3..52c078e 100644 --- a/app/billing/frontend/supplier_invoices.html +++ b/app/billing/frontend/supplier_invoices.html @@ -110,6 +110,9 @@

Kassekladde - Integration med e-conomic

+ + Sync Dashboard + Se Templates diff --git a/app/billing/frontend/sync_dashboard.html b/app/billing/frontend/sync_dashboard.html new file mode 100644 index 0000000..cb05cea --- /dev/null +++ b/app/billing/frontend/sync_dashboard.html @@ -0,0 +1,408 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Sync Dashboard - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Draft Sync Dashboard

+

Overblik over ordre-draft sync, attention queue og seneste events.

+
+
+ + +
+
+ +
+
+
+
Total
+
0
+
+
+
+
+
Pending
+
0
+
+
+
+
+
Exported
+
0
+
+
+
+
+
Failed
+
0
+
+
+
+
+
Posted
+
0
+
+
+
+ +
+
+ +
+
+
+
+
Attention Items
+ +
+
+
+ + + + + + + + + + + + + + +
DraftStatusOrderInvoiceSeneste Event
Indlæser...
+
+
+
+
+ +
+
+
+
Recent Events
+ +
+
+
Indlæser...
+
+
+
+
+ + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/billing/frontend/views.py b/app/billing/frontend/views.py index 4ee085f..d48ed02 100644 --- a/app/billing/frontend/views.py +++ b/app/billing/frontend/views.py @@ -45,3 +45,12 @@ async def templates_list_page(request: Request): "request": request, "title": "Templates" }) + + +@router.get("/billing/sync-dashboard", response_class=HTMLResponse) +async def billing_sync_dashboard_page(request: Request): + """Operational sync dashboard for ordre_drafts lifecycle.""" + return templates.TemplateResponse("billing/frontend/sync_dashboard.html", { + "request": request, + "title": "Billing Sync Dashboard" + }) diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 3cc0675..41ce499 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -18,6 +18,8 @@ logger = logging.getLogger(__name__) router = APIRouter() +ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"} + # Pydantic Models class EmailListItem(BaseModel): @@ -36,6 +38,8 @@ class EmailListItem(BaseModel): rule_name: Optional[str] = None supplier_name: Optional[str] = None customer_name: Optional[str] = None + linked_case_id: Optional[int] = None + linked_case_title: Optional[str] = None class EmailAttachment(BaseModel): @@ -79,6 +83,7 @@ class EmailDetail(BaseModel): attachments: List[EmailAttachment] = [] customer_name: Optional[str] = None supplier_name: Optional[str] = None + linked_case_title: Optional[str] = None class EmailRule(BaseModel): @@ -160,14 +165,12 @@ class CreateSagFromEmailRequest(BaseModel): ansvarlig_bruger_id: Optional[int] = None assigned_group_id: Optional[int] = None created_by_user_id: int = 1 - relation_type: str = "kommentar" + relation_type: str = "mail" class LinkEmailToSagRequest(BaseModel): sag_id: int - relation_type: str = "kommentar" - note: Optional[str] = None - forfatter: str = "E-mail Motor" + relation_type: str = "mail" mark_processed: bool = True @@ -302,13 +305,16 @@ async def list_emails( em.received_date, em.classification, em.confidence_score, em.status, em.is_read, em.has_attachments, em.attachment_count, em.body_text, em.body_html, + em.linked_case_id, er.name as rule_name, v.name as supplier_name, - c.name as customer_name + c.name as customer_name, + s.titel AS linked_case_title FROM email_messages em LEFT JOIN email_rules er ON em.rule_id = er.id LEFT JOIN vendors v ON em.supplier_id = v.id LEFT JOIN customers c ON em.customer_id = c.id + LEFT JOIN sag_sager s ON em.linked_case_id = s.id WHERE {where_sql} ORDER BY em.received_date DESC LIMIT %s OFFSET %s @@ -331,10 +337,12 @@ async def get_email(email_id: int): query = """ SELECT em.*, c.name AS customer_name, - v.name AS supplier_name + v.name AS supplier_name, + s.titel AS linked_case_title FROM email_messages em LEFT JOIN customers c ON em.customer_id = c.id LEFT JOIN vendors v ON em.supplier_id = v.id + LEFT JOIN sag_sager s ON em.linked_case_id = s.id WHERE em.id = %s AND em.deleted_at IS NULL """ result = execute_query(query, (email_id,)) @@ -580,23 +588,9 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques (sag_id, payload.contact_id, 'primary') ) - relation_type = (payload.relation_type or 'kommentar').strip().lower() - if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}: - system_note = ( - f"E-mail knyttet som {relation_type}.\n" - f"Emne: {email_data.get('subject') or '(ingen emne)'}\n" - f"Fra: {email_data.get('sender_email') or '(ukendt)'}" - ) - if payload.secondary_label: - system_note += f"\nLabel: {payload.secondary_label.strip()[:60]}" - - execute_update( - """ - INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) - VALUES (%s, %s, %s, %s) - """, - (sag_id, 'E-mail Motor', system_note, True) - ) + relation_type = (payload.relation_type or 'mail').strip().lower() + if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES: + raise HTTPException(status_code=400, detail="relation_type must be 'mail'") return { "success": True, @@ -663,20 +657,9 @@ async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest): (payload.sag_id, email_id) ) - relation_type = (payload.relation_type or 'kommentar').strip().lower() - if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}: - email_data = email_row[0] - note = payload.note or ( - f"E-mail knyttet som {relation_type}. " - f"Emne: {email_data.get('subject') or '(ingen emne)'}" - ) - execute_update( - """ - INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) - VALUES (%s, %s, %s, %s) - """, - (payload.sag_id, payload.forfatter, note, True) - ) + relation_type = (payload.relation_type or 'mail').strip().lower() + if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES: + raise HTTPException(status_code=400, detail="relation_type must be 'mail'") return { "success": True, diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index daf6f1b..9cc265f 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -555,6 +555,27 @@ flex-wrap: wrap; } + .quick-action-row-case .btn { + min-height: 40px; + } + + @media (max-width: 576px) { + .quick-action-row-case { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + + .quick-action-row-case .btn { + width: 100%; + } + + .quick-action-row-case .btn:nth-child(1), + .quick-action-row-case .btn:nth-child(2) { + grid-column: 1 / -1; + } + } + .customer-search-wrap { position: relative; } @@ -1702,6 +1723,7 @@ function renderEmailList(emailList) { ${formatClassification(classification)} + ${getCaseBadge(email)} ${getStatusBadge(email)} ${email.confidence_score ? `${Math.round(email.confidence_score * 100)}%` : ''} ${email.has_attachments ? ` ${email.attachment_count || ''}` : ''} @@ -1864,6 +1886,13 @@ function renderEmailDetail(email) { ${timestamp}
+ ${email.linked_case_id ? ` +
+ + SAG-${email.linked_case_id}${email.linked_case_title ? `: ${escapeHtml(email.linked_case_title)}` : ''} + +
+ ` : ''}
@@ -1880,6 +1909,11 @@ function renderEmailDetail(email) { + ${email.linked_case_id ? ` + + Sag + + ` : ''} @@ -2055,7 +2089,7 @@ function renderEmailAnalysis(email) {
-
+
@@ -2073,15 +2107,7 @@ function renderEmailAnalysis(email) {
-
- - -
-
@@ -2283,7 +2309,7 @@ function getCaseFormPayload() { priority: document.getElementById('casePriority')?.value || 'normal', ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null, assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null, - relation_type: 'kommentar' + relation_type: 'mail' }; } @@ -2331,7 +2357,7 @@ async function linkCurrentEmailToExistingSag() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sag_id: Number(selectedSagId), - relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar' + relation_type: 'mail' }) }); @@ -3196,6 +3222,15 @@ function getStatusBadge(email) { return ''; } +function getCaseBadge(email) { + if (!email.linked_case_id) { + return ''; + } + + const title = email.linked_case_title ? ` title="${escapeHtml(email.linked_case_title)}"` : ''; + return `SAG-${email.linked_case_id}`; +} + function getFileIcon(contentType) { if (contentType?.includes('pdf')) return 'pdf'; if (contentType?.includes('image')) return 'image'; diff --git a/app/jobs/process_subscriptions.py b/app/jobs/process_subscriptions.py index 217e59f..81db2c3 100644 --- a/app/jobs/process_subscriptions.py +++ b/app/jobs/process_subscriptions.py @@ -7,11 +7,9 @@ Runs daily at 04:00 import logging from datetime import datetime, date -from decimal import Decimal import json from dateutil.relativedelta import relativedelta -from app.core.config import settings from app.core.database import execute_query, get_db_connection logger = logging.getLogger(__name__) @@ -19,11 +17,11 @@ logger = logging.getLogger(__name__) async def process_subscriptions(): """ - Main job: Process subscriptions due for invoicing - - Find active subscriptions where next_invoice_date <= TODAY - - Create ordre draft with line items from subscription - - Advance period_start and next_invoice_date based on billing_interval - - Log all actions for audit trail + Main job: Process subscriptions due for invoicing. + - Find active subscriptions where next_invoice_date <= today + - Skip subscriptions blocked for invoicing (missing asset/serial) + - Aggregate eligible subscriptions into one ordre_draft per customer + merge key + due date + billing direction + - Advance period_start and next_invoice_date for processed subscriptions """ try: @@ -39,9 +37,14 @@ async def process_subscriptions(): c.name AS customer_name, s.product_name, s.billing_interval, + s.billing_direction, + s.advance_months, s.price, s.next_invoice_date, s.period_start, + s.invoice_merge_key, + s.billing_blocked, + s.billing_block_reason, COALESCE( ( SELECT json_agg( @@ -51,7 +54,12 @@ async def process_subscriptions(): 'quantity', si.quantity, 'unit_price', si.unit_price, 'line_total', si.line_total, - 'product_id', si.product_id + 'product_id', si.product_id, + 'asset_id', si.asset_id, + 'billing_blocked', si.billing_blocked, + 'billing_block_reason', si.billing_block_reason, + 'period_from', si.period_from, + 'period_to', si.period_to ) ORDER BY si.id ) FROM sag_subscription_items si @@ -75,110 +83,186 @@ async def process_subscriptions(): logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process") + blocked_count = 0 processed_count = 0 error_count = 0 - + + grouped_subscriptions = {} for sub in subscriptions: + if sub.get('billing_blocked'): + blocked_count += 1 + logger.warning( + "⚠️ Subscription %s skipped due to billing block: %s", + sub.get('id'), + sub.get('billing_block_reason') or 'unknown reason' + ) + continue + + group_key = ( + int(sub['customer_id']), + str(sub.get('invoice_merge_key') or f"cust-{sub['customer_id']}"), + str(sub.get('next_invoice_date')), + str(sub.get('billing_direction') or 'forward'), + ) + grouped_subscriptions.setdefault(group_key, []).append(sub) + + for group in grouped_subscriptions.values(): try: - await _process_single_subscription(sub) - processed_count += 1 + count = await _process_subscription_group(group) + processed_count += count except Exception as e: - logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True) + logger.error("❌ Failed processing subscription group: %s", e, exc_info=True) error_count += 1 - - logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors") + + logger.info( + "✅ Subscription processing complete: %s processed, %s blocked, %s errors", + processed_count, + blocked_count, + error_count, + ) except Exception as e: logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True) -async def _process_single_subscription(sub: dict): - """Process a single subscription: create ordre draft and advance period""" - - subscription_id = sub['id'] - logger.info(f"Processing subscription #{subscription_id}: {sub['product_name']} for {sub['customer_name']}") - +async def _process_subscription_group(subscriptions: list[dict]) -> int: + """Create one aggregated ordre draft for a group of subscriptions and advance all periods.""" + + if not subscriptions: + return 0 + + first = subscriptions[0] + customer_id = first['customer_id'] + customer_name = first.get('customer_name') or f"Customer #{customer_id}" + billing_direction = first.get('billing_direction') or 'forward' + invoice_aggregate_key = first.get('invoice_merge_key') or f"cust-{customer_id}" + conn = get_db_connection() cursor = conn.cursor() - + try: - # Convert line_items from JSON to list - line_items = sub.get('line_items', []) - if isinstance(line_items, str): - line_items = json.loads(line_items) - - # Build ordre draft lines_json ordre_lines = [] - for item in line_items: - product_number = str(item.get('product_id', 'SUB')) - ordre_lines.append({ - "product": { - "productNumber": product_number, - "description": item.get('description', '') - }, - "quantity": float(item.get('quantity', 1)), - "unitNetPrice": float(item.get('unit_price', 0)), - "totalNetAmount": float(item.get('line_total', 0)), - "discountPercentage": 0 - }) - - # Create ordre draft title with period information - period_start = sub.get('period_start') or sub.get('next_invoice_date') - next_period_start = _calculate_next_period_start(period_start, sub['billing_interval']) - - title = f"Abonnement: {sub['product_name']}" - notes = f"Periode: {period_start} til {next_period_start}\nAbonnement ID: {subscription_id}" - - if sub.get('sag_id'): - notes += f"\nSag: {sub['sag_name']}" - - # Insert ordre draft + source_subscription_ids = [] + coverage_start = None + coverage_end = None + + for sub in subscriptions: + subscription_id = int(sub['id']) + source_subscription_ids.append(subscription_id) + + line_items = sub.get('line_items', []) + if isinstance(line_items, str): + line_items = json.loads(line_items) + + period_start = sub.get('period_start') or sub.get('next_invoice_date') + period_end = _calculate_next_period_start(period_start, sub['billing_interval']) + if coverage_start is None or period_start < coverage_start: + coverage_start = period_start + if coverage_end is None or period_end > coverage_end: + coverage_end = period_end + + for item in line_items: + if item.get('billing_blocked'): + logger.warning( + "⚠️ Skipping blocked subscription item %s on subscription %s", + item.get('id'), + subscription_id, + ) + continue + + product_number = str(item.get('product_id', 'SUB')) + ordre_lines.append({ + "product": { + "productNumber": product_number, + "description": item.get('description', '') + }, + "quantity": float(item.get('quantity', 1)), + "unitNetPrice": float(item.get('unit_price', 0)), + "totalNetAmount": float(item.get('line_total', 0)), + "discountPercentage": 0, + "metadata": { + "subscription_id": subscription_id, + "asset_id": item.get('asset_id'), + "period_from": str(item.get('period_from') or period_start), + "period_to": str(item.get('period_to') or period_end), + } + }) + + if not ordre_lines: + logger.warning("⚠️ No invoiceable lines in subscription group for customer %s", customer_id) + return 0 + + title = f"Abonnementer: {customer_name}" + notes = ( + f"Aggregated abonnement faktura\n" + f"Kunde: {customer_name}\n" + f"Coverage: {coverage_start} til {coverage_end}\n" + f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}" + ) + insert_query = """ INSERT INTO ordre_drafts ( title, customer_id, lines_json, notes, + coverage_start, + coverage_end, + billing_direction, + source_subscription_ids, + invoice_aggregate_key, layout_number, created_by_user_id, + sync_status, export_status_json, updated_at - ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) + ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) RETURNING id """ - + cursor.execute(insert_query, ( title, - sub['customer_id'], + customer_id, json.dumps(ordre_lines, ensure_ascii=False), notes, + coverage_start, + coverage_end, + billing_direction, + source_subscription_ids, + invoice_aggregate_key, 1, # Default layout None, # System-created - json.dumps({"source": "subscription", "subscription_id": subscription_id}, ensure_ascii=False) + 'pending', + json.dumps({"source": "subscription", "subscription_ids": source_subscription_ids}, ensure_ascii=False) )) - + ordre_id = cursor.fetchone()[0] - logger.info(f"✅ Created ordre draft #{ordre_id} for subscription #{subscription_id}") - - # Calculate new period dates - current_period_start = sub.get('period_start') or sub.get('next_invoice_date') - new_period_start = next_period_start - new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval']) - - # Update subscription with new period dates - update_query = """ - UPDATE sag_subscriptions - SET period_start = %s, - next_invoice_date = %s, - updated_at = CURRENT_TIMESTAMP - WHERE id = %s - """ - - cursor.execute(update_query, (new_period_start, new_next_invoice_date, subscription_id)) - + logger.info( + "✅ Created aggregated ordre draft #%s for %s subscription(s)", + ordre_id, + len(source_subscription_ids), + ) + + for sub in subscriptions: + subscription_id = int(sub['id']) + current_period_start = sub.get('period_start') or sub.get('next_invoice_date') + new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval']) + new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval']) + + cursor.execute( + """ + UPDATE sag_subscriptions + SET period_start = %s, + next_invoice_date = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (new_period_start, new_next_invoice_date, subscription_id) + ) + conn.commit() - logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}") - + return len(source_subscription_ids) + except Exception as e: conn.rollback() raise e diff --git a/app/jobs/reconcile_ordre_drafts.py b/app/jobs/reconcile_ordre_drafts.py new file mode 100644 index 0000000..7033452 --- /dev/null +++ b/app/jobs/reconcile_ordre_drafts.py @@ -0,0 +1,119 @@ +""" +Reconcile ordre draft sync lifecycle. + +Promotes sync_status based on known economic references on ordre_drafts: +- pending/failed + economic_order_number -> exported +- exported + economic_invoice_number -> posted +""" + +import logging +from typing import Any, Dict, List + +from app.core.database import execute_query, get_db_connection, release_db_connection +from app.services.economic_service import get_economic_service +from psycopg2.extras import RealDictCursor + +logger = logging.getLogger(__name__) + + +async def reconcile_ordre_drafts_sync_status(apply_changes: bool = True) -> Dict[str, Any]: + """Reconcile ordre_drafts sync statuses and optionally persist changes.""" + + drafts = execute_query( + """ + SELECT id, sync_status, economic_order_number, economic_invoice_number + FROM ordre_drafts + ORDER BY id ASC + """, + (), + ) or [] + + changes: List[Dict[str, Any]] = [] + invoice_status_cache: Dict[str, str] = {} + economic_service = get_economic_service() + + for draft in drafts: + current = (draft.get("sync_status") or "pending").strip().lower() + target = current + + if current in {"pending", "failed"} and draft.get("economic_order_number"): + target = "exported" + if target == "exported" and draft.get("economic_invoice_number"): + target = "posted" + + invoice_number = str(draft.get("economic_invoice_number") or "").strip() + if invoice_number: + if invoice_number not in invoice_status_cache: + invoice_status_cache[invoice_number] = await economic_service.get_invoice_lifecycle_status(invoice_number) + lifecycle = invoice_status_cache[invoice_number] + if lifecycle == "paid": + target = "paid" + elif lifecycle in {"booked", "unpaid"} and target in {"pending", "failed", "exported"}: + target = "posted" + + if target != current: + changes.append( + { + "draft_id": draft.get("id"), + "from": current, + "to": target, + "economic_invoice_number": invoice_number or None, + } + ) + + if apply_changes and changes: + conn = get_db_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + for change in changes: + cursor.execute( + """ + UPDATE ordre_drafts + SET sync_status = %s, + last_sync_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + last_exported_at = CASE + WHEN %s IN ('exported', 'posted', 'paid') THEN CURRENT_TIMESTAMP + ELSE last_exported_at + END + WHERE id = %s + """, + (change["to"], change["to"], change["draft_id"]), + ) + cursor.execute( + """ + INSERT INTO ordre_draft_sync_events ( + draft_id, + event_type, + from_status, + to_status, + event_payload, + created_by_user_id + ) VALUES (%s, %s, %s, %s, %s::jsonb, NULL) + """, + ( + change["draft_id"], + "sync_status_reconcile", + change["from"], + change["to"], + '{"source":"reconcile_job"}', + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + release_db_connection(conn) + + logger.info( + "✅ Reconciled ordre draft sync status: %s changes (%s)", + len(changes), + "applied" if apply_changes else "preview", + ) + + return { + "status": "applied" if apply_changes else "preview", + "change_count": len(changes), + "changes": changes, + } diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index 7d0cc07..87dd089 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -1,6 +1,7 @@ import logging import json from datetime import datetime +from uuid import uuid4 from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query, Request @@ -11,6 +12,7 @@ from app.modules.orders.backend.service import aggregate_order_lines logger = logging.getLogger(__name__) router = APIRouter() +ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"} class OrdreLineInput(BaseModel): @@ -32,6 +34,7 @@ class OrdreExportRequest(BaseModel): notes: Optional[str] = None layout_number: Optional[int] = None draft_id: Optional[int] = None + force_export: bool = False class OrdreDraftUpsertRequest(BaseModel): @@ -65,6 +68,42 @@ def _get_user_id_from_request(http_request: Request) -> Optional[int]: return None +def _log_sync_event( + draft_id: int, + event_type: str, + from_status: Optional[str], + to_status: Optional[str], + event_payload: Dict[str, Any], + user_id: Optional[int], +) -> None: + """Best-effort logging of sync events for ordre_drafts.""" + try: + from app.core.database import execute_query + + execute_query( + """ + INSERT INTO ordre_draft_sync_events ( + draft_id, + event_type, + from_status, + to_status, + event_payload, + created_by_user_id + ) VALUES (%s, %s, %s, %s, %s::jsonb, %s) + """, + ( + draft_id, + event_type, + from_status, + to_status, + json.dumps(event_payload, ensure_ascii=False), + user_id, + ) + ) + except Exception as e: + logger.warning("⚠️ Could not log ordre sync event for draft %s: %s", draft_id, e) + + @router.get("/ordre/aggregate") async def get_ordre_aggregate( customer_id: Optional[int] = Query(None), @@ -95,6 +134,39 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): """Export selected ordre lines to e-conomic draft order.""" try: user_id = _get_user_id_from_request(http_request) + previous_status = None + export_idempotency_key = None + + if request.draft_id: + from app.core.database import execute_query_single + + draft_row = execute_query_single( + """ + SELECT id, sync_status, export_idempotency_key, export_status_json + FROM ordre_drafts + WHERE id = %s + """, + (request.draft_id,) + ) + if not draft_row: + raise HTTPException(status_code=404, detail="Draft not found") + + previous_status = (draft_row.get("sync_status") or "pending").strip().lower() + if previous_status in {"exported", "posted", "paid"} and not request.force_export: + raise HTTPException( + status_code=409, + detail=f"Draft already exported with status '{previous_status}'. Use force_export=true to retry.", + ) + + export_idempotency_key = draft_row.get("export_idempotency_key") or str(uuid4()) + _log_sync_event( + request.draft_id, + "export_attempt", + previous_status, + previous_status, + {"force_export": request.force_export, "idempotency_key": export_idempotency_key}, + user_id, + ) line_payload = [line.model_dump() for line in request.lines] export_result = await ordre_economic_export_service.export_order( @@ -123,15 +195,53 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): "timestamp": datetime.utcnow().isoformat(), } + economic_order_number = ( + export_result.get("economic_order_number") + or export_result.get("order_number") + or export_result.get("orderNumber") + ) + economic_invoice_number = ( + export_result.get("economic_invoice_number") + or export_result.get("invoice_number") + or export_result.get("invoiceNumber") + ) + target_sync_status = "pending" if export_result.get("dry_run") else "exported" + execute_query( """ UPDATE ordre_drafts SET export_status_json = %s::jsonb, + sync_status = %s, + export_idempotency_key = %s, + economic_order_number = COALESCE(%s, economic_order_number), + economic_invoice_number = COALESCE(%s, economic_invoice_number), + last_sync_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, - (json.dumps(existing_status, ensure_ascii=False), request.draft_id), + ( + json.dumps(existing_status, ensure_ascii=False), + target_sync_status, + export_idempotency_key, + str(economic_order_number) if economic_order_number is not None else None, + str(economic_invoice_number) if economic_invoice_number is not None else None, + request.draft_id, + ), + ) + + _log_sync_event( + request.draft_id, + "export_success", + previous_status, + target_sync_status, + { + "dry_run": bool(export_result.get("dry_run")), + "idempotency_key": export_idempotency_key, + "economic_order_number": economic_order_number, + "economic_invoice_number": economic_invoice_number, + }, + user_id, ) return export_result @@ -150,9 +260,26 @@ async def list_ordre_drafts( """List all ordre drafts (no user filtering).""" try: query = """ - SELECT id, title, customer_id, notes, layout_number, created_by_user_id, - created_at, updated_at, last_exported_at + SELECT id, title, customer_id, notes, layout_number, created_by_user_id, + coverage_start, coverage_end, billing_direction, source_subscription_ids, + invoice_aggregate_key, sync_status, export_idempotency_key, + economic_order_number, economic_invoice_number, + ev_latest.event_type AS latest_event_type, + ev_latest.created_at AS latest_event_at, + ( + SELECT COUNT(*) + FROM ordre_draft_sync_events ev + WHERE ev.draft_id = ordre_drafts.id + ) AS sync_event_count, + last_sync_at, created_at, updated_at, last_exported_at FROM ordre_drafts + LEFT JOIN LATERAL ( + SELECT event_type, created_at + FROM ordre_draft_sync_events + WHERE draft_id = ordre_drafts.id + ORDER BY created_at DESC, id DESC + LIMIT 1 + ) ev_latest ON TRUE ORDER BY updated_at DESC, id DESC LIMIT %s """ @@ -202,9 +329,10 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req notes, layout_number, created_by_user_id, + sync_status, export_status_json, updated_at - ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) + ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, CURRENT_TIMESTAMP) RETURNING * """ params = ( @@ -223,6 +351,172 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req raise HTTPException(status_code=500, detail="Failed to create ordre draft") +@router.get("/ordre/drafts/sync-status/summary") +async def get_ordre_draft_sync_summary(http_request: Request): + """Return sync status counters for ordre drafts.""" + try: + from app.core.database import execute_query_single + + query = """ + SELECT + COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, + COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count, + COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, + COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count, + COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count, + COUNT(*) AS total_count + FROM ordre_drafts + """ + return execute_query_single(query, ()) or { + "pending_count": 0, + "exported_count": 0, + "failed_count": 0, + "posted_count": 0, + "paid_count": 0, + "total_count": 0, + } + except Exception as e: + logger.error("❌ Error loading ordre sync summary: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to load sync summary") + + +@router.patch("/ordre/drafts/{draft_id}/sync-status") +async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any], http_request: Request): + """Update sync lifecycle fields for one ordre draft.""" + try: + user_id = _get_user_id_from_request(http_request) + sync_status = (payload.get("sync_status") or "").strip().lower() + if sync_status not in ALLOWED_SYNC_STATUSES: + raise HTTPException(status_code=400, detail="Invalid sync_status") + + economic_order_number = payload.get("economic_order_number") + economic_invoice_number = payload.get("economic_invoice_number") + export_status_json = payload.get("export_status_json") + + updates = ["sync_status = %s", "last_sync_at = CURRENT_TIMESTAMP", "updated_at = CURRENT_TIMESTAMP"] + values: List[Any] = [sync_status] + + if economic_order_number is not None: + updates.append("economic_order_number = %s") + values.append(str(economic_order_number) if economic_order_number else None) + + if economic_invoice_number is not None: + updates.append("economic_invoice_number = %s") + values.append(str(economic_invoice_number) if economic_invoice_number else None) + + if export_status_json is not None: + updates.append("export_status_json = %s::jsonb") + values.append(json.dumps(export_status_json, ensure_ascii=False)) + + if sync_status in {"exported", "posted", "paid"}: + updates.append("last_exported_at = CURRENT_TIMESTAMP") + + from app.core.database import execute_query_single + previous = execute_query_single( + "SELECT sync_status FROM ordre_drafts WHERE id = %s", + (draft_id,) + ) + if not previous: + raise HTTPException(status_code=404, detail="Draft not found") + from_status = (previous.get("sync_status") or "pending").strip().lower() + + values.append(draft_id) + from app.core.database import execute_query + result = execute_query( + f""" + UPDATE ordre_drafts + SET {', '.join(updates)} + WHERE id = %s + RETURNING * + """, + tuple(values) + ) + if not result: + raise HTTPException(status_code=404, detail="Draft not found") + + _log_sync_event( + draft_id, + "sync_status_manual_update", + from_status, + sync_status, + { + "economic_order_number": economic_order_number, + "economic_invoice_number": economic_invoice_number, + }, + user_id, + ) + return result[0] + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error updating ordre draft sync status: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to update draft sync status") + + +@router.get("/ordre/drafts/{draft_id}/sync-events") +async def list_ordre_draft_sync_events( + draft_id: int, + http_request: Request, + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + event_type: Optional[str] = Query(None), + from_status: Optional[str] = Query(None), + to_status: Optional[str] = Query(None), + from_date: Optional[str] = Query(None), + to_date: Optional[str] = Query(None), +): + """List audit events for one ordre draft sync lifecycle.""" + try: + from app.core.database import execute_query + + where_clauses = ["draft_id = %s"] + params: List[Any] = [draft_id] + + if event_type: + where_clauses.append("event_type = %s") + params.append(event_type) + if from_status: + where_clauses.append("from_status = %s") + params.append(from_status) + if to_status: + where_clauses.append("to_status = %s") + params.append(to_status) + if from_date: + where_clauses.append("created_at >= %s::timestamp") + params.append(from_date) + if to_date: + where_clauses.append("created_at <= %s::timestamp") + params.append(to_date) + + count_query = f""" + SELECT COUNT(*) AS total + FROM ordre_draft_sync_events + WHERE {' AND '.join(where_clauses)} + """ + total_row = execute_query(count_query, tuple(params)) or [{"total": 0}] + total = int(total_row[0].get("total") or 0) + + data_query = f""" + SELECT id, draft_id, event_type, from_status, to_status, event_payload, created_by_user_id, created_at + FROM ordre_draft_sync_events + WHERE {' AND '.join(where_clauses)} + ORDER BY created_at DESC, id DESC + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + rows = execute_query(data_query, tuple(params)) or [] + + return { + "items": rows, + "total": total, + "limit": limit, + "offset": offset, + } + except Exception as e: + logger.error("❌ Error listing ordre draft sync events: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to list sync events") + + @router.patch("/ordre/drafts/{draft_id}") async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): """Update existing ordre draft.""" diff --git a/app/modules/orders/templates/detail.html b/app/modules/orders/templates/detail.html index 5f6b0dc..9d5df98 100644 --- a/app/modules/orders/templates/detail.html +++ b/app/modules/orders/templates/detail.html @@ -49,6 +49,30 @@ color: white; background: var(--accent); } + .sync-card { + background: var(--bg-card); + border-radius: 12px; + padding: 1rem 1.25rem; + border: 1px solid rgba(0,0,0,0.06); + margin-bottom: 1rem; + } + .sync-label { + font-size: 0.8rem; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.5px; + margin-bottom: 0.25rem; + } + .sync-value { + font-weight: 600; + color: var(--text-primary); + } + .event-payload { + max-width: 360px; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.8rem; + } {% endblock %} @@ -72,6 +96,13 @@ Safety mode aktiv: e-conomic eksport er read-only eller dry-run. +
+
+ + +
+
+
@@ -114,6 +145,120 @@
Sidst opdateret
-
+
+
+
+
Sync Lifecycle
+
Manuel statusstyring og audit events for denne ordre
+
+
+ + +
+
+ +
+
+
Sync status
+ +
+
+
e-conomic ordre nr.
+ +
+
+
e-conomic faktura nr.
+ +
+
+ +
+
+ +
+
+
Aktuel status
+
-
+
+
+
Sidste sync
+
-
+
+
+
Ordrenummer
+
-
+
+
+
Fakturanummer
+
-
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + + +
TidspunktTypeFraTilPayload
Indlæser events...
+
+
+
-
+
+ + +
+
+
+
@@ -138,6 +283,15 @@
+ +
+ +
{% endblock %} @@ -146,6 +300,27 @@ const draftId = {{ draft_id }}; let orderData = null; let orderLines = []; + const syncEventsLimit = 10; + let syncEventsOffset = 0; + let syncEventsTotal = 0; + let detailToast = null; + + function showToast(message, variant = 'dark') { + const toastEl = document.getElementById('detailToast'); + const bodyEl = document.getElementById('detailToastBody'); + if (!toastEl || !bodyEl || typeof bootstrap === 'undefined') { + console.log(message); + return; + } + + toastEl.className = 'toast align-items-center border-0'; + toastEl.classList.add(`text-bg-${variant}`); + bodyEl.textContent = message; + if (!detailToast) { + detailToast = new bootstrap.Toast(toastEl, { delay: 3200 }); + } + detailToast.show(); + } function formatCurrency(value) { return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0)); @@ -164,6 +339,35 @@ return 'Salg'; } + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function syncStatusBadge(status) { + const normalized = String(status || 'pending').toLowerCase(); + if (normalized === 'paid') return 'paid'; + if (normalized === 'posted') return 'posted'; + if (normalized === 'exported') return 'exported'; + if (normalized === 'failed') return 'failed'; + return 'pending'; + } + + function refreshSyncPanelFromOrder() { + document.getElementById('syncStatusSelect').value = (orderData.sync_status || 'pending').toLowerCase(); + document.getElementById('economicOrderNumber').value = orderData.economic_order_number || ''; + document.getElementById('economicInvoiceNumber').value = orderData.economic_invoice_number || ''; + + document.getElementById('syncStatusBadge').innerHTML = syncStatusBadge(orderData.sync_status); + document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at); + document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-'; + document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-'; + } + function renderLines() { const tbody = document.getElementById('linesTableBody'); if (!orderLines.length) { @@ -311,10 +515,142 @@ document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at); renderLines(); + refreshSyncPanelFromOrder(); await loadConfig(); + await loadSyncEvents(syncEventsOffset); } catch (error) { console.error(error); - alert(`Fejl: ${error.message}`); + showToast(`Fejl: ${error.message}`, 'danger'); + } + } + + async function updateSyncStatus() { + const payload = { + sync_status: (document.getElementById('syncStatusSelect').value || 'pending').trim().toLowerCase(), + economic_order_number: document.getElementById('economicOrderNumber').value.trim() || null, + economic_invoice_number: document.getElementById('economicInvoiceNumber').value.trim() || null, + }; + + try { + const res = await fetch(`/api/v1/ordre/drafts/${draftId}/sync-status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere sync status'); + + orderData = data; + refreshSyncPanelFromOrder(); + await loadSyncEvents(0); + showToast('Sync status opdateret', 'success'); + } catch (error) { + showToast(`Fejl: ${error.message}`, 'danger'); + } + } + + async function markDraftPaid() { + if (!confirm('Markér denne ordre som betalt (kun hvis status er posted)?')) return; + + try { + const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apply: true, mark_paid_ids: [draftId] }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Kunne ikke markere som betalt'); + + await loadOrder(); + if ((orderData.sync_status || '').toLowerCase() !== 'paid') { + showToast('Ingen statusændring. Ordren skal være i status posted før den kan markeres som paid.', 'warning'); + return; + } + showToast('Ordre markeret som betalt', 'success'); + } catch (error) { + showToast(`Fejl: ${error.message}`, 'danger'); + } + } + + function buildEventsQuery(offset) { + const params = new URLSearchParams(); + params.set('limit', String(syncEventsLimit)); + params.set('offset', String(Math.max(0, offset || 0))); + + const eventType = document.getElementById('eventTypeFilter').value.trim(); + const fromStatus = document.getElementById('fromStatusFilter').value.trim(); + const toStatus = document.getElementById('toStatusFilter').value.trim(); + const fromDate = document.getElementById('fromDateFilter').value; + const toDate = document.getElementById('toDateFilter').value; + + if (eventType) params.set('event_type', eventType); + if (fromStatus) params.set('from_status', fromStatus); + if (toStatus) params.set('to_status', toStatus); + if (fromDate) params.set('from_date', fromDate); + if (toDate) params.set('to_date', toDate); + + return params.toString(); + } + + function renderSyncEvents(items) { + const body = document.getElementById('syncEventsBody'); + if (!Array.isArray(items) || items.length === 0) { + body.innerHTML = 'Ingen events fundet'; + return; + } + + body.innerHTML = items.map((event) => { + const payload = typeof event.event_payload === 'object' + ? JSON.stringify(event.event_payload, null, 2) + : String(event.event_payload || ''); + + return ` + + ${formatDate(event.created_at)} + ${escapeHtml(event.event_type || '-')} + ${escapeHtml(event.from_status || '-')} + ${escapeHtml(event.to_status || '-')} +
${escapeHtml(payload)}
+ + `; + }).join(''); + } + + function updateEventsPager() { + const start = syncEventsTotal === 0 ? 0 : syncEventsOffset + 1; + const end = Math.min(syncEventsOffset + syncEventsLimit, syncEventsTotal); + document.getElementById('syncEventsMeta').textContent = `Viser ${start}-${end} af ${syncEventsTotal}`; + + document.getElementById('eventsPrevBtn').disabled = syncEventsOffset <= 0; + document.getElementById('eventsNextBtn').disabled = syncEventsOffset + syncEventsLimit >= syncEventsTotal; + } + + function changeEventsPage(delta) { + const nextOffset = syncEventsOffset + (delta * syncEventsLimit); + if (nextOffset < 0 || nextOffset >= syncEventsTotal) { + return; + } + loadSyncEvents(nextOffset); + } + + async function loadSyncEvents(offset = 0) { + syncEventsOffset = Math.max(0, offset); + const body = document.getElementById('syncEventsBody'); + body.innerHTML = 'Indlæser events...'; + + try { + const query = buildEventsQuery(syncEventsOffset); + const res = await fetch(`/api/v1/ordre/drafts/${draftId}/sync-events?${query}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente sync events'); + + syncEventsTotal = Number(data.total || 0); + renderSyncEvents(data.items || []); + updateEventsPager(); + } catch (error) { + body.innerHTML = `${escapeHtml(error.message)}`; + syncEventsTotal = 0; + updateEventsPager(); } } @@ -352,22 +688,22 @@ const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme ordre'); - alert('Ordre gemt'); + showToast('Ordre gemt', 'success'); await loadOrder(); } catch (err) { - alert(`Kunne ikke gemme ordre: ${err.message}`); + showToast(`Kunne ikke gemme ordre: ${err.message}`, 'danger'); } } async function exportOrder() { const customerId = Number(document.getElementById('customerId').value || 0); if (!customerId) { - alert('Angiv kunde ID før eksport'); + showToast('Angiv kunde ID før eksport', 'warning'); return; } if (!orderLines.length) { - alert('Ingen linjer at eksportere'); + showToast('Ingen linjer at eksportere', 'warning'); return; } @@ -388,6 +724,7 @@ notes: document.getElementById('orderNotes').value || null, layout_number: Number(document.getElementById('layoutNumber').value || 0) || null, draft_id: draftId, + force_export: document.getElementById('forceExportToggle').checked, }; try { @@ -401,11 +738,11 @@ throw new Error(data.detail || 'Eksport fejlede'); } - alert(data.message || 'Eksport udført'); + showToast(data.message || 'Eksport udført', data.dry_run ? 'warning' : 'success'); await loadOrder(); } catch (err) { console.error(err); - alert(`Eksport fejlede: ${err.message}`); + showToast(`Eksport fejlede: ${err.message}`, 'danger'); } } diff --git a/app/modules/orders/templates/list.html b/app/modules/orders/templates/list.html index e9ad80c..8a87213 100644 --- a/app/modules/orders/templates/list.html +++ b/app/modules/orders/templates/list.html @@ -36,6 +36,31 @@ .order-row:hover { background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.05); } + .sync-actions { + display: flex; + align-items: center; + gap: 0.35rem; + } + .sync-actions .form-select { + min-width: 128px; + } + .latest-event { + max-width: 210px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .selected-counter { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--accent); + color: var(--accent); + background: rgba(var(--accent-rgb, 15, 76, 117), 0.08); + font-size: 0.85rem; + font-weight: 600; + } {% endblock %} @@ -48,6 +73,16 @@
Opret ny ordre + + Valgte: 0 +
@@ -65,6 +100,7 @@ + @@ -72,23 +108,53 @@ + + - +
Ordre # Titel KundeOprettet Sidst opdateret Sidst eksporteretSeneste event StatusSync Handlinger
Indlæser...
Indlæser...
+ +
+ +
{% endblock %} {% block extra_js %} @@ -6597,12 +7280,6 @@ { action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' } ]; - function isCaseAddActionActive(actionConfig) { - if (!actionConfig.moduleKey) return true; - if (actionConfig.moduleKey === 'time') return true; - return modulePrefs[actionConfig.moduleKey] !== false; - } - async function openCaseModuleAddPanel() { if (typeof loadModulePrefs === 'function') { await loadModulePrefs(); @@ -6610,13 +7287,11 @@ const panel = document.getElementById('caseAddSidePanel'); const backdrop = document.getElementById('caseAddSideBackdrop'); - const reopen = document.getElementById('caseAddSideReopen'); - if (!panel || !backdrop || !reopen) return; + if (!panel || !backdrop) return; backdrop.classList.add('open'); panel.classList.add('open'); panel.setAttribute('aria-hidden', 'false'); - reopen.classList.remove('show'); if (!caseAddOriginalShowRelModal && typeof window._showRelModal === 'function') { caseAddOriginalShowRelModal = window._showRelModal; @@ -6625,22 +7300,18 @@ window._showRelModal = renderCaseAddWorkspaceModal; } - if (!caseAddPanelInitialized) { - renderCaseAddActionList(); - caseAddPanelInitialized = true; - } + renderCaseAddActionList(caseAddActiveAction); + caseAddPanelInitialized = true; } function closeCaseModuleAddPanel() { const panel = document.getElementById('caseAddSidePanel'); const backdrop = document.getElementById('caseAddSideBackdrop'); - const reopen = document.getElementById('caseAddSideReopen'); - if (!panel || !backdrop || !reopen) return; + if (!panel || !backdrop) return; panel.classList.remove('open'); panel.setAttribute('aria-hidden', 'true'); backdrop.classList.remove('open'); - reopen.classList.add('show'); if (typeof caseAddOriginalShowRelModal === 'function') { window._showRelModal = caseAddOriginalShowRelModal; @@ -6673,23 +7344,46 @@ }); } - function renderCaseAddActionList() { + function _isCaseAddModuleEnabled(actionConfig) { + if (!actionConfig?.moduleKey) return true; + if (actionConfig.moduleKey === 'time') return true; + return modulePrefs[actionConfig.moduleKey] !== false; + } + + function _renderCaseAddModuleToggle(actionConfig) { + if (!actionConfig?.moduleKey) { + return ''; + } + + const isTimeModule = actionConfig.moduleKey === 'time'; + const isChecked = _isCaseAddModuleEnabled(actionConfig); + return ``; + } + + function renderCaseAddActionList(preferredAction = null) { const listEl = document.getElementById('caseAddModuleList'); if (!listEl) return; - const actions = CASE_ADD_ACTIONS.filter((cfg) => isCaseAddActionActive(cfg)); + const actions = CASE_ADD_ACTIONS; if (!actions.length) { listEl.innerHTML = '
Ingen aktive moduler fundet.
'; return; } listEl.innerHTML = actions.map((cfg) => ` - +
+ + ${_renderCaseAddModuleToggle(cfg)} +
`).join(''); - openCaseAddAction(actions[0].action); + const fallbackAction = actions[0]?.action || null; + const nextAction = actions.some((cfg) => cfg.action === preferredAction) ? preferredAction : fallbackAction; + if (nextAction) { + openCaseAddAction(nextAction); + } } async function openCaseAddAction(actionName) { @@ -7868,7 +8562,10 @@ // ---------------- EMAILS ---------------- let linkedEmailsCache = []; + let filteredLinkedEmailsCache = []; let selectedLinkedEmailId = null; + let selectedLinkedEmailDetail = null; + let selectedEmailThreadKey = null; function parseEmailField(value) { return String(value || '') @@ -7925,6 +8622,41 @@ } } + function openReplyToLinkedEmail() { + const composeModalEl = document.getElementById('caseEmailComposeModal'); + if (!composeModalEl || !selectedLinkedEmailId || !selectedLinkedEmailDetail) { + return; + } + + const toInput = document.getElementById('caseEmailTo'); + const subjectInput = document.getElementById('caseEmailSubject'); + const bodyInput = document.getElementById('caseEmailBody'); + + const senderEmail = (selectedLinkedEmailDetail.sender_email || '').trim(); + const originalSubject = (selectedLinkedEmailDetail.subject || '').trim(); + + if (toInput && !toInput.value.trim() && senderEmail) { + toInput.value = senderEmail; + } + + if (subjectInput && !subjectInput.value.trim()) { + const replySubject = /^re:\s*/i.test(originalSubject) + ? originalSubject + : `Re: ${originalSubject || `Sag #${caseIds}`}`; + subjectInput.value = escapeHtmlForInput(replySubject); + } + + if (bodyInput && !bodyInput.value.trim()) { + const received = selectedLinkedEmailDetail.received_date + ? new Date(selectedLinkedEmailDetail.received_date).toLocaleString('da-DK') + : '-'; + const senderName = selectedLinkedEmailDetail.sender_name || senderEmail || 'Ukendt'; + bodyInput.value = `\n\n---\nFra: ${senderName}\nDato: ${received}\nEmne: ${originalSubject || '(Ingen emne)'}\n`; + } + + bootstrap.Modal.getOrCreateInstance(composeModalEl).show(); + } + async function sendCaseEmail() { const toInput = document.getElementById('caseEmailTo'); const ccInput = document.getElementById('caseEmailCc'); @@ -7979,16 +8711,29 @@ body_text: bodyText, attachment_file_ids: attachmentFileIds, thread_email_id: selectedLinkedEmailId || null, - thread_key: linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key || null + thread_key: ( + linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key + || linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key + || null + ) }) }); if (!res.ok) { - let message = 'Kunne ikke sende e-mail.'; + let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`; try { - const err = await res.json(); - if (err?.detail) { - message = err.detail; + const responseText = await res.text(); + if (responseText) { + try { + const err = JSON.parse(responseText); + if (err?.detail) { + message = err.detail; + } else if (err?.message) { + message = err.message; + } + } catch (_) { + message = responseText.slice(0, 500); + } } } catch (_) { } @@ -8016,7 +8761,7 @@ } } catch (error) { statusEl.className = 'text-danger'; - statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.'; + statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)'; } finally { sendBtn.disabled = false; } @@ -8029,40 +8774,50 @@ instance.show(); } + window.quickReplyToEmailFromComment = async function(emailId) { + const parsedId = Number(emailId); + if (!Number.isFinite(parsedId)) return; + + openCaseEmailTab(); + + try { + await loadLinkedEmails(); + await loadLinkedEmailDetail(parsedId); + openReplyToLinkedEmail(); + } catch (error) { + console.error('Kunne ikke starte quick svar fra kommentar:', error); + } + } + async function loadLinkedEmails() { const container = document.getElementById('linked-emails-list'); - if(!container) return; + const threadContainer = document.getElementById('email-threads-list'); + if (!container || !threadContainer) return; try { const res = await fetch(`/api/v1/sag/${caseIds}/email-links`); if(res.ok) { linkedEmailsCache = await res.json(); - applyLinkedEmailFilters(); - if (selectedLinkedEmailId && linkedEmailsCache.some(e => Number(e.id) === Number(selectedLinkedEmailId))) { - await loadLinkedEmailDetail(selectedLinkedEmailId); - } else if (linkedEmailsCache.length > 0) { - await loadLinkedEmailDetail(linkedEmailsCache[0].id); - } else { - selectedLinkedEmailId = null; - renderEmailPreviewEmpty(); - } + await applyLinkedEmailFilters(true); } else { container.innerHTML = '
Fejl ved hentning af emails
'; + threadContainer.innerHTML = '
Fejl ved hentning af tråde
'; setModuleContentState('emails', true); } } catch(e) { console.error(e); container.innerHTML = '
Fejl ved hentning af emails
'; + threadContainer.innerHTML = '
Fejl ved hentning af tråde
'; setModuleContentState('emails', true); } } - function applyLinkedEmailFilters() { + function getFilteredLinkedEmails() { const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase(); const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all'; const readFilter = document.getElementById('emailReadFilter')?.value || 'all'; - const filtered = linkedEmailsCache.filter((email) => { + return linkedEmailsCache.filter((email) => { if (textFilter) { const haystack = [ email.subject, @@ -8084,10 +8839,176 @@ return true; }); + } - renderLinkedEmails(filtered); - const counter = document.getElementById('linkedEmailsCount'); - if (counter) counter.textContent = String(filtered.length); + function getThreadKey(email) { + return (email?.resolved_thread_key || email?.thread_key || `email-${email?.id || 'unknown'}`).toString(); + } + + function isOutgoingEmail(email) { + if (typeof email?.is_outgoing === 'boolean') { + return email.is_outgoing; + } + const folder = (email?.folder || '').toString().toLowerCase(); + const status = (email?.status || '').toString().toLowerCase(); + return folder.startsWith('sent') || status === 'sent'; + } + + function buildThreadGroups(emails) { + const map = new Map(); + + emails.forEach((email) => { + const threadKey = getThreadKey(email); + const existing = map.get(threadKey); + const receivedDateMs = email.received_date ? new Date(email.received_date).getTime() : 0; + + if (!existing) { + map.set(threadKey, { + threadKey, + lastDateMs: receivedDateMs, + latestEmail: email, + emails: [email] + }); + return; + } + + existing.emails.push(email); + if (receivedDateMs > existing.lastDateMs) { + existing.lastDateMs = receivedDateMs; + existing.latestEmail = email; + } + }); + + return Array.from(map.values()) + .map((group) => { + group.emails.sort((a, b) => { + const aDate = a.received_date ? new Date(a.received_date).getTime() : 0; + const bDate = b.received_date ? new Date(b.received_date).getTime() : 0; + return bDate - aDate; + }); + return group; + }) + .sort((a, b) => b.lastDateMs - a.lastDateMs); + } + + function getCurrentThreadEmails() { + if (!selectedEmailThreadKey) return []; + return filteredLinkedEmailsCache + .filter((email) => getThreadKey(email) === selectedEmailThreadKey) + .sort((a, b) => { + const aDate = a.received_date ? new Date(a.received_date).getTime() : 0; + const bDate = b.received_date ? new Date(b.received_date).getTime() : 0; + return bDate - aDate; + }); + } + + function renderEmailThreads(threadGroups) { + const container = document.getElementById('email-threads-list'); + if (!container) return; + + if (!threadGroups.length) { + container.innerHTML = '
Ingen tråde fundet...
'; + const counter = document.getElementById('linkedEmailThreadsCount'); + if (counter) counter.textContent = '0'; + return; + } + + const counter = document.getElementById('linkedEmailThreadsCount'); + if (counter) counter.textContent = String(threadGroups.length); + + container.innerHTML = threadGroups.map((group) => { + const latest = group.latestEmail || {}; + const isSelected = selectedEmailThreadKey === group.threadKey; + const receivedDate = latest.received_date ? new Date(latest.received_date).toLocaleString('da-DK') : '-'; + const sender = latest.sender_name || latest.sender_email || '-'; + const subject = latest.subject || '(Ingen emne)'; + const unreadCount = group.emails.filter((item) => !item.is_read).length; + + return ` + + `; + }).join(''); + } + + function selectEmailThread(threadKey) { + selectedEmailThreadKey = String(threadKey || ''); + + const threadGroups = buildThreadGroups(filteredLinkedEmailsCache); + renderEmailThreads(threadGroups); + + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + + if (!threadEmails.length) { + selectedLinkedEmailId = null; + renderEmailPreviewEmpty(); + return; + } + + const hasCurrentSelected = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId)); + if (!hasCurrentSelected) { + selectedLinkedEmailId = Number(threadEmails[0].id); + } + + loadLinkedEmailDetail(selectedLinkedEmailId, true); + } + + async function applyLinkedEmailFilters(loadDetail = false) { + filteredLinkedEmailsCache = getFilteredLinkedEmails(); + const threadGroups = buildThreadGroups(filteredLinkedEmailsCache); + + renderEmailThreads(threadGroups); + + if (!threadGroups.length) { + selectedEmailThreadKey = null; + selectedLinkedEmailId = null; + renderLinkedEmails([]); + const threadCounter = document.getElementById('threadEmailsCount'); + if (threadCounter) threadCounter.textContent = '0'; + renderEmailPreviewEmpty(); + setModuleContentState('emails', false); + return; + } + + const selectedThreadExists = threadGroups.some((group) => group.threadKey === selectedEmailThreadKey); + if (!selectedThreadExists) { + selectedEmailThreadKey = threadGroups[0].threadKey; + } + + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + + const threadCounter = document.getElementById('threadEmailsCount'); + if (threadCounter) threadCounter.textContent = String(threadEmails.length); + + if (!threadEmails.length) { + selectedLinkedEmailId = null; + renderEmailPreviewEmpty(); + return; + } + + const selectedEmailExists = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId)); + if (!selectedEmailExists) { + selectedLinkedEmailId = Number(threadEmails[0].id); + } + + if (loadDetail && selectedLinkedEmailId) { + await loadLinkedEmailDetail(selectedLinkedEmailId, true); + } + + setModuleContentState('emails', true); } function renderLinkedEmails(emails) { @@ -8095,15 +9016,15 @@ if (!container) return; if(!emails || emails.length === 0) { container.innerHTML = '
Ingen linkede e-mails...
'; - setModuleContentState('emails', false); return; } - setModuleContentState('emails', true); + container.innerHTML = emails.map(e => { const isSelected = Number(selectedLinkedEmailId) === Number(e.id); const receivedDate = e.received_date ? new Date(e.received_date).toLocaleString('da-DK') : '-'; const sender = e.sender_name || e.sender_email || '-'; const subject = e.subject || '(Ingen emne)'; + const isOutgoing = isOutgoingEmail(e); const snippetSource = e.body_text || e.body_html || ''; const snippet = snippetSource.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 130); const hasAttachments = Boolean(e.has_attachments) || Number(e.attachment_count || 0) > 0; @@ -8114,6 +9035,10 @@
${escapeHtml(subject)}
${escapeHtml(sender)}
+
${isOutgoing + ? 'Udgående' + : 'Indgående' + }
${escapeHtml(snippet || 'Ingen preview')}
@@ -8133,6 +9058,7 @@ function renderEmailPreviewEmpty() { const panel = document.getElementById('email-preview-panel'); if (!panel) return; + selectedLinkedEmailDetail = null; panel.innerHTML = `
Vælg en e-mail i listen for at se indhold og vedhæftninger @@ -8140,7 +9066,7 @@ `; } - async function loadLinkedEmailDetail(emailId) { + async function loadLinkedEmailDetail(emailId, skipRefresh = false) { selectedLinkedEmailId = Number(emailId); const panel = document.getElementById('email-preview-panel'); if (!panel) return; @@ -8152,23 +9078,10 @@
`; - renderLinkedEmails(linkedEmailsCache.filter((email) => { - const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase(); - const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all'; - const readFilter = document.getElementById('emailReadFilter')?.value || 'all'; - - if (textFilter) { - const haystack = [email.subject, email.sender_email, email.sender_name, email.body_text, email.body_html].join(' ').toLowerCase(); - if (!haystack.includes(textFilter)) return false; - } - const hasAttachments = Boolean(email.has_attachments) || Number(email.attachment_count || 0) > 0; - if (attachmentFilter === 'with' && !hasAttachments) return false; - if (attachmentFilter === 'without' && hasAttachments) return false; - const isRead = Boolean(email.is_read); - if (readFilter === 'read' && !isRead) return false; - if (readFilter === 'unread' && isRead) return false; - return true; - })); + if (!skipRefresh) { + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + } try { const res = await fetch(`/api/v1/emails/${emailId}`); @@ -8184,12 +9097,18 @@ const attachments = Array.isArray(email.attachments) ? email.attachments : []; const bodyText = email.body_text || ''; const bodyHtml = email.body_html || ''; + selectedLinkedEmailDetail = email; panel.innerHTML = `
${escapeHtml(subject)}
Fra: ${escapeHtml(sender)}
Dato: ${escapeHtml(received)}
+
+ +
Vedhæftninger (${attachments.length})
@@ -8217,8 +9136,20 @@ if (cacheIdx >= 0) { linkedEmailsCache[cacheIdx].is_read = true; } + + const filteredIdx = filteredLinkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id)); + if (filteredIdx >= 0) { + filteredLinkedEmailsCache[filteredIdx].is_read = true; + } + + if (!skipRefresh) { + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + renderEmailThreads(buildThreadGroups(filteredLinkedEmailsCache)); + } } catch (e) { console.error(e); + selectedLinkedEmailDetail = null; panel.innerHTML = '
Fejl ved hentning af e-mail detaljer.
'; } } @@ -8266,28 +9197,7 @@ if (!el) return; const eventName = id === 'emailFilterInput' ? 'input' : 'change'; el.addEventListener(eventName, () => { - applyLinkedEmailFilters(); - const visible = linkedEmailsCache.filter((email) => { - const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase(); - const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all'; - const readFilter = document.getElementById('emailReadFilter')?.value || 'all'; - - if (textFilter) { - const haystack = [email.subject, email.sender_email, email.sender_name, email.body_text, email.body_html].join(' ').toLowerCase(); - if (!haystack.includes(textFilter)) return false; - } - const hasAttachments = Boolean(email.has_attachments) || Number(email.attachment_count || 0) > 0; - if (attachmentFilter === 'with' && !hasAttachments) return false; - if (attachmentFilter === 'without' && hasAttachments) return false; - const isRead = Boolean(email.is_read); - if (readFilter === 'read' && !isRead) return false; - if (readFilter === 'unread' && isRead) return false; - return true; - }); - - if (!visible.some((email) => Number(email.id) === Number(selectedLinkedEmailId))) { - renderEmailPreviewEmpty(); - } + applyLinkedEmailFilters(true); }); }); @@ -9641,10 +10551,21 @@ }); if (!res.ok) { - let message = 'Kunne ikke sende e-mail.'; + let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`; try { - const err = await res.json(); - if (err?.detail) message = err.detail; + const responseText = await res.text(); + if (responseText) { + try { + const err = JSON.parse(responseText); + if (err?.detail) { + message = err.detail; + } else if (err?.message) { + message = err.message; + } + } catch (_) { + message = responseText.slice(0, 500); + } + } } catch (_) { } throw new Error(message); @@ -9662,7 +10583,7 @@ if (relModal) relModal.hide(); } catch (error) { statusEl.className = 'small text-danger'; - statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.'; + statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)'; if (typeof showNotification === 'function') showNotification(statusEl.textContent, 'error'); if (saveBtn) { saveBtn.disabled = false; @@ -9864,7 +10785,7 @@ const current = document.getElementById('beskrivelse-text').innerText.trim(); document.getElementById('beskrivelse-textarea').value = current; document.getElementById('beskrivelse-view').classList.add('d-none'); - document.getElementById('beskrivelse-edit-btn').classList.add('d-none'); + document.getElementById('beskrivelse-edit-btn')?.classList.add('d-none'); document.getElementById('beskrivelse-editor').classList.remove('d-none'); document.getElementById('beskrivelse-textarea').focus(); }; @@ -9872,7 +10793,7 @@ window.cancelBeskrivelsEdit = function () { document.getElementById('beskrivelse-editor').classList.add('d-none'); document.getElementById('beskrivelse-view').classList.remove('d-none'); - document.getElementById('beskrivelse-edit-btn').classList.remove('d-none'); + document.getElementById('beskrivelse-edit-btn')?.classList.remove('d-none'); }; window.saveBeskrivelsEdit = async function () { diff --git a/app/prepaid/backend/router.py b/app/prepaid/backend/router.py index f9fa97d..fae5cde 100644 --- a/app/prepaid/backend/router.py +++ b/app/prepaid/backend/router.py @@ -135,7 +135,7 @@ async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[ raise HTTPException(status_code=500, detail=str(e)) -@router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any]) +@router.get("/prepaid-cards/{card_id:int}", response_model=Dict[str, Any]) async def get_prepaid_card(card_id: int): """ Get a specific prepaid card with transactions @@ -321,7 +321,7 @@ async def create_prepaid_card(card: PrepaidCardCreate): raise HTTPException(status_code=500, detail=str(e)) -@router.put("/prepaid-cards/{card_id}/status") +@router.put("/prepaid-cards/{card_id:int}/status") async def update_card_status(card_id: int, status: str): """ Update prepaid card status (cancel, reactivate) @@ -362,7 +362,7 @@ async def update_card_status(card_id: int, status: str): raise HTTPException(status_code=500, detail=str(e)) -@router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any]) +@router.put("/prepaid-cards/{card_id:int}/rounding", response_model=Dict[str, Any]) async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate): """ Update rounding interval for a prepaid card (minutes) @@ -394,7 +394,7 @@ async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate) raise HTTPException(status_code=500, detail=str(e)) -@router.delete("/prepaid-cards/{card_id}") +@router.delete("/prepaid-cards/{card_id:int}") async def delete_prepaid_card(card_id: int): """ Delete a prepaid card (only if no transactions) diff --git a/app/prepaid/backend/views.py b/app/prepaid/backend/views.py index 64e8966..eee6e84 100644 --- a/app/prepaid/backend/views.py +++ b/app/prepaid/backend/views.py @@ -6,7 +6,7 @@ import logging logger = logging.getLogger(__name__) router = APIRouter() -templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend"]) +templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend", "app"]) @router.get("/prepaid-cards", response_class=HTMLResponse) diff --git a/app/products/backend/router.py b/app/products/backend/router.py index 2020f13..38ee908 100644 --- a/app/products/backend/router.py +++ b/app/products/backend/router.py @@ -113,7 +113,9 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s """ match_params = (product_id, supplier_name, supplier_sku) - existing = execute_query_single(match_query, match_params) if match_query else None + existing = None + if match_query and match_params is not None: + existing = execute_query_single(match_query, match_params) if existing: update_query = """ @@ -473,6 +475,9 @@ async def list_products( minimum_term_months, is_bundle, billable, + serial_number_required, + asset_required, + rental_asset_enabled, image_url FROM products {where_clause} @@ -526,6 +531,9 @@ async def create_product(payload: Dict[str, Any]): parent_product_id, bundle_pricing_model, billable, + serial_number_required, + asset_required, + rental_asset_enabled, default_case_tag, default_time_rate_id, category_id, @@ -548,7 +556,8 @@ async def create_product(payload: Dict[str, Any]): %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s ) RETURNING * """ @@ -585,6 +594,9 @@ async def create_product(payload: Dict[str, Any]): payload.get("parent_product_id"), payload.get("bundle_pricing_model"), payload.get("billable", True), + payload.get("serial_number_required", False), + payload.get("asset_required", False), + payload.get("rental_asset_enabled", False), payload.get("default_case_tag"), payload.get("default_time_rate_id"), payload.get("category_id"), @@ -634,7 +646,7 @@ async def update_product( payload: Dict[str, Any], current_user: dict = Depends(require_permission("products.update")) ): - """Update product fields like name.""" + """Update product fields for core metadata and billing validation flags.""" try: name = payload.get("name") if name is not None: @@ -642,21 +654,45 @@ async def update_product( if not name: raise HTTPException(status_code=400, detail="name cannot be empty") + serial_number_required = payload.get("serial_number_required") + asset_required = payload.get("asset_required") + rental_asset_enabled = payload.get("rental_asset_enabled") + existing = execute_query_single( - "SELECT name FROM products WHERE id = %s AND deleted_at IS NULL", + """ + SELECT name, serial_number_required, asset_required, rental_asset_enabled + FROM products + WHERE id = %s AND deleted_at IS NULL + """, (product_id,) ) if not existing: raise HTTPException(status_code=404, detail="Product not found") - query = """ + updates = ["updated_at = CURRENT_TIMESTAMP"] + values: List[Any] = [] + + if name is not None: + updates.append("name = %s") + values.append(name) + if serial_number_required is not None: + updates.append("serial_number_required = %s") + values.append(bool(serial_number_required)) + if asset_required is not None: + updates.append("asset_required = %s") + values.append(bool(asset_required)) + if rental_asset_enabled is not None: + updates.append("rental_asset_enabled = %s") + values.append(bool(rental_asset_enabled)) + + values.append(product_id) + query = f""" UPDATE products - SET name = COALESCE(%s, name), - updated_at = CURRENT_TIMESTAMP + SET {', '.join(updates)} WHERE id = %s AND deleted_at IS NULL RETURNING * """ - result = execute_query(query, (name, product_id)) + result = execute_query(query, tuple(values)) if not result: raise HTTPException(status_code=404, detail="Product not found") if name is not None and name != existing.get("name"): @@ -666,6 +702,15 @@ async def update_product( current_user.get("id") if current_user else None, {"old": existing.get("name"), "new": name} ) + + for field in ("serial_number_required", "asset_required", "rental_asset_enabled"): + if field in payload and payload.get(field) != existing.get(field): + _log_product_audit( + product_id, + f"{field}_updated", + current_user.get("id") if current_user else None, + {"old": existing.get(field), "new": bool(payload.get(field))} + ) return result[0] except HTTPException: raise diff --git a/app/services/economic_service.py b/app/services/economic_service.py index a6462d5..7dda60b 100644 --- a/app/services/economic_service.py +++ b/app/services/economic_service.py @@ -784,7 +784,7 @@ class EconomicService: invoice_date: str, total_amount: float, vat_breakdown: Dict[str, float], - line_items: List[Dict] = None, + line_items: Optional[List[Dict]] = None, due_date: Optional[str] = None, text: Optional[str] = None) -> Dict: """ @@ -983,10 +983,12 @@ class EconomicService: data = await response.json() if response_text else {} # e-conomic returns array of created vouchers - if isinstance(data, list) and len(data) > 0: + if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict): voucher_data = data[0] - else: + elif isinstance(data, dict): voucher_data = data + else: + voucher_data = {} voucher_number = voucher_data.get('voucherNumber') logger.info(f"✅ Supplier invoice posted to kassekladde: voucher #{voucher_number}") @@ -1045,8 +1047,8 @@ class EconomicService: url = f"{self.api_url}/journals/{journal_number}/vouchers/{accounting_year}-{voucher_number}/attachment/file" headers = { - 'X-AppSecretToken': self.app_secret_token, - 'X-AgreementGrantToken': self.agreement_grant_token + 'X-AppSecretToken': str(self.app_secret_token or ''), + 'X-AgreementGrantToken': str(self.agreement_grant_token or '') } # Use multipart/form-data as required by e-conomic API @@ -1070,6 +1072,55 @@ class EconomicService: logger.error(f"❌ upload_voucher_attachment error: {e}") return {"error": True, "message": str(e)} + async def get_invoice_lifecycle_status(self, invoice_number: str) -> str: + """ + Resolve lifecycle status for an invoice number from e-conomic. + + Returns one of: draft, booked, unpaid, paid, not_found, error + """ + invoice_number = str(invoice_number or "").strip() + if not invoice_number: + return "not_found" + + endpoints = [ + ("paid", f"{self.api_url}/invoices/paid"), + ("unpaid", f"{self.api_url}/invoices/unpaid"), + ("booked", f"{self.api_url}/invoices/booked"), + ("draft", f"{self.api_url}/invoices/drafts"), + ] + + try: + async with aiohttp.ClientSession() as session: + for status_name, endpoint in endpoints: + page = 0 + while True: + async with session.get( + endpoint, + params={"pagesize": 1000, "skippages": page}, + headers=self._get_headers(), + ) as response: + if response.status != 200: + break + + data = await response.json() + collection = data.get("collection", []) + if not collection: + break + + for inv in collection: + inv_no = inv.get("draftInvoiceNumber") or inv.get("bookedInvoiceNumber") + if str(inv_no or "") == invoice_number: + return status_name + + if len(collection) < 1000: + break + page += 1 + + return "not_found" + except Exception as e: + logger.error("❌ Error resolving invoice lifecycle status %s: %s", invoice_number, e) + return "error" + # Singleton instance _economic_service_instance = None diff --git a/app/services/email_processor_service.py b/app/services/email_processor_service.py index 6c90f2b..e83dbac 100644 --- a/app/services/email_processor_service.py +++ b/app/services/email_processor_service.py @@ -133,13 +133,20 @@ class EmailProcessorService: classification = (email_data.get('classification') or '').strip().lower() confidence = float(email_data.get('confidence_score') or 0.0) require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True) + has_helpdesk_hint = email_workflow_service.has_helpdesk_routing_hint(email_data) - if require_manual_approval: + if has_helpdesk_hint: + logger.info( + "🧵 Email %s has SAG/thread hint; bypassing manual approval gate for auto-routing", + email_id, + ) + + if require_manual_approval and not has_helpdesk_hint: await self._set_awaiting_user_action(email_id, reason='manual_approval_required') stats['awaiting_user_action'] = True return stats - if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD: + if (not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD) and not has_helpdesk_hint: await self._set_awaiting_user_action(email_id, reason='low_confidence') stats['awaiting_user_action'] = True return stats diff --git a/app/services/email_service.py b/app/services/email_service.py index 21b02aa..c93e839 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -13,11 +13,12 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders -from typing import List, Dict, Optional, Tuple +from typing import List, Dict, Optional, Tuple, Any from datetime import datetime import json import asyncio import base64 +import re from uuid import uuid4 # Try to import aiosmtplib, but don't fail if not available @@ -57,6 +58,186 @@ class EmailService: 'client_secret': settings.GRAPH_CLIENT_SECRET, 'user_email': settings.GRAPH_USER_EMAIL } + + def _graph_send_available(self) -> bool: + return bool( + self.use_graph + and self.graph_config.get('tenant_id') + and self.graph_config.get('client_id') + and self.graph_config.get('client_secret') + and self.graph_config.get('user_email') + ) + + async def _send_via_graph( + self, + to_addresses: List[str], + subject: str, + body_text: str, + body_html: Optional[str] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None, + reply_to: Optional[str] = None, + in_reply_to: Optional[str] = None, + references: Optional[str] = None, + attachments: Optional[List[Dict]] = None, + ) -> Tuple[bool, str, Optional[Dict[str, str]]]: + """Send email via Microsoft Graph sendMail endpoint.""" + + access_token = await self._get_graph_access_token() + if not access_token: + return False, "Graph token acquisition failed", None + + def _recipient(addr: str) -> Dict: + return {"emailAddress": {"address": addr}} + + message: Dict = { + "subject": subject, + "body": { + "contentType": "HTML" if body_html else "Text", + "content": body_html or body_text, + }, + "toRecipients": [_recipient(addr) for addr in (to_addresses or [])], + } + + if cc: + message["ccRecipients"] = [_recipient(addr) for addr in cc] + if bcc: + message["bccRecipients"] = [_recipient(addr) for addr in bcc] + if reply_to: + message["replyTo"] = [_recipient(reply_to)] + + # Microsoft Graph only allows custom internet headers prefixed with x-. + # Standard headers like In-Reply-To/References are rejected with + # InvalidInternetMessageHeader, so only attach safe diagnostic metadata. + headers = [] + if in_reply_to: + headers.append({"name": "x-bmc-in-reply-to", "value": in_reply_to[:900]}) + if references: + headers.append({"name": "x-bmc-references", "value": references[:900]}) + if headers: + message["internetMessageHeaders"] = headers + + graph_attachments = [] + for attachment in (attachments or []): + content = attachment.get("content") + if not content: + continue + graph_attachments.append({ + "@odata.type": "#microsoft.graph.fileAttachment", + "name": attachment.get("filename") or "attachment.bin", + "contentType": attachment.get("content_type") or "application/octet-stream", + "contentBytes": base64.b64encode(content).decode("ascii"), + }) + if graph_attachments: + message["attachments"] = graph_attachments + + url = f"https://graph.microsoft.com/v1.0/users/{self.graph_config['user_email']}/sendMail" + request_body = { + "message": message, + "saveToSentItems": True, + } + request_headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + try: + async with ClientSession() as session: + async with session.post(url, headers=request_headers, json=request_body) as response: + if response.status in (200, 202): + metadata = None + try: + metadata = await self._find_recent_sent_graph_message( + access_token=access_token, + subject=subject, + to_addresses=to_addresses, + ) + except Exception as metadata_error: + logger.warning( + "⚠️ Graph send succeeded but SentItems metadata lookup failed: %s", + metadata_error, + ) + return True, f"Email sent to {len(to_addresses)} recipient(s) via Graph", metadata + + error_text = await response.text() + logger.error("❌ Graph send failed: status=%s body=%s", response.status, error_text) + return False, f"Graph send failed ({response.status}): {error_text[:300]}", None + except Exception as e: + return False, f"Graph send exception: {str(e)}", None + + def _recipient_addresses_match(self, graph_recipients: Optional[List[Dict[str, Any]]], to_addresses: List[str]) -> bool: + if not to_addresses: + return True + + expected = {addr.strip().lower() for addr in (to_addresses or []) if addr} + if not expected: + return True + + actual = set() + for recipient in graph_recipients or []: + address = ( + recipient.get("emailAddress", {}).get("address") + if isinstance(recipient, dict) + else None + ) + if address: + actual.add(str(address).strip().lower()) + + return bool(actual) and expected.issubset(actual) + + async def _find_recent_sent_graph_message( + self, + access_token: str, + subject: str, + to_addresses: List[str], + ) -> Optional[Dict[str, str]]: + """Best-effort lookup for the most recent sent Graph message metadata.""" + try: + user_email = self.graph_config['user_email'] + url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/SentItems/messages" + params = { + '$top': 15, + '$orderby': 'sentDateTime desc', + '$select': 'id,subject,toRecipients,internetMessageId,conversationId,sentDateTime' + } + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + } + + async with ClientSession() as session: + async with session.get(url, params=params, headers=headers) as response: + if response.status != 200: + logger.warning("⚠️ Could not read SentItems metadata (status=%s)", response.status) + return None + + payload = await response.json() + messages = payload.get('value') or [] + normalized_subject = (subject or '').strip().lower() + + for msg in messages: + candidate_subject = str(msg.get('subject') or '').strip().lower() + if normalized_subject and candidate_subject != normalized_subject: + continue + if not self._recipient_addresses_match(msg.get('toRecipients'), to_addresses): + continue + + internet_message_id = self._normalize_message_id_value(msg.get('internetMessageId')) + conversation_id = self._normalize_message_id_value(msg.get('conversationId')) + if internet_message_id or conversation_id: + logger.info( + "🧵 Matched sent Graph metadata (conversationId=%s, messageId=%s)", + conversation_id, + internet_message_id, + ) + return { + 'internet_message_id': internet_message_id, + 'conversation_id': conversation_id, + } + except Exception as e: + logger.warning("⚠️ Failed to resolve sent Graph metadata: %s", e) + + return None async def fetch_new_emails(self, limit: int = 50) -> List[Dict]: """ @@ -172,7 +353,7 @@ class EmailService: params = { '$top': limit, '$orderby': 'receivedDateTime desc', - '$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId' + '$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders' } headers = { @@ -398,10 +579,26 @@ class EmailService: received_date_str = msg.get('receivedDateTime', '') received_date = datetime.fromisoformat(received_date_str.replace('Z', '+00:00')) if received_date_str else datetime.now() + headers = msg.get('internetMessageHeaders') or [] + in_reply_to = None + references = None + for header in headers: + name = str(header.get('name') or '').strip().lower() + value = str(header.get('value') or '').strip() + if not value: + continue + if name == 'in-reply-to': + in_reply_to = value + elif name == 'references': + references = value + + conversation_id = self._normalize_message_id_value(msg.get('conversationId')) + return { 'message_id': msg.get('internetMessageId', msg.get('id', '')), - 'in_reply_to': None, - 'email_references': None, + 'in_reply_to': in_reply_to, + 'email_references': references, + 'thread_key': conversation_id, 'subject': msg.get('subject', ''), 'sender_name': sender_name, 'sender_email': sender_email, @@ -509,6 +706,46 @@ class EmailService: else: # Just email address return ("", header.strip()) + + def _normalize_message_id_value(self, value: Optional[str]) -> Optional[str]: + """Normalize message-id like tokens for stable thread matching.""" + if not value: + return None + normalized = str(value).strip().strip("<>").lower() + normalized = "".join(normalized.split()) + return normalized or None + + def _extract_reference_ids(self, raw_references: Optional[str]) -> List[str]: + if not raw_references: + return [] + refs: List[str] = [] + for token in re.split(r"[\s,]+", str(raw_references).strip()): + normalized = self._normalize_message_id_value(token) + if normalized: + refs.append(normalized) + return list(dict.fromkeys(refs)) + + def _derive_thread_key(self, email_data: Dict) -> Optional[str]: + """ + Derive a stable conversation key. + Priority: + 1) First References token (root message id) + 2) In-Reply-To + 3) Message-ID + """ + explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key")) + if explicit_thread_key: + return explicit_thread_key + + reference_ids = self._extract_reference_ids(email_data.get("email_references")) + if reference_ids: + return reference_ids[0] + + in_reply_to = self._normalize_message_id_value(email_data.get("in_reply_to")) + if in_reply_to: + return in_reply_to + + return self._normalize_message_id_value(email_data.get("message_id")) def _parse_email_date(self, date_str: str) -> datetime: """Parse email date header into datetime object""" @@ -532,32 +769,62 @@ class EmailService: async def save_email(self, email_data: Dict) -> Optional[int]: """Save email to database""" try: - query = """ - INSERT INTO email_messages - (message_id, subject, sender_email, sender_name, recipient_email, cc, - body_text, body_html, received_date, folder, has_attachments, attachment_count, - in_reply_to, email_references, - status, is_read) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false) - RETURNING id - """ - - email_id = execute_insert(query, ( - email_data['message_id'], - email_data['subject'], - email_data['sender_email'], - email_data['sender_name'], - email_data['recipient_email'], - email_data['cc'], - email_data['body_text'], - email_data['body_html'], - email_data['received_date'], - email_data['folder'], - email_data['has_attachments'], - email_data['attachment_count'], - email_data.get('in_reply_to'), - email_data.get('email_references') - )) + thread_key = self._derive_thread_key(email_data) + + try: + query = """ + INSERT INTO email_messages + (message_id, subject, sender_email, sender_name, recipient_email, cc, + body_text, body_html, received_date, folder, has_attachments, attachment_count, + in_reply_to, email_references, thread_key, + status, is_read) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false) + RETURNING id + """ + email_id = execute_insert(query, ( + email_data['message_id'], + email_data['subject'], + email_data['sender_email'], + email_data['sender_name'], + email_data['recipient_email'], + email_data['cc'], + email_data['body_text'], + email_data['body_html'], + email_data['received_date'], + email_data['folder'], + email_data['has_attachments'], + email_data['attachment_count'], + email_data.get('in_reply_to'), + email_data.get('email_references'), + thread_key, + )) + except Exception: + query = """ + INSERT INTO email_messages + (message_id, subject, sender_email, sender_name, recipient_email, cc, + body_text, body_html, received_date, folder, has_attachments, attachment_count, + in_reply_to, email_references, + status, is_read) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false) + RETURNING id + """ + + email_id = execute_insert(query, ( + email_data['message_id'], + email_data['subject'], + email_data['sender_email'], + email_data['sender_name'], + email_data['recipient_email'], + email_data['cc'], + email_data['body_text'], + email_data['body_html'], + email_data['received_date'], + email_data['folder'], + email_data['has_attachments'], + email_data['attachment_count'], + email_data.get('in_reply_to'), + email_data.get('email_references') + )) logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...") @@ -879,36 +1146,70 @@ class EmailService: return None # Insert email - query = """ - INSERT INTO email_messages ( - message_id, subject, sender_email, sender_name, - recipient_email, cc, body_text, body_html, - received_date, folder, has_attachments, attachment_count, - in_reply_to, email_references, - status, import_method, created_at - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) - RETURNING id - """ - - result = execute_insert(query, ( - email_data["message_id"], - email_data["subject"], - email_data["sender_email"], - email_data["sender_name"], - email_data.get("recipient_email", ""), - email_data.get("cc", ""), - email_data["body_text"], - email_data["body_html"], - email_data["received_date"], - email_data["folder"], - email_data["has_attachments"], - len(email_data.get("attachments", [])), - email_data.get("in_reply_to"), - email_data.get("email_references"), - "new", - "manual_upload" - )) + thread_key = self._derive_thread_key(email_data) + try: + query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, body_html, + received_date, folder, has_attachments, attachment_count, + in_reply_to, email_references, thread_key, + status, import_method, created_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + RETURNING id + """ + + result = execute_insert(query, ( + email_data["message_id"], + email_data["subject"], + email_data["sender_email"], + email_data["sender_name"], + email_data.get("recipient_email", ""), + email_data.get("cc", ""), + email_data["body_text"], + email_data["body_html"], + email_data["received_date"], + email_data["folder"], + email_data["has_attachments"], + len(email_data.get("attachments", [])), + email_data.get("in_reply_to"), + email_data.get("email_references"), + thread_key, + "new", + "manual_upload" + )) + except Exception: + query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, body_html, + received_date, folder, has_attachments, attachment_count, + in_reply_to, email_references, + status, import_method, created_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + RETURNING id + """ + + result = execute_insert(query, ( + email_data["message_id"], + email_data["subject"], + email_data["sender_email"], + email_data["sender_name"], + email_data.get("recipient_email", ""), + email_data.get("cc", ""), + email_data["body_text"], + email_data["body_html"], + email_data["received_date"], + email_data["folder"], + email_data["has_attachments"], + len(email_data.get("attachments", [])), + email_data.get("in_reply_to"), + email_data.get("email_references"), + "new", + "manual_upload" + )) if not result: logger.error("❌ Failed to insert email - no ID returned") @@ -958,14 +1259,37 @@ class EmailService: logger.warning(f"🔒 DRY RUN MODE: Would send email to {to_addresses} with subject '{subject}'") return True, "Dry run mode - email not actually sent" + graph_failure_message: Optional[str] = None + + # Prefer Graph send when Graph integration is enabled/configured. + if self._graph_send_available(): + graph_ok, graph_message = await self._send_via_graph( + to_addresses=to_addresses, + subject=subject, + body_text=body_text, + body_html=body_html, + cc=cc, + bcc=bcc, + reply_to=reply_to, + ) + if graph_ok: + logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject) + return True, graph_message + graph_failure_message = graph_message + logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message) + # Check if aiosmtplib is available if not HAS_AIOSMTPLIB: logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib") + if graph_failure_message: + return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed" return False, "aiosmtplib not installed" # Validate SMTP configuration if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]): logger.error("❌ SMTP not configured - cannot send email") + if graph_failure_message: + return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured" return False, "SMTP not configured" try: @@ -1013,8 +1337,10 @@ class EmailService: return True, f"Email sent to {len(to_addresses)} recipient(s)" except Exception as e: - error_msg = f"❌ Failed to send email: {str(e)}" + error_msg = f"❌ SMTP send error: {str(e)}" logger.error(error_msg) + if graph_failure_message: + return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}" return False, error_msg async def send_email_with_attachments( @@ -1030,10 +1356,11 @@ class EmailService: references: Optional[str] = None, attachments: Optional[List[Dict]] = None, respect_dry_run: bool = True, - ) -> Tuple[bool, str, str]: - """Send email via SMTP with optional attachments and return generated Message-ID.""" + ) -> Tuple[bool, str, str, Optional[str]]: + """Send email and return status, message, message-id, and optional provider thread key.""" generated_message_id = f"<{uuid4().hex}@bmchub.local>" + provider_thread_key: Optional[str] = None if respect_dry_run and settings.REMINDERS_DRY_RUN: logger.warning( @@ -1041,15 +1368,53 @@ class EmailService: to_addresses, subject, ) - return True, "Dry run mode - email not actually sent", generated_message_id + return True, "Dry run mode - email not actually sent", generated_message_id, provider_thread_key + + graph_failure_message: Optional[str] = None + + # Prefer Graph send when Graph integration is enabled/configured. + if self._graph_send_available(): + graph_ok, graph_message, graph_metadata = await self._send_via_graph( + to_addresses=to_addresses, + subject=subject, + body_text=body_text, + body_html=body_html, + cc=cc, + bcc=bcc, + reply_to=reply_to, + in_reply_to=in_reply_to, + references=references, + attachments=attachments, + ) + if graph_ok: + if graph_metadata: + graph_message_id = graph_metadata.get('internet_message_id') + graph_thread_key = graph_metadata.get('conversation_id') + if graph_message_id: + generated_message_id = graph_message_id + if graph_thread_key: + provider_thread_key = graph_thread_key + logger.info( + "✅ Email with attachments sent via Graph to %s recipient(s): %s (thread_key=%s)", + len(to_addresses), + subject, + provider_thread_key, + ) + return True, graph_message, generated_message_id, provider_thread_key + graph_failure_message = graph_message + logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message) if not HAS_AIOSMTPLIB: logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib") - return False, "aiosmtplib not installed", generated_message_id + if graph_failure_message: + return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key + return False, "aiosmtplib not installed", generated_message_id, provider_thread_key if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]): logger.error("❌ SMTP not configured - cannot send email") - return False, "SMTP not configured", generated_message_id + if graph_failure_message: + return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key + return False, "SMTP not configured", generated_message_id, provider_thread_key try: msg = MIMEMultipart('mixed') @@ -1114,9 +1479,11 @@ class EmailService: len(to_addresses), subject, ) - return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id + return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key except Exception as e: - error_msg = f"❌ Failed to send email with attachments: {str(e)}" + error_msg = f"❌ SMTP send error (attachments): {str(e)}" logger.error(error_msg) - return False, error_msg, generated_message_id + if graph_failure_message: + return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key + return False, error_msg, generated_message_id, provider_thread_key diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py index 023beb2..2c7d48f 100644 --- a/app/services/email_workflow_service.py +++ b/app/services/email_workflow_service.py @@ -53,12 +53,21 @@ class EmailWorkflowService: return {'status': 'disabled', 'workflows_executed': 0} email_id = email_data.get('id') - classification = email_data.get('classification') + classification = (email_data.get('classification') or '').strip().lower() confidence = email_data.get('confidence_score', 0.0) + has_hint = self.has_helpdesk_routing_hint(email_data) - if not email_id or not classification: - logger.warning(f"⚠️ Cannot execute workflows: missing email_id or classification") + if not email_id: + logger.warning("⚠️ Cannot execute workflows: missing email_id") return {'status': 'skipped', 'reason': 'missing_data'} + + if not classification: + if has_hint: + classification = 'general' + email_data['classification'] = classification + else: + logger.warning("⚠️ Cannot execute workflows: missing classification") + return {'status': 'skipped', 'reason': 'missing_data'} logger.info(f"🔄 Finding workflows for classification: {classification} (confidence: {confidence})") @@ -82,9 +91,14 @@ class EmailWorkflowService: logger.info("✅ Bankruptcy system workflow executed successfully") # Special System Workflow: Helpdesk SAG routing - # - If SAG- is present in subject/header => update existing case - # - If no SAG id and sender domain matches customer => create new case - if classification not in self.HELPDESK_SKIP_CLASSIFICATIONS: + # - If SAG/tråd-hint findes => forsøg altid routing til eksisterende sag + # - Uden hints: brug klassifikationsgating som før + should_try_helpdesk = ( + classification not in self.HELPDESK_SKIP_CLASSIFICATIONS + or has_hint + ) + + if should_try_helpdesk: helpdesk_result = await self._handle_helpdesk_sag_routing(email_data) if helpdesk_result: results['details'].append(helpdesk_result) @@ -208,17 +222,48 @@ class EmailWorkflowService: domain = domain[4:] return domain or None + def has_helpdesk_routing_hint(self, email_data: Dict) -> bool: + """Return True when email has explicit routing hints (SAG or thread headers/key).""" + if self._extract_sag_id(email_data): + return True + + explicit_thread_key = self._normalize_message_id(email_data.get('thread_key')) + if explicit_thread_key: + return True + + if self._normalize_message_id(email_data.get('in_reply_to')): + return True + + if self._extract_reference_message_ids(email_data.get('email_references')): + return True + + return False + def _extract_sag_id(self, email_data: Dict) -> Optional[int]: candidates = [ email_data.get('subject') or '', email_data.get('in_reply_to') or '', - email_data.get('email_references') or '' + email_data.get('email_references') or '', + email_data.get('body_text') or '', + email_data.get('body_html') or '', + ] + + # Accept both strict and human variants used in real subjects, e.g.: + # - SAG-53 + # - SAG #53 + # - Sag 53 + sag_patterns = [ + r'\bSAG-(\d+)\b', + r'\bSAG\s*#\s*(\d+)\b', + r'\bSAG\s+(\d+)\b', + r'\bBMCid\s*:\s*s(\d+)t\d+\b', ] for value in candidates: - match = re.search(r'\bSAG-(\d+)\b', value, re.IGNORECASE) - if match: - return int(match.group(1)) + for pattern in sag_patterns: + match = re.search(pattern, value, re.IGNORECASE) + if match: + return int(match.group(1)) return None def _normalize_message_id(self, value: Optional[str]) -> Optional[str]: @@ -244,6 +289,53 @@ class EmailWorkflowService: # De-duplicate while preserving order return list(dict.fromkeys(tokens)) + def _extract_reference_message_ids(self, raw_references: Optional[str]) -> List[str]: + tokens: List[str] = [] + if raw_references: + for ref in re.split(r'[\s,]+', str(raw_references).strip()): + normalized_ref = self._normalize_message_id(ref) + if normalized_ref: + tokens.append(normalized_ref) + return list(dict.fromkeys(tokens)) + + def _derive_thread_key(self, email_data: Dict) -> Optional[str]: + """Derive stable conversation key: root References -> In-Reply-To -> Message-ID.""" + explicit = self._normalize_message_id(email_data.get('thread_key')) + if explicit: + return explicit + + ref_ids = self._extract_reference_message_ids(email_data.get('email_references')) + if ref_ids: + return ref_ids[0] + + in_reply_to = self._normalize_message_id(email_data.get('in_reply_to')) + if in_reply_to: + return in_reply_to + + return self._normalize_message_id(email_data.get('message_id')) + + def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]: + if not thread_key: + return None + + # Backward compatibility when DB migration is not yet applied. + try: + rows = execute_query( + """ + SELECT se.sag_id + FROM sag_emails se + JOIN email_messages em ON em.id = se.email_id + WHERE em.deleted_at IS NULL + AND LOWER(TRIM(COALESCE(em.thread_key, ''))) = %s + ORDER BY se.created_at DESC + LIMIT 1 + """, + (thread_key,) + ) + return rows[0]['sag_id'] if rows else None + except Exception: + return None + def _find_sag_id_from_thread_headers(self, email_data: Dict) -> Optional[int]: thread_message_ids = self._extract_thread_message_ids(email_data) if not thread_message_ids: @@ -297,15 +389,58 @@ class EmailWorkflowService: (sag_id, email_id, sag_id, email_id) ) + def _strip_quoted_email_text(self, body_text: str) -> str: + """Return only the newest reply content (remove quoted history/signatures).""" + if not body_text: + return "" + + text = str(body_text).replace("\r\n", "\n").replace("\r", "\n") + lines = text.split("\n") + cleaned_lines: List[str] = [] + + header_marker_re = re.compile(r'^(fra|from|sent|date|dato|to|til|emne|subject|cc):\s*', re.IGNORECASE) + original_message_re = re.compile(r'^(original message|oprindelig besked|videresendt besked)', re.IGNORECASE) + + for idx, line in enumerate(lines): + stripped = line.strip() + lowered = stripped.lower() + + if stripped.startswith('>'): + break + + if original_message_re.match(stripped): + break + + # Typical separator before quoted headers (e.g. "---" / "_____" lines) + if re.match(r'^[-_]{3,}$', stripped): + lookahead = lines[idx + 1: idx + 4] + if any(header_marker_re.match(candidate.strip()) for candidate in lookahead): + break + + if idx > 0 and header_marker_re.match(stripped): + if lines[idx - 1].strip() == "": + break + + cleaned_lines.append(line) + + while cleaned_lines and cleaned_lines[-1].strip() == "": + cleaned_lines.pop() + + return "\n".join(cleaned_lines).strip() + def _add_helpdesk_comment(self, sag_id: int, email_data: Dict) -> None: + email_id = email_data.get('id') sender = email_data.get('sender_email') or 'ukendt' subject = email_data.get('subject') or '(ingen emne)' received = email_data.get('received_date') received_str = received.isoformat() if hasattr(received, 'isoformat') else str(received or '') - body_text = (email_data.get('body_text') or '').strip() + body_text = self._strip_quoted_email_text((email_data.get('body_text') or '').strip()) + + email_meta_line = f"Email-ID: {email_id}\n" if email_id else "" comment = ( f"📧 Indgående email\n" + f"{email_meta_line}" f"Fra: {sender}\n" f"Emne: {subject}\n" f"Modtaget: {received_str}\n\n" @@ -351,12 +486,45 @@ class EmailWorkflowService: if not email_id: return None - sag_id = self._extract_sag_id(email_data) - if not sag_id: - sag_id = self._find_sag_id_from_thread_headers(email_data) - if sag_id: + derived_thread_key = self._derive_thread_key(email_data) + sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key) + sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data) + sag_id_from_tag = self._extract_sag_id(email_data) + + routing_source = None + sag_id = None + + if sag_id_from_thread_key: + sag_id = sag_id_from_thread_key + routing_source = 'thread_key' + logger.info("🧵 Matched email %s to SAG-%s via thread key", email_id, sag_id) + + if sag_id_from_thread: + if sag_id and sag_id != sag_id_from_thread: + logger.warning( + "⚠️ Email %s has conflicting thread matches (thread_key: SAG-%s, headers: SAG-%s). Using thread_key.", + email_id, + sag_id, + sag_id_from_thread, + ) + elif not sag_id: + sag_id = sag_id_from_thread + routing_source = 'thread_headers' logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id) + if sag_id_from_tag: + if sag_id and sag_id != sag_id_from_tag: + logger.warning( + "⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using thread match.", + email_id, + sag_id, + sag_id_from_tag + ) + elif not sag_id: + sag_id = sag_id_from_tag + routing_source = 'sag_tag' + logger.info("🏷️ Matched email %s to SAG-%s via SAG tag", email_id, sag_id) + # 1) Existing SAG via subject/headers if sag_id: case_rows = execute_query( @@ -390,7 +558,8 @@ class EmailWorkflowService: 'status': 'completed', 'action': 'updated_existing_sag', 'sag_id': sag_id, - 'customer_id': case.get('customer_id') + 'customer_id': case.get('customer_id'), + 'routing_source': routing_source } # 2) No SAG id -> create only if sender domain belongs to known customer @@ -425,7 +594,8 @@ class EmailWorkflowService: 'action': 'created_new_sag', 'sag_id': case['id'], 'customer_id': customer['id'], - 'domain': sender_domain + 'domain': sender_domain, + 'routing_source': 'customer_domain' } async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]: diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py index 4d99884..3843833 100644 --- a/app/settings/backend/router.py +++ b/app/settings/backend/router.py @@ -2,7 +2,7 @@ Settings and User Management API Router """ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from typing import List, Optional, Dict from pydantic import BaseModel from app.core.database import execute_query @@ -15,6 +15,17 @@ import json logger = logging.getLogger(__name__) router = APIRouter() +DEFAULT_EMAIL_SIGNATURE_TEMPLATE = ( + "{full_name}\n" + "{title}\n" + "{company_name}\n" + "Telefon: {company_phone}\n" + "Email: {email}\n" + "Web: {company_website}\n" + "Adresse: {company_address}\n" + "BMCid: {bmc_id_tag}" +) + # Pydantic Models class Setting(BaseModel): @@ -58,6 +69,30 @@ class UserUpdate(BaseModel): @router.get("/settings", response_model=List[Setting], tags=["Settings"]) async def get_settings(category: Optional[str] = None): """Get all settings or filter by category""" + execute_query( + """ + INSERT INTO settings (key, value, category, description, value_type, is_public) + VALUES + (%s, %s, %s, %s, %s, %s), + (%s, %s, %s, %s, %s, %s) + ON CONFLICT (key) DO NOTHING + """, + ( + "email_default_signature_template", + DEFAULT_EMAIL_SIGNATURE_TEMPLATE, + "email", + "Standard signatur skabelon til udgående sagsmails", + "text", + True, + "company_website", + "https://bmcnetworks.dk", + "company", + "Firma website", + "string", + True, + ), + ) + query = "SELECT * FROM settings" params = [] @@ -130,6 +165,24 @@ async def get_setting(key: str): result = execute_query(query, (key,)) + if not result and key == "email_default_signature_template": + execute_query( + """ + INSERT INTO settings (key, value, category, description, value_type, is_public) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (key) DO NOTHING + """, + ( + "email_default_signature_template", + DEFAULT_EMAIL_SIGNATURE_TEMPLATE, + "email", + "Standard signatur skabelon til udgående sagsmails", + "text", + True, + ) + ) + result = execute_query(query, (key,)) + if not result: raise HTTPException(status_code=404, detail="Setting not found") @@ -146,6 +199,27 @@ async def update_setting(key: str, setting: SettingUpdate): RETURNING * """ result = execute_query(query, (setting.value, key)) + + if not result and key == "email_default_signature_template": + result = execute_query( + """ + INSERT INTO settings (key, value, category, description, value_type, is_public) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (key) + DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP + RETURNING * + """, + ( + "email_default_signature_template", + setting.value, + "email", + "Standard signatur skabelon til udgående sagsmails", + "text", + True, + ) + ) if not result: raise HTTPException(status_code=404, detail="Setting not found") @@ -542,6 +616,10 @@ class PromptUpdate(BaseModel): class PromptTestRequest(BaseModel): test_input: Optional[str] = None prompt_text: Optional[str] = None + timeout_seconds: Optional[int] = None + + +_prompt_test_last_call: Dict[str, float] = {} @router.put("/ai-prompts/{key}", tags=["Settings"]) @@ -579,7 +657,7 @@ async def reset_ai_prompt(key: str): @router.post("/ai-prompts/{key}/test", tags=["Settings"]) -async def test_ai_prompt(key: str, payload: PromptTestRequest): +async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Request): """Run a quick AI test for a specific system prompt""" prompts = _get_prompts_with_overrides() if key not in prompts: @@ -597,12 +675,37 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest): raise HTTPException(status_code=400, detail="Test input is empty") start = time.perf_counter() + client_host = (http_request.client.host if http_request.client else "unknown") + + # Cooldown to prevent hammering external endpoints and getting rate-limited/banned. + cooldown_seconds = 2.0 + now_monotonic = time.monotonic() + last_call = _prompt_test_last_call.get(client_host) + if last_call and (now_monotonic - last_call) < cooldown_seconds: + wait_for = round(cooldown_seconds - (now_monotonic - last_call), 2) + raise HTTPException( + status_code=429, + detail=f"For mange tests for hurtigt. Vent {wait_for} sekunder og prøv igen.", + ) + _prompt_test_last_call[client_host] = now_monotonic + + read_timeout_seconds = payload.timeout_seconds or 90 + read_timeout_seconds = max(5, min(int(read_timeout_seconds), 300)) + try: model_normalized = (model or "").strip().lower() # qwen models are more reliable with /api/chat than /api/generate. use_chat_api = model_normalized.startswith("qwen") - timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0) + logger.info( + "🧪 AI prompt test start key=%s model=%s timeout=%ss client=%s", + key, + model, + read_timeout_seconds, + client_host, + ) + + timeout = httpx.Timeout(connect=10.0, read=float(read_timeout_seconds), write=30.0, pool=10.0) async with httpx.AsyncClient(timeout=timeout) as client: if use_chat_api: response = await client.post( @@ -659,6 +762,7 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest): "endpoint": endpoint, "test_input": test_input, "ai_response": ai_response, + "timeout_seconds": read_timeout_seconds, "latency_ms": latency_ms, } diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index 6aa2a6b..1523912 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -348,6 +348,23 @@
+
+
+
Standard Signatur (Sagsmails)
+

Bruges automatisk ved afsendelse fra sag. Skabelonen bruger indlogget bruger + firmaoplysninger.

+ + +
+ Variabler: {full_name}, {title}, {email}, {company_name}, {company_phone}, {company_address}, {bmc_id_tag} +
+
+ +
+
+
+
Email Skabeloner
@@ -1699,7 +1716,7 @@ async function loadSettings() { function displaySettingsByCategory() { const categories = { - company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_address'], + company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_website', 'company_address'], integrations: ['vtiger_enabled', 'vtiger_url', 'vtiger_username', 'economic_enabled', 'economic_app_secret', 'economic_agreement_token'], notifications: ['email_notifications'], system: ['system_timezone'] @@ -2942,6 +2959,28 @@ async function loadAIPrompts() { const container = document.getElementById('aiPromptsContent'); const accordionHtml = ` +
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
${Object.entries(prompts).map(([key, prompt], index) => `
@@ -3031,6 +3070,7 @@ async function loadAIPrompts() { `; container.innerHTML = accordionHtml; + renderAiPromptLog(); } catch (error) { console.error('Error loading AI prompts:', error); @@ -3116,6 +3156,9 @@ async function testPrompt(key) { const editElement = document.getElementById(`edit_prompt_${key}`); const promptText = editElement ? editElement.value : ''; + const timeoutInput = document.getElementById('aiTestTimeoutSeconds'); + const timeoutSecondsRaw = timeoutInput ? Number(timeoutInput.value) : 90; + const timeoutSeconds = Math.min(300, Math.max(5, Number.isFinite(timeoutSecondsRaw) ? timeoutSecondsRaw : 90)); const originalHtml = btn.innerHTML; btn.disabled = true; @@ -3124,12 +3167,13 @@ async function testPrompt(key) { resultElement.className = 'alert alert-secondary m-3 py-2 px-3'; resultElement.classList.remove('d-none'); resultElement.textContent = 'Tester AI...'; + addAiPromptLogEntry('info', key, `Test startet (timeout=${timeoutSeconds}s)`); try { const response = await fetch(`/api/v1/ai-prompts/${key}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt_text: promptText }) + body: JSON.stringify({ prompt_text: promptText, timeout_seconds: timeoutSeconds }) }); if (!response.ok) { @@ -3146,16 +3190,54 @@ async function testPrompt(key) { `✅ AI svar modtaget (${result.latency_ms} ms)\n` + `Model: ${result.model}\n\n` + `${preview || '[Tomt svar]'}`; + addAiPromptLogEntry('success', key, `OK (${result.latency_ms} ms, timeout=${result.timeout_seconds || timeoutSeconds}s)`); } catch (error) { console.error('Error testing AI prompt:', error); resultElement.className = 'alert alert-danger m-3 py-2 px-3'; resultElement.textContent = `❌ ${error.message || 'Kunne ikke teste AI prompt'}`; + addAiPromptLogEntry('error', key, error.message || 'Kunne ikke teste AI prompt'); } finally { btn.disabled = false; btn.innerHTML = originalHtml; } } +let aiPromptTestLog = []; + +function addAiPromptLogEntry(level, key, message) { + aiPromptTestLog.unshift({ + level, + key, + message, + timestamp: new Date().toISOString(), + }); + if (aiPromptTestLog.length > 200) { + aiPromptTestLog = aiPromptTestLog.slice(0, 200); + } + renderAiPromptLog(); +} + +function renderAiPromptLog() { + const logWindow = document.getElementById('aiPromptLogWindow'); + if (!logWindow) return; + + if (!aiPromptTestLog.length) { + logWindow.innerHTML = '
Ingen test-log endnu.
'; + return; + } + + logWindow.innerHTML = aiPromptTestLog.map((row) => { + const icon = row.level === 'success' ? 'bi-check-circle text-success' : row.level === 'error' ? 'bi-x-circle text-danger' : 'bi-info-circle text-primary'; + const ts = new Date(row.timestamp).toLocaleString('da-DK'); + return `
[${ts}] ${escapeHtml(row.key)}: ${escapeHtml(row.message)}
`; + }).join(''); +} + +function clearAiPromptLog() { + aiPromptTestLog = []; + renderAiPromptLog(); +} + function copyPrompt(key) { @@ -4432,6 +4514,45 @@ async function loadEmailTemplateCustomers() { } } +async function loadDefaultEmailSignatureTemplate() { + try { + const response = await fetch('/api/v1/settings/email_default_signature_template'); + if (!response.ok) { + throw new Error('Kunne ikke hente signatur-skabelon'); + } + const setting = await response.json(); + const textarea = document.getElementById('emailDefaultSignatureTemplate'); + if (textarea) { + textarea.value = setting.value || ''; + } + } catch (error) { + console.error('Error loading default email signature template:', error); + } +} + +async function saveDefaultEmailSignatureTemplate() { + const textarea = document.getElementById('emailDefaultSignatureTemplate'); + if (!textarea) return; + + try { + const response = await fetch('/api/v1/settings/email_default_signature_template', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: textarea.value || '' }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || 'Kunne ikke gemme signatur-skabelon'); + } + + showNotification('Standard signatur gemt', 'success'); + } catch (error) { + console.error('Error saving default email signature template:', error); + showNotification(error.message || 'Kunne ikke gemme signatur-skabelon', 'error'); + } +} + async function openEmailTemplateModal() { // Reset form document.getElementById('emailTemplateForm').reset(); @@ -4577,6 +4698,7 @@ document.addEventListener('DOMContentLoaded', () => { // Other loaders are called at bottom of file in existing script loadEmailTemplates(); loadEmailTemplateCustomers(); + loadDefaultEmailSignatureTemplate(); }); diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 814d7e1..a6f7f96 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -1086,7 +1086,7 @@ -{% include "shared/frontend/quick_create_modal.html" %} +{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}