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 @@
diff --git a/app/subscriptions/backend/router.py b/app/subscriptions/backend/router.py
index 19bc609..36a40c7 100644
--- a/app/subscriptions/backend/router.py
+++ b/app/subscriptions/backend/router.py
@@ -21,6 +21,8 @@ router = APIRouter()
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
+ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
+ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
@@ -165,6 +167,15 @@ async def create_subscription(payload: Dict[str, Any]):
billing_interval = payload.get("billing_interval")
billing_day = payload.get("billing_day")
start_date = payload.get("start_date")
+ billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
+ advance_months = int(payload.get("advance_months") or 1)
+ first_full_period_start = payload.get("first_full_period_start")
+ binding_months = int(payload.get("binding_months") or 0)
+ binding_start_date_raw = payload.get("binding_start_date") or start_date
+ binding_group_key = payload.get("binding_group_key")
+ invoice_merge_key = payload.get("invoice_merge_key")
+ price_change_case_id = payload.get("price_change_case_id")
+ renewal_case_id = payload.get("renewal_case_id")
notes = payload.get("notes")
line_items = payload.get("line_items") or []
@@ -178,6 +189,12 @@ async def create_subscription(payload: Dict[str, Any]):
raise HTTPException(status_code=400, detail="start_date is required")
if not line_items:
raise HTTPException(status_code=400, detail="line_items is required")
+ if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
+ raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
+ if advance_months < 1 or advance_months > 24:
+ raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
+ if binding_months < 0:
+ raise HTTPException(status_code=400, detail="binding_months must be >= 0")
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
@@ -202,18 +219,27 @@ async def create_subscription(payload: Dict[str, Any]):
product_map = {}
if product_ids:
rows = execute_query(
- "SELECT id, name, sales_price FROM products WHERE id = ANY(%s)",
+ """
+ SELECT id, name, sales_price, serial_number_required, asset_required
+ FROM products
+ WHERE id = ANY(%s)
+ """,
(product_ids,)
)
product_map = {row["id"]: row for row in (rows or [])}
cleaned_items = []
total_price = 0
+ blocked_reasons = []
for idx, item in enumerate(line_items, start=1):
product_id = item.get("product_id")
description = (item.get("description") or "").strip()
quantity = item.get("quantity")
unit_price = item.get("unit_price")
+ asset_id = item.get("asset_id")
+ serial_number = (item.get("serial_number") or "").strip() or None
+ period_from = item.get("period_from")
+ period_to = item.get("period_to")
product = product_map.get(product_id)
if not description and product:
@@ -228,21 +254,58 @@ async def create_subscription(payload: Dict[str, Any]):
if unit_price is None or float(unit_price) < 0:
raise HTTPException(status_code=400, detail="line_items unit_price must be >= 0")
+ if asset_id is not None:
+ asset = execute_query_single(
+ "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
+ (asset_id,)
+ )
+ if not asset:
+ raise HTTPException(status_code=400, detail=f"asset_id {asset_id} was not found")
+
+ requires_asset = bool(product and product.get("asset_required"))
+ requires_serial_number = bool(product and product.get("serial_number_required"))
+ item_block_reasons: List[str] = []
+ if requires_asset and not asset_id:
+ item_block_reasons.append("Asset mangler")
+ if requires_serial_number and not serial_number:
+ item_block_reasons.append("Serienummer mangler")
+
line_total = float(quantity) * float(unit_price)
total_price += line_total
+ billing_blocked = len(item_block_reasons) > 0
+ billing_block_reason = "; ".join(item_block_reasons) if billing_blocked else None
+ if billing_block_reason:
+ blocked_reasons.append(f"{description}: {billing_block_reason}")
cleaned_items.append({
"line_no": idx,
"product_id": product_id,
+ "asset_id": asset_id,
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"line_total": line_total,
+ "period_from": period_from,
+ "period_to": period_to,
+ "requires_serial_number": requires_serial_number,
+ "serial_number": serial_number,
+ "billing_blocked": billing_blocked,
+ "billing_block_reason": billing_block_reason,
})
product_name = cleaned_items[0]["description"]
if len(cleaned_items) > 1:
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
+ billing_blocked = len(blocked_reasons) > 0
+ billing_block_reason = " | ".join(blocked_reasons) if billing_blocked else None
+
+ binding_start_date = _safe_date(binding_start_date_raw)
+ if not binding_start_date:
+ raise HTTPException(status_code=400, detail="binding_start_date must be a valid date")
+ binding_end_date = None
+ if binding_months > 0:
+ binding_end_date = binding_start_date + relativedelta(months=binding_months)
+
# Calculate next_invoice_date based on billing_interval
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
@@ -272,14 +335,30 @@ async def create_subscription(payload: Dict[str, Any]):
customer_id,
product_name,
billing_interval,
+ billing_direction,
+ advance_months,
+ first_full_period_start,
billing_day,
price,
start_date,
period_start,
next_invoice_date,
+ binding_months,
+ binding_start_date,
+ binding_end_date,
+ binding_group_key,
+ billing_blocked,
+ billing_block_reason,
+ invoice_merge_key,
+ price_change_case_id,
+ renewal_case_id,
status,
notes
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, 'draft', %s
+ )
RETURNING *
""",
(
@@ -287,11 +366,23 @@ async def create_subscription(payload: Dict[str, Any]):
sag["customer_id"],
product_name,
billing_interval,
+ billing_direction,
+ advance_months,
+ first_full_period_start,
billing_day,
total_price,
start_date,
period_start,
next_invoice_date,
+ binding_months,
+ binding_start_date,
+ binding_end_date,
+ binding_group_key,
+ billing_blocked,
+ billing_block_reason,
+ invoice_merge_key,
+ price_change_case_id,
+ renewal_case_id,
notes,
)
)
@@ -304,20 +395,34 @@ async def create_subscription(payload: Dict[str, Any]):
subscription_id,
line_no,
product_id,
+ asset_id,
description,
quantity,
unit_price,
- line_total
- ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ line_total,
+ period_from,
+ period_to,
+ requires_serial_number,
+ serial_number,
+ billing_blocked,
+ billing_block_reason
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription["id"],
item["line_no"],
item["product_id"],
+ item["asset_id"],
item["description"],
item["quantity"],
item["unit_price"],
item["line_total"],
+ item["period_from"],
+ item["period_to"],
+ item["requires_serial_number"],
+ item["serial_number"],
+ item["billing_blocked"],
+ item["billing_block_reason"],
)
)
@@ -348,13 +453,25 @@ async def get_subscription(subscription_id: int):
c.name AS customer_name,
s.product_name,
s.billing_interval,
+ s.billing_direction,
+ s.advance_months,
+ s.first_full_period_start,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.next_invoice_date,
s.period_start,
+ s.binding_months,
+ s.binding_start_date,
+ s.binding_end_date,
+ s.binding_group_key,
s.notice_period_days,
+ s.billing_blocked,
+ s.billing_block_reason,
+ s.invoice_merge_key,
+ s.price_change_case_id,
+ s.renewal_case_id,
s.status,
s.notes,
s.cancelled_at,
@@ -377,11 +494,18 @@ async def get_subscription(subscription_id: int):
i.id,
i.line_no,
i.product_id,
+ i.asset_id,
p.name AS product_name,
i.description,
i.quantity,
i.unit_price,
- i.line_total
+ i.line_total,
+ i.period_from,
+ i.period_to,
+ i.requires_serial_number,
+ i.serial_number,
+ i.billing_blocked,
+ i.billing_block_reason
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
@@ -416,7 +540,11 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
allowed_fields = {
"product_name", "billing_interval", "billing_day", "price",
"start_date", "end_date", "next_invoice_date", "period_start",
- "notice_period_days", "status", "notes"
+ "notice_period_days", "status", "notes",
+ "billing_direction", "advance_months", "first_full_period_start",
+ "binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
+ "billing_blocked", "billing_block_reason", "invoice_merge_key",
+ "price_change_case_id", "renewal_case_id"
}
updates = []
@@ -471,13 +599,23 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
"""
INSERT INTO sag_subscription_items (
subscription_id, line_no, description,
- quantity, unit_price, line_total, product_id
- ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ quantity, unit_price, line_total, product_id,
+ asset_id, period_from, period_to,
+ requires_serial_number, serial_number,
+ billing_blocked, billing_block_reason
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription_id, idx, description,
quantity, unit_price, line_total,
- item.get("product_id")
+ item.get("product_id"),
+ item.get("asset_id"),
+ item.get("period_from"),
+ item.get("period_to"),
+ bool(item.get("requires_serial_number")),
+ item.get("serial_number"),
+ bool(item.get("billing_blocked")),
+ item.get("billing_block_reason"),
)
)
@@ -537,10 +675,13 @@ async def list_subscriptions(status: str = Query("all")):
c.name AS customer_name,
s.product_name,
s.billing_interval,
+ s.billing_direction,
s.billing_day,
s.price,
s.start_date,
s.end_date,
+ s.billing_blocked,
+ s.invoice_merge_key,
s.status,
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
FROM sag_subscriptions s
@@ -602,6 +743,475 @@ async def trigger_subscription_processing():
raise HTTPException(status_code=500, detail=str(e))
+@router.get("/sag-subscriptions/{subscription_id}/price-changes", response_model=List[Dict[str, Any]])
+async def list_subscription_price_changes(subscription_id: int):
+ """List planned price changes for one subscription."""
+ try:
+ query = """
+ SELECT
+ spc.id,
+ spc.subscription_id,
+ spc.subscription_item_id,
+ spc.sag_id,
+ sg.titel AS sag_title,
+ spc.change_scope,
+ spc.old_unit_price,
+ spc.new_unit_price,
+ spc.effective_date,
+ spc.approval_status,
+ spc.reason,
+ spc.approved_by_user_id,
+ spc.approved_at,
+ spc.created_by_user_id,
+ spc.created_at,
+ spc.updated_at
+ FROM subscription_price_changes spc
+ LEFT JOIN sag_sager sg ON sg.id = spc.sag_id
+ WHERE spc.subscription_id = %s
+ AND spc.deleted_at IS NULL
+ ORDER BY spc.effective_date ASC, spc.id ASC
+ """
+ return execute_query(query, (subscription_id,)) or []
+ except Exception as e:
+ logger.error(f"❌ Error listing subscription price changes: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/sag-subscriptions/{subscription_id}/price-changes", response_model=Dict[str, Any])
+async def create_subscription_price_change(subscription_id: int, payload: Dict[str, Any]):
+ """Create a planned price change (case is mandatory)."""
+ try:
+ new_unit_price = payload.get("new_unit_price")
+ effective_date = payload.get("effective_date")
+ sag_id = payload.get("sag_id")
+ subscription_item_id = payload.get("subscription_item_id")
+ reason = payload.get("reason")
+ created_by_user_id = payload.get("created_by_user_id")
+
+ if new_unit_price is None:
+ raise HTTPException(status_code=400, detail="new_unit_price is required")
+ if float(new_unit_price) < 0:
+ raise HTTPException(status_code=400, detail="new_unit_price must be >= 0")
+ if not effective_date:
+ raise HTTPException(status_code=400, detail="effective_date is required")
+ if not sag_id:
+ raise HTTPException(status_code=400, detail="sag_id is required")
+
+ subscription = execute_query_single(
+ "SELECT id, customer_id, price FROM sag_subscriptions WHERE id = %s",
+ (subscription_id,)
+ )
+ if not subscription:
+ raise HTTPException(status_code=404, detail="Subscription not found")
+
+ sag = execute_query_single(
+ "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (sag_id,)
+ )
+ if not sag:
+ raise HTTPException(status_code=400, detail="Sag not found")
+ if int(sag.get("customer_id") or 0) != int(subscription.get("customer_id") or 0):
+ raise HTTPException(status_code=400, detail="Sag customer mismatch for subscription")
+
+ change_scope = "subscription"
+ old_unit_price = subscription.get("price")
+ if subscription_item_id is not None:
+ item = execute_query_single(
+ """
+ SELECT id, unit_price
+ FROM sag_subscription_items
+ WHERE id = %s AND subscription_id = %s
+ """,
+ (subscription_item_id, subscription_id)
+ )
+ if not item:
+ raise HTTPException(status_code=400, detail="subscription_item_id not found on this subscription")
+ change_scope = "item"
+ old_unit_price = item.get("unit_price")
+
+ result = execute_query(
+ """
+ INSERT INTO subscription_price_changes (
+ subscription_id,
+ subscription_item_id,
+ sag_id,
+ change_scope,
+ old_unit_price,
+ new_unit_price,
+ effective_date,
+ approval_status,
+ reason,
+ created_by_user_id
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s)
+ RETURNING *
+ """,
+ (
+ subscription_id,
+ subscription_item_id,
+ sag_id,
+ change_scope,
+ old_unit_price,
+ new_unit_price,
+ effective_date,
+ reason,
+ created_by_user_id,
+ )
+ )
+ return result[0] if result else {}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating subscription price change: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/sag-subscriptions/price-changes/{change_id}/approve", response_model=Dict[str, Any])
+async def approve_subscription_price_change(change_id: int, payload: Dict[str, Any]):
+ """Approve or reject a planned price change."""
+ try:
+ approval_status = (payload.get("approval_status") or "approved").strip().lower()
+ approved_by_user_id = payload.get("approved_by_user_id")
+ if approval_status not in ALLOWED_PRICE_CHANGE_STATUSES:
+ raise HTTPException(status_code=400, detail="Invalid approval_status")
+ if approval_status == "applied":
+ raise HTTPException(status_code=400, detail="Use apply endpoint to set applied status")
+
+ result = execute_query(
+ """
+ UPDATE subscription_price_changes
+ SET approval_status = %s,
+ approved_by_user_id = %s,
+ approved_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ AND deleted_at IS NULL
+ RETURNING *
+ """,
+ (approval_status, approved_by_user_id, change_id)
+ )
+ if not result:
+ raise HTTPException(status_code=404, detail="Price change not found")
+ return result[0]
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error approving subscription price change: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/sag-subscriptions/price-changes/{change_id}/apply", response_model=Dict[str, Any])
+async def apply_subscription_price_change(change_id: int):
+ """Apply an approved price change to subscription or item pricing."""
+ conn = get_db_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ SELECT *
+ FROM subscription_price_changes
+ WHERE id = %s
+ AND deleted_at IS NULL
+ """,
+ (change_id,)
+ )
+ change = cursor.fetchone()
+ if not change:
+ raise HTTPException(status_code=404, detail="Price change not found")
+ if change.get("approval_status") not in ("approved", "pending"):
+ raise HTTPException(status_code=400, detail="Price change must be approved or pending before apply")
+
+ subscription_id = int(change["subscription_id"])
+ change_scope = change.get("change_scope")
+ new_unit_price = float(change.get("new_unit_price") or 0)
+
+ if change_scope == "item" and change.get("subscription_item_id"):
+ cursor.execute(
+ """
+ UPDATE sag_subscription_items
+ SET unit_price = %s,
+ line_total = ROUND((quantity * %s)::numeric, 2),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (new_unit_price, new_unit_price, change["subscription_item_id"])
+ )
+ else:
+ cursor.execute(
+ """
+ UPDATE sag_subscription_items
+ SET unit_price = %s,
+ line_total = ROUND((quantity * %s)::numeric, 2),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE subscription_id = %s
+ """,
+ (new_unit_price, new_unit_price, subscription_id)
+ )
+
+ cursor.execute(
+ """
+ SELECT COALESCE(SUM(line_total), 0) AS total
+ FROM sag_subscription_items
+ WHERE subscription_id = %s
+ """,
+ (subscription_id,)
+ )
+ row = cursor.fetchone() or {"total": 0}
+ cursor.execute(
+ """
+ UPDATE sag_subscriptions
+ SET price = %s,
+ price_change_case_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (row.get("total") or 0, change.get("sag_id"), subscription_id)
+ )
+
+ cursor.execute(
+ """
+ UPDATE subscription_price_changes
+ SET approval_status = 'applied',
+ approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ RETURNING *
+ """,
+ (change_id,)
+ )
+ updated_change = cursor.fetchone()
+
+ conn.commit()
+ return updated_change or {}
+ except HTTPException:
+ conn.rollback()
+ raise
+ except Exception as e:
+ conn.rollback()
+ logger.error(f"❌ Error applying subscription price change: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ release_db_connection(conn)
+
+
+@router.get("/sag-subscriptions/{subscription_id}/asset-bindings", response_model=List[Dict[str, Any]])
+async def list_subscription_asset_bindings(subscription_id: int):
+ """List asset bindings attached to a subscription."""
+ try:
+ return execute_query(
+ """
+ SELECT
+ b.id,
+ b.subscription_id,
+ b.asset_id,
+ b.shared_binding_key,
+ b.binding_months,
+ b.start_date,
+ b.end_date,
+ b.notice_period_days,
+ b.status,
+ b.sag_id,
+ b.created_by_user_id,
+ b.created_at,
+ b.updated_at,
+ h.brand,
+ h.model,
+ h.serial_number AS asset_serial_number,
+ h.internal_asset_id,
+ h.status AS asset_status
+ FROM subscription_asset_bindings b
+ LEFT JOIN hardware_assets h ON h.id = b.asset_id
+ WHERE b.subscription_id = %s
+ AND b.deleted_at IS NULL
+ ORDER BY b.start_date DESC, b.id DESC
+ """,
+ (subscription_id,)
+ ) or []
+ except Exception as e:
+ logger.error(f"❌ Error listing subscription asset bindings: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/sag-subscriptions/{subscription_id}/asset-bindings", response_model=Dict[str, Any])
+async def create_subscription_asset_binding(subscription_id: int, payload: Dict[str, Any]):
+ """Create a binding for one asset under a subscription."""
+ try:
+ asset_id = payload.get("asset_id")
+ start_date_raw = payload.get("start_date")
+ end_date_raw = payload.get("end_date")
+ binding_months = int(payload.get("binding_months") or 0)
+ shared_binding_key = payload.get("shared_binding_key")
+ notice_period_days = int(payload.get("notice_period_days") or 30)
+ sag_id = payload.get("sag_id")
+ created_by_user_id = payload.get("created_by_user_id")
+
+ if not asset_id:
+ raise HTTPException(status_code=400, detail="asset_id is required")
+ if notice_period_days < 0:
+ raise HTTPException(status_code=400, detail="notice_period_days must be >= 0")
+ if binding_months < 0:
+ raise HTTPException(status_code=400, detail="binding_months must be >= 0")
+
+ subscription = execute_query_single(
+ "SELECT id, customer_id, start_date FROM sag_subscriptions WHERE id = %s",
+ (subscription_id,)
+ )
+ if not subscription:
+ raise HTTPException(status_code=404, detail="Subscription not found")
+
+ asset = execute_query_single(
+ "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
+ (asset_id,)
+ )
+ if not asset:
+ raise HTTPException(status_code=400, detail="Asset not found")
+
+ if sag_id:
+ sag = execute_query_single(
+ "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (sag_id,)
+ )
+ if not sag:
+ raise HTTPException(status_code=400, detail="Sag not found")
+ if int(sag.get("customer_id") or 0) != int(subscription.get("customer_id") or 0):
+ raise HTTPException(status_code=400, detail="Sag customer mismatch for subscription")
+
+ start_date = _safe_date(start_date_raw) or _safe_date(subscription.get("start_date")) or date.today()
+ end_date = _safe_date(end_date_raw)
+ if not end_date and binding_months > 0:
+ end_date = start_date + relativedelta(months=binding_months)
+
+ result = execute_query(
+ """
+ INSERT INTO subscription_asset_bindings (
+ subscription_id,
+ asset_id,
+ shared_binding_key,
+ binding_months,
+ start_date,
+ end_date,
+ notice_period_days,
+ status,
+ sag_id,
+ created_by_user_id
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'active', %s, %s)
+ RETURNING *
+ """,
+ (
+ subscription_id,
+ asset_id,
+ shared_binding_key,
+ binding_months,
+ start_date,
+ end_date,
+ notice_period_days,
+ sag_id,
+ created_by_user_id,
+ )
+ )
+ if not result:
+ raise HTTPException(status_code=500, detail="Could not create binding")
+
+ execute_query(
+ """
+ UPDATE sag_subscription_items
+ SET asset_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE subscription_id = %s
+ AND asset_id IS NULL
+ """,
+ (asset_id, subscription_id)
+ )
+
+ return result[0]
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating subscription asset binding: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/sag-subscriptions/asset-bindings/{binding_id}", response_model=Dict[str, Any])
+async def update_subscription_asset_binding(binding_id: int, payload: Dict[str, Any]):
+ """Update status/dates/notice for a subscription asset binding."""
+ try:
+ allowed_fields = {
+ "shared_binding_key",
+ "binding_months",
+ "start_date",
+ "end_date",
+ "notice_period_days",
+ "status",
+ "sag_id",
+ }
+ updates = []
+ values = []
+ for field, value in payload.items():
+ if field in allowed_fields:
+ updates.append(f"{field} = %s")
+ values.append(value)
+
+ if "status" in payload and payload.get("status") not in {"active", "ended", "cancelled"}:
+ raise HTTPException(status_code=400, detail="Invalid binding status")
+
+ if "notice_period_days" in payload and int(payload.get("notice_period_days") or 0) < 0:
+ raise HTTPException(status_code=400, detail="notice_period_days must be >= 0")
+
+ if not updates:
+ existing = execute_query_single(
+ "SELECT * FROM subscription_asset_bindings WHERE id = %s AND deleted_at IS NULL",
+ (binding_id,)
+ )
+ if not existing:
+ raise HTTPException(status_code=404, detail="Binding not found")
+ return existing
+
+ values.append(binding_id)
+ result = execute_query(
+ f"""
+ UPDATE subscription_asset_bindings
+ SET {', '.join(updates)},
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ AND deleted_at IS NULL
+ RETURNING *
+ """,
+ tuple(values)
+ )
+ if not result:
+ raise HTTPException(status_code=404, detail="Binding not found")
+ return result[0]
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating subscription asset binding: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/sag-subscriptions/asset-bindings/{binding_id}", response_model=Dict[str, Any])
+async def delete_subscription_asset_binding(binding_id: int):
+ """Soft-delete a subscription asset binding."""
+ try:
+ result = execute_query(
+ """
+ UPDATE subscription_asset_bindings
+ SET deleted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ AND deleted_at IS NULL
+ RETURNING id
+ """,
+ (binding_id,)
+ )
+ if not result:
+ raise HTTPException(status_code=404, detail="Binding not found")
+ return {"status": "deleted", "id": result[0].get("id")}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error deleting subscription asset binding: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.post("/simply-subscription-staging/import", response_model=Dict[str, Any])
async def import_simply_subscriptions_to_staging():
"""Import recurring Simply CRM SalesOrders into staging (parking area)."""
diff --git a/design_forslag_kompakt.html b/design_forslag_kompakt.html
new file mode 100644
index 0000000..f781862
--- /dev/null
+++ b/design_forslag_kompakt.html
@@ -0,0 +1,195 @@
+
+
+
+
+
+ Kompakte Designforslag - Sagsdetaljer
+
+
+
+
+
+
+
Fokuseret på Minimal Plads (Kompakt)
+
Her er 3 designs uden store ikoner, uden navnebobler og med minimal whitespace, præcis som i en professionel log eller et tæt ticket-system.
+
+
+
Forslag 1: Inline Log (Slack Kompakt style)
+
Minder om terminal-output eller kompakt chat. Alt udover teksten står på én linje, og marginer er næsten fjernet.
+
+
+
+ I dag 10:00
+ Jens Jensen:
+ Vi har et problem med at vores to printere på kontoret ikke vil forbinde til netværket siden fredag. Skærmene lyser, men de melder offline på printserveren.
+
+
+ I dag 10:15
+ Christian Thomas:
+ Hej Jens. Jeg kan se at switchen port 4 & 5 var nede hurtigt i nat. Har I prøvet at genstarte dem, så de fanger ny DHCP IP?
+
+
+ I dag 10:35
+ Jens Jensen:
+ Ja, det har vi nu og det løste det mærkeligt nok for den ene, men HP printeren driller stadig.
+
+
+ I dag 10:45
+ Christian Thomas:
+ Jeg logger lige på jeres firewall udefra om 5 minutter og tjekker om HP'en er blokeret i MAC-filteret.
+
+
+
+
+
+
Forslag 2: Helpdesk Split (ITSM style)
+
Klassisk 2-kolonne layout. Venstre side har fast bredde til metadata, højre side udnytter hele bredden til ren tekst. Ingen tidsspilde vertikalt.
+
+
+
+
+
+
Jens Jensen
+
KUNDE
+
I dag, kl. 10:00
+
+
+ Vi har et problem med at vores to printere på kontoret ikke vil forbinde til netværket siden fredag. Skærmene lyser, men de melder offline på printserveren.
+
+
+
+
+
+
+
Christian Thomas
+
BMC NETWORKS
+
I dag, kl. 10:15
+
+
+ Hej Jens. Jeg kan se at switchen port 4 & 5 var nede hurtigt i nat. Har I prøvet at genstarte dem, så de fanger ny DHCP IP?
+
+
+
+
+
+
+
Jens Jensen
+
KUNDE
+
I dag, kl. 10:35
+
+
+ Ja, det har vi nu og det løste det mærkeligt nok for den ene, men HP printeren driller stadig.
+
+
+
+
+
+
+
Forslag 3: Minimalistisk Logbog
+
Hver tråd adskilles af en meget tynd grå overskrift. Ingen kasser rundt om teksten, den flyder frit for maksimal informationsdensitet.
+
+
+
+
+
+ Jens Jensen (Kunde)
+ I dag, kl. 10:00
+
+
+ Vi har et problem med at vores to printere på kontoret ikke vil forbinde til netværket siden fredag. Skærmene lyser, men de melder offline på printserveren.
+
+
+
+
+
+ Christian Thomas (Tekniker)
+ I dag, kl. 10:15
+
+
+ Hej Jens. Jeg kan se at switchen port 4 & 5 var nede hurtigt i nat. Har I prøvet at genstarte dem, så de fanger ny DHCP IP?
+
+
+
+
+
+ Jens Jensen (Kunde)
+ I dag, kl. 10:35
+
+
+ Ja, det har vi nu og det løste det mærkeligt nok for den ene, men HP printeren driller stadig.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/design_forslag_sagsdetaljer.html b/design_forslag_sagsdetaljer.html
new file mode 100644
index 0000000..8938102
--- /dev/null
+++ b/design_forslag_sagsdetaljer.html
@@ -0,0 +1,338 @@
+
+
+
+
+
+ Designforslag - Sagsdetaljer & Kommentarer
+
+
+
+
+
+
+
+
+
UI Forslag: Sagsdetaljer & Kommentarer
+
3 forskellige måder at redesigne "Opgavebeskrivelse" og "Kommentarer" på, uden at røre live-koden endnu.
+
+
+
+
+
Forslag 1: Chat / Messenger UI
+
Gør det nemt at adskille hvem der siger hvad. Interne noter (højre, blå), kundens svar (venstre, grå). Beskrivelsen er "låst" i toppen som opgavens udgangspunkt.
Meget stilrent design til CRM / Enterprise systemer. Bevarer fuld bredde for lang tekst, men bruger en tyk farvekode på venstre kant til at identificere typen lynhurtigt.
+
+
+
+ Opgavebeskrivelse
+ Rediger
+
+
awrtqerqerg
+
+
+
Kommentarer & Historik
+
+
+
+
+
+ Hurtig kommentar
+ Intern Note
+
+
19/03-2026 06:34
+
+
+ tiest
+
+
+
+
+
+
+
+ Hurtig kommentar
+ Intern Note
+
+
19/03-2026 07:59
+
+
+ adfgaegea hsrhsh
+
+
+
+
+
+
+
+ Bruger
+ Kunde
+
+
19/03-2026 08:03
+
+
+ sdfsdfsdfgsg
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/design_forslag_top3_ny_side.html b/design_forslag_top3_ny_side.html
new file mode 100644
index 0000000..f535ceb
--- /dev/null
+++ b/design_forslag_top3_ny_side.html
@@ -0,0 +1,290 @@
+
+
+
+
+
+ 3 Kompakte Forslag - Sagsdetaljer
+
+
+
+
+
+
+
3 nye forslag på én side
+
Fokus: kompakt layout, mindre vertikalt spild, hurtig læsning i drift.
+
+
+
+
+
Forslag 1: Kompakt Thread
+ Bedst balance mellem læsbarhed og tæthed
+
+
+
+
+
JJJens Jensen10:00
+
Vi har et problem med printerne siden fredag. De melder offline.
+
+
+
CTChristian Thomas10:15
+
Jeg kan se port 4 og 5 har været nede. Kan I genstarte printerne?
+
+
+
SYSystem10:20
+
Status ændret: Åben -> Under behandling
+
+
+
+
+
+
+
+
Forslag 2: Activity Log
+ Maksimal informationsdensitet
+
+
+
+
+
+
+
Jens Jensen10:00
+
Vi har et problem med printerne siden fredag. De melder offline.
+
+
+
+
+
+
Christian Thomas10:15
+
Jeg kan se port 4 og 5 har været nede. Kan I genstarte printerne?
+
+
+
+
+
+
System10:20
+
Status ændret: Åben -> Under behandling
+
+
+
+
+
+
+
+
+
Forslag 3: Split Inbox
+ Tydelig kunde/tekniker-retning
+
+
+
+
Vi har et problem med printerne siden fredag. De melder offline.
Jens Jensen10:00
+
Jeg kan se port 4 og 5 har været nede. Kan I genstarte printerne?
Christian Thomas10:15
+
Status ændret: Åben -> Under behandling
System10:20
+
+
+
+
+
+
\ No newline at end of file
diff --git a/forslag_kommentar.html b/forslag_kommentar.html
new file mode 100644
index 0000000..cd48163
--- /dev/null
+++ b/forslag_kommentar.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+Top 3 Forslag – Kommentarfelt
+
+
+
+
+
+
Mine 3 bedste forslag til kommentarfeltet
+
Klik på fanerne for at skifte mellem forslagene. Alle er kompakte og klar til at implementere direkte.
Vi har et problem med vores to printere – de melder offline siden fredag.
+
+
+
CTChristian Thomas21/03 10:15
+
Port 4 og 5 var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP?
+
+
+
⚙System21/03 10:20
+
Status ændret: Åben → Under behandling
+
+
+
JJJens Jensen21/03 10:35
+
HP'en virker stadig ikke – den anden kom op igen efter genstart.
+
+
+
CTChristian Thomas21/03 10:45
+
Jeg logger på jeres firewall nu og tjekker MAC-filteret.
+
+
+
+ Send
+
+
+
Fordel: Bedste balance mellem kompakt og læsbar. Lille initial-cirkel gør det nemt at følge hvem der skriver. Virker godt med mange beskeder.
+
+
+
+
+
+
+
+
+
Jens Jensen · 21/03 10:00
+
Vi har et problem med vores to printere – de melder offline siden fredag.
+
+
+
+
+
+
Christian Thomas · 21/03 10:15
+
Port 4 og 5 var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP?
+
+
+
+
+
+
System · 21/03 10:20
+
Status ændret: Åben → Under behandling
+
+
+
+
+
+
Jens Jensen · 21/03 10:35
+
HP'en virker stadig ikke – den anden kom op igen efter genstart.
+
+
+
+
+
+
Christian Thomas · 21/03 10:45
+
Jeg logger på jeres firewall nu og tjekker MAC-filteret.
+
+
+
+
+ Send
+
+
+
Fordel: Absolut tættest muligt. Blå streg = intern tekniker, grå = system, ingen streg = kunde. Perfekt til teknikere der scanner hurtigt.
+
+
+
+
+
+
+
+
Vi har et problem med vores to printere – de melder offline siden fredag.
+
Jens Jensen · 21/03 10:00
+
+
+
Port 4 og 5 var nede kort i nat. Har I prøvet at genstarte printerne?
+
Christian Thomas · 21/03 10:15
+
+
+
Status ændret: Åben → Under behandling
+
System · 21/03 10:20
+
+
+
HP'en virker stadig ikke – den anden kom op igen efter genstart.
+
Jens Jensen · 21/03 10:35
+
+
+
Jeg logger på jeres firewall nu og tjekker MAC-filteret.
+
Christian Thomas · 21/03 10:45
+
+
+
+
+ Send
+
+
+
Fordel: Gør kunde/tekniker-retningen tydelig uden farver eller headers. System-beskeder centreres. Godt til dialog-tunge sager.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/main.py b/main.py
index 4118a6f..b9110c7 100644
--- a/main.py
+++ b/main.py
@@ -170,6 +170,20 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ Subscription invoice job scheduled (daily at 04:00)")
+ # Register ordre draft sync reconcile job
+ from app.jobs.reconcile_ordre_drafts import reconcile_ordre_drafts_sync_status
+
+ backup_scheduler.scheduler.add_job(
+ func=reconcile_ordre_drafts_sync_status,
+ trigger=CronTrigger(hour=4, minute=30),
+ kwargs={"apply_changes": True},
+ id='reconcile_ordre_drafts_sync_status',
+ name='Reconcile Ordre Draft Sync Status',
+ max_instances=1,
+ replace_existing=True
+ )
+ logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)")
+
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync
@@ -281,13 +295,21 @@ async def auth_middleware(request: Request, call_next):
request.state.user_id = None
if path.startswith("/api") and not payload.get("shadow_admin"):
- if not payload.get("sub"):
+ sub_value = payload.get("sub")
+ if not sub_value:
+ from fastapi.responses import JSONResponse
+ return JSONResponse(
+ status_code=401,
+ content={"detail": "Invalid token"}
+ )
+ try:
+ user_id = int(sub_value)
+ except (TypeError, ValueError):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"detail": "Invalid token"}
)
- user_id = int(payload.get("sub"))
if _users_column_exists("is_2fa_enabled"):
user = execute_query_single(
diff --git a/migrations/1002_asset_first_subscription_billing.sql b/migrations/1002_asset_first_subscription_billing.sql
new file mode 100644
index 0000000..376d726
--- /dev/null
+++ b/migrations/1002_asset_first_subscription_billing.sql
@@ -0,0 +1,116 @@
+-- Migration 1002: Asset-first subscriptions, billing controls, and price change tracking
+-- Adds fields and tables needed for asset-linked subscriptions and flexible billing.
+
+ALTER TABLE sag_subscriptions
+ADD COLUMN IF NOT EXISTS billing_direction VARCHAR(20) NOT NULL DEFAULT 'forward'
+ CHECK (billing_direction IN ('forward', 'backward')),
+ADD COLUMN IF NOT EXISTS advance_months INTEGER NOT NULL DEFAULT 1
+ CHECK (advance_months >= 1 AND advance_months <= 24),
+ADD COLUMN IF NOT EXISTS first_full_period_start DATE,
+ADD COLUMN IF NOT EXISTS binding_months INTEGER NOT NULL DEFAULT 0
+ CHECK (binding_months >= 0),
+ADD COLUMN IF NOT EXISTS binding_start_date DATE,
+ADD COLUMN IF NOT EXISTS binding_end_date DATE,
+ADD COLUMN IF NOT EXISTS binding_group_key VARCHAR(80),
+ADD COLUMN IF NOT EXISTS billing_blocked BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN IF NOT EXISTS billing_block_reason TEXT,
+ADD COLUMN IF NOT EXISTS invoice_merge_key VARCHAR(120),
+ADD COLUMN IF NOT EXISTS price_change_case_id INTEGER REFERENCES sag_sager(id),
+ADD COLUMN IF NOT EXISTS renewal_case_id INTEGER REFERENCES sag_sager(id);
+
+CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_billing_direction ON sag_subscriptions(billing_direction);
+CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_billing_blocked ON sag_subscriptions(billing_blocked) WHERE billing_blocked = true;
+CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_binding_end_date ON sag_subscriptions(binding_end_date) WHERE binding_end_date IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_invoice_merge_key ON sag_subscriptions(invoice_merge_key);
+
+ALTER TABLE sag_subscription_items
+ADD COLUMN IF NOT EXISTS asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE RESTRICT,
+ADD COLUMN IF NOT EXISTS period_from DATE,
+ADD COLUMN IF NOT EXISTS period_to DATE,
+ADD COLUMN IF NOT EXISTS requires_serial_number BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN IF NOT EXISTS serial_number VARCHAR(100),
+ADD COLUMN IF NOT EXISTS billing_blocked BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN IF NOT EXISTS billing_block_reason TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_asset_id ON sag_subscription_items(asset_id);
+CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_blocked ON sag_subscription_items(billing_blocked) WHERE billing_blocked = true;
+
+CREATE TABLE IF NOT EXISTS subscription_asset_bindings (
+ id SERIAL PRIMARY KEY,
+ subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
+ asset_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE RESTRICT,
+ shared_binding_key VARCHAR(80),
+ binding_months INTEGER NOT NULL DEFAULT 0 CHECK (binding_months >= 0),
+ start_date DATE NOT NULL,
+ end_date DATE,
+ notice_period_days INTEGER NOT NULL DEFAULT 30 CHECK (notice_period_days >= 0),
+ status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'ended', 'cancelled')),
+ sag_id INTEGER REFERENCES sag_sager(id),
+ created_by_user_id INTEGER REFERENCES users(user_id),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP,
+ UNIQUE (subscription_id, asset_id, start_date)
+);
+
+CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_subscription ON subscription_asset_bindings(subscription_id);
+CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_asset ON subscription_asset_bindings(asset_id);
+CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_end_date ON subscription_asset_bindings(end_date);
+
+CREATE TRIGGER trigger_subscription_asset_bindings_updated_at
+BEFORE UPDATE ON subscription_asset_bindings
+FOR EACH ROW
+EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE IF NOT EXISTS subscription_price_changes (
+ id SERIAL PRIMARY KEY,
+ subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
+ subscription_item_id INTEGER REFERENCES sag_subscription_items(id) ON DELETE SET NULL,
+ sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
+ change_scope VARCHAR(20) NOT NULL DEFAULT 'subscription' CHECK (change_scope IN ('subscription', 'item')),
+ old_unit_price DECIMAL(10,2),
+ new_unit_price DECIMAL(10,2) NOT NULL CHECK (new_unit_price >= 0),
+ effective_date DATE NOT NULL,
+ approval_status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'rejected', 'applied')),
+ reason TEXT,
+ approved_by_user_id INTEGER REFERENCES users(user_id),
+ approved_at TIMESTAMP,
+ created_by_user_id INTEGER REFERENCES users(user_id),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_subscription_price_changes_subscription ON subscription_price_changes(subscription_id);
+CREATE INDEX IF NOT EXISTS idx_subscription_price_changes_effective_date ON subscription_price_changes(effective_date);
+CREATE INDEX IF NOT EXISTS idx_subscription_price_changes_status ON subscription_price_changes(approval_status);
+
+CREATE TRIGGER trigger_subscription_price_changes_updated_at
+BEFORE UPDATE ON subscription_price_changes
+FOR EACH ROW
+EXECUTE FUNCTION update_updated_at_column();
+
+ALTER TABLE ordre_drafts
+ADD COLUMN IF NOT EXISTS coverage_start DATE,
+ADD COLUMN IF NOT EXISTS coverage_end DATE,
+ADD COLUMN IF NOT EXISTS billing_direction VARCHAR(20)
+ CHECK (billing_direction IN ('forward', 'backward')),
+ADD COLUMN IF NOT EXISTS source_subscription_ids INTEGER[] NOT NULL DEFAULT '{}',
+ADD COLUMN IF NOT EXISTS invoice_aggregate_key VARCHAR(120),
+ADD COLUMN IF NOT EXISTS sync_status VARCHAR(20) NOT NULL DEFAULT 'pending'
+ CHECK (sync_status IN ('pending', 'exported', 'failed', 'posted', 'paid')),
+ADD COLUMN IF NOT EXISTS economic_order_number VARCHAR(80),
+ADD COLUMN IF NOT EXISTS economic_invoice_number VARCHAR(80),
+ADD COLUMN IF NOT EXISTS last_sync_at TIMESTAMP;
+
+CREATE INDEX IF NOT EXISTS idx_ordre_drafts_sync_status ON ordre_drafts(sync_status);
+CREATE INDEX IF NOT EXISTS idx_ordre_drafts_invoice_aggregate_key ON ordre_drafts(invoice_aggregate_key);
+
+ALTER TABLE products
+ADD COLUMN IF NOT EXISTS serial_number_required BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN IF NOT EXISTS asset_required BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN IF NOT EXISTS rental_asset_enabled BOOLEAN NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN products.serial_number_required IS 'If true, subscription line billing requires serial number data.';
+COMMENT ON COLUMN products.asset_required IS 'If true, subscription line billing requires linked hardware asset.';
+COMMENT ON COLUMN products.rental_asset_enabled IS 'If true, product is eligible for asset-first rental subscription flows.';
diff --git a/migrations/1003_ordre_sync_audit_and_idempotency.sql b/migrations/1003_ordre_sync_audit_and_idempotency.sql
new file mode 100644
index 0000000..eb17206
--- /dev/null
+++ b/migrations/1003_ordre_sync_audit_and_idempotency.sql
@@ -0,0 +1,25 @@
+-- Migration 1003: Ordre draft sync audit + idempotency safeguards
+
+ALTER TABLE ordre_drafts
+ADD COLUMN IF NOT EXISTS export_idempotency_key VARCHAR(120);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_ordre_drafts_export_idempotency_key
+ ON ordre_drafts(export_idempotency_key)
+ WHERE export_idempotency_key IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS ordre_draft_sync_events (
+ id SERIAL PRIMARY KEY,
+ draft_id INTEGER NOT NULL REFERENCES ordre_drafts(id) ON DELETE CASCADE,
+ event_type VARCHAR(50) NOT NULL,
+ from_status VARCHAR(20),
+ to_status VARCHAR(20),
+ event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_ordre_draft_sync_events_draft_id
+ ON ordre_draft_sync_events(draft_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_ordre_draft_sync_events_type
+ ON ordre_draft_sync_events(event_type, created_at DESC);
diff --git a/migrations/142_email_thread_key.sql b/migrations/142_email_thread_key.sql
new file mode 100644
index 0000000..ccab9c1
--- /dev/null
+++ b/migrations/142_email_thread_key.sql
@@ -0,0 +1,40 @@
+-- Migration 142: Persistent thread key for reliable inbound case routing
+
+ALTER TABLE email_messages
+ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
+
+-- Backfill thread_key for existing rows.
+-- Priority:
+-- 1) First token from References (root message id)
+-- 2) In-Reply-To
+-- 3) Message-ID
+UPDATE email_messages
+SET thread_key = LOWER(
+ REGEXP_REPLACE(
+ COALESCE(
+ NULLIF(
+ SPLIT_PART(
+ REGEXP_REPLACE(COALESCE(email_references, ''), '^[\s<>,]+', ''),
+ ' ',
+ 1
+ ),
+ ''
+ ),
+ NULLIF(in_reply_to, ''),
+ NULLIF(message_id, '')
+ ),
+ '[<>\s]',
+ '',
+ 'g'
+ )
+)
+WHERE (thread_key IS NULL OR TRIM(thread_key) = '')
+ AND (
+ COALESCE(NULLIF(email_references, ''), NULLIF(in_reply_to, ''), NULLIF(message_id, '')) IS NOT NULL
+ );
+
+CREATE INDEX IF NOT EXISTS idx_email_messages_thread_key
+ON email_messages(thread_key)
+WHERE thread_key IS NOT NULL;
+
+COMMENT ON COLUMN email_messages.thread_key IS 'Stable normalized thread key (root message-id/in-reply-to) for case routing';
diff --git a/mine_3_anbefalinger.html b/mine_3_anbefalinger.html
new file mode 100644
index 0000000..5529901
--- /dev/null
+++ b/mine_3_anbefalinger.html
@@ -0,0 +1,324 @@
+
+
+
+
+
+ Mine 3 anbefalinger – Sag Kommentarer
+
+
+
+
+
+
+
+
+
Mine 3 anbefalinger til sag-kommentarer
+
Alle 3 er baseret på den faktiske Jinja-struktur i detail.html og er klar til at erstatte det eksisterende kort-layout direkte.
+
+
+
+
+
Anbefaling 1 — Thread (Linear/Notion style) ⭐ min favorit
+
Lille initial-cirkel + navn + tid på én linje. Tekst nedenunder med kun et fast indent. Ingen bokse, ingen farvegte headers. Virker kompakt men er stadig behagelig at læse.
+
+
+
+
+
+
JJ
+ Jens Jensen
+ 21/03-2026 10:00
+
+
Vi har et problem med vores to printere på kontoret — de melder offline på printserveren siden fredag.
+
+
+
+
+
+
CT
+ Christian Thomas
+ 21/03-2026 10:15
+
+
Jeg kan se at port 4 & 5 på switchen var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP-adresse?
+
+
+
+
+
+
+ System
+ 21/03-2026 10:20
+
+
Status ændret fra Åben til Under behandling
+
+
+
+
+
+
JJ
+ Jens Jensen
+ 21/03-2026 10:35
+
+
HP'en virker stadig ikke - den anden fik vi op at køre.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vis Jinja-kode til at erstatte det nuværende
+
Ingen avatarer. Kun tekst-rækker. En 3px venstre-streg i blå viser hvem der er tekniker vs. hvid/grå for alle andre. Højeste mulige informationsdensitet — ligner en IT-log.
+
+
+
+
+
+
Jens Jensen · 21/03-2026 kl. 10:00
+
Vi har et problem med vores to printere på kontoret — de melder offline på printserveren siden fredag.
+
+
+
+
+
+
Christian ThomasBMC · 21/03-2026 kl. 10:15
+
Jeg kan se at port 4 & 5 på switchen var nede kort i nat. Har I prøvet at genstarte printerne?
+
+
+
+
+
+
System · 21/03-2026 kl. 10:20
+
Status ændret: Åben → Under behandling
+
+
+
+
+
+
Jens Jensen · 21/03-2026 kl. 10:35
+
HP'en virker stadig ikke — den anden fick vi op at køre.
+
+
+
+
+
+
+
+
+
+
+
+
+ Vis Jinja-kode til at erstatte det nuværende
+
Kunde-beskeder til venstre. BMC/tekniker-svar til højre. Ingen farvegte headers, ingen bobler — bare flad alignment og subtil baggrundsforskel. Klart at forstå hvem der sagde hvad.
+
+
+
+
+
Vi har et problem med vores to printere på kontoret — de melder offline på printserveren siden fredag.
+
Jens Jensen · 21/03-2026 10:00
+
+
+
+
+
Jeg kan se at port 4 & 5 på switchen var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP-adresse?
+
Christian Thomas · 21/03-2026 10:15
+
+
+
+
+
Status ændret: Åben → Under behandling
+
System · 21/03-2026 10:20
+
+
+
+
+
HP'en virker stadig ikke — den anden fick vi op at køre.
+
Jens Jensen · 21/03-2026 10:35
+
+
+
+
+
+
+
+
+
+
+
+ Vis Jinja-kode til at erstatte det nuværende
+
+ Hvornår vil jeg bruge hvilken?
+ 1 (Thread): Bedst når der er blandede deltagere (intern + kunde + system). Nemmest at tilpasse.
+ 2 (Log): Bedst hvis I primært bruger systemet internt — minder om et driftslog og er meget kompakt.
+ 3 (Inbox): Bedst hvis I har et tydeligt skel mellem "kundekorrespondance" og "intern teknikerbesked".
+
+ ⚠
+ Alle printere paa lokationen er nede — paavirker daglig drift.
+
+
+ Printserveren er genstartet uden effekt. Brugerne kan ikke sende printjobs. Fakturaen er der desuden fejl i og skal kontrolleres.
+
+
+ 🕐
+ Sidst aendret af ct — 19/03-2026 08:02
+
+
+
Styrke: første sekund giver teknikeren det vigtigste - ideal til kritiske sager.
+
+
+
+
+
+ Forsog 6
+
Inline Highlight
+
Fri prosa med inline highlight-ord der fanger oejnene hurtigt uden at bryde flyden.
+
+
+
+ Aaben
+ Opgave
+ 19/03-2026 06:31
+ Rediger
+
+
+
+ Printer virker ikke —
+ alle printere paa lokationen er paavirket.
+ Printserveren er genstartet uden effekt og
+ brugerne faar timeout ved udskrift.
+
+
+
+
+
+ Opdatering:
+ Fakturaen indeholder fejl og skal kontrolleres.
+ Ny fejl-rapport tilføjet 19/03-2026 08:15.
+
+
+
+
Styrke: ingen struktur-tvang — fri tekst men med visuel guidning til vigtige ord.
Her er mine 3 bedste forslag samlet med de tidligere forslag fra de forskellige runder i denne samtale. Jeg har også markeret modelkilden pr. forslag.
+
+ Alle forslag i denne samtale er lavet af den samme model: GPT-5.4. Der er derfor ikke flere forskellige modeltyper bag forslagene. Det, jeg har samlet her, er forslag fra forskellige runder/filer, men modelkilden er den samme for dem alle.
+
>CT [06:31] Jeg starter med spooler og netvaerkstjek paa printserveren.
+
~system [06:33] Status sat til under behandling.
+
>Kunde [06:44] Tak. Vi ser timeout paa alle printere lige nu.
+
>CT [06:52] Driver geninstalleret. Test print sendt.
+
✓Kunde [07:01] Det virker nu. Alle kan printe igen!
+
+
+
+ >
+
+ Send
+
+
+
+
+
+
+
+
+
+
+
+ Forslag 9
+
Magazine Spread
+
Redaktionel typografi med stor citat-beskrivelse, metadata-raekke og margin-noter som kommentarfeed.
+
+
+
+
+
+
Telefonsamtale - +4528901815
+
+ aaben
+ opgave
+
+
+ Rediger sag
+
+
+
+
Printer virker ikke — alle printere paa lokationen er ramt og printserveren er genstartet uden effekt. Fakturaen har desuden fejl og skal indgaa i fejlfindingen.
+
Oprettet af Christian Thomas · 06:20 · Scope: 3 printere + 1 server
+
+
+
+
Status
Aaben
+
Prioritet
Hoj
+
Type
Opgave
+
Deadline
11/03
+
+
+
+
Kommentarer
+
+
+
06:31
+
CTTekniker
+
Jeg starter med spooler og netvaerkstjek paa printserveren.
+
+
+
+
06:33
+
System
+
Status sat til under behandling.
+
+
+
+
06:44
+
KundeKontakt
+
Tak. Vi ser timeout paa alle printere lige nu.
+
+
+
+
06:52
+
CTTekniker
+
Driver geninstalleret paa alle klienter. Test print bekraeftet OK.
+
+
+
+
07:01
+
KundeKontakt
+
Det virker nu. Alle kan printe igen - tak for hurtig hjaelp!
+
+
+
+
+
+ Send
+
+
+
+
+
+
+
+
+ Runde 4 — 3 nye forslag
+
+
+
+
+
+ Forslag 10
+
Narrative Focus - Incident Story
+
Historien foldes ud i tydelige afsnit: Situation, Impact, Next Step. Kommentarer holdes korte og menneskelige.
+
+
+
+
+
+
Telefonsamtale - +4528901815
+
+ aaben
+ opgave
+
+
+ Rediger sag
+
+
+
+
Opgavebeskrivelse
+
+
Situation: Printere svarer ikke i hele huset
+
Kl. 06:20 meldte kunden, at alle printere gik i timeout. Printserver er allerede genstartet uden effekt, og faktura-job fejler samtidig.
+
Impact: Fakturering og udskrivning er stoppet for hele lokationen.
+
Next step: Spooler, queue og driver-path valideres i prioriteret raekkefoelge.
+
+
+
+
+
Kommentarer (6)
+
+
CTChristian Thomas06:31
+
Starter med spooler og print queue. Logger hvert trin her i traden.
+
+
+
KUKunde06:44
+
Tak. Alle afdelinger melder stadig timeout ved print.
+
+
+
SYSystem06:33
+
Status sat til under behandling.
+
+
+
+ Send
+
+
+
+
+
+
+
+
+
+
+
+ Forslag 11
+
Narrative Focus - Kunde Perspektiv
+
Samme narrative ramme, men med fokus paa kundeoplevelse og konsekvens for drift, saa prioriteten staer tydeligt.
+
+
+
+
+
+
Telefonsamtale - +4528901815
+
+ aaben
+ opgave
+
+
+ Rediger sag
+
+
+
+
Opgavebeskrivelse
+
+
Kunden kan ikke printe fakturaer
+
Fejlen paavirker baade lager og kontor, da udskrivning stopper i hele lokationen. Fejlen opleves som timeout fra alle klienter.
+
Kritisk nu: Faktura-flowet er forsinket, hvilket rammer dagens afsendelser.
+
Maal: Midlertidig stabil drift inden kl. 08:00, derefter varig root-cause.
+
+
+
+
+
Kommentarer (6)
+
+
KUKunde06:44
+
Vi har 14 ventende printjobs. Kan I prioritere en hurtig workaround?
+
+
+
CTChristian Thomas06:47
+
Ja. Midlertidigt skifter vi spooler profil og tester pa en enkelt printer om 5 min.
+
+
+
SYSystem06:48
+
Prioritet aendret til hoj pga. forretningspaavirkning.
+
+
+
+ Send
+
+
+
+
+
+
+
+
+
+
+
+ Forslag 12
+
Narrative Focus - Teknisk Journal
+
Narrativ beskrivelse med mini-journal i teksten, saa teknikeren kan laese hele progressionen uden at skifte kontekst.
+
+
+
+
+
+
Telefonsamtale - +4528901815
+
+ aaben
+ opgave
+
+
+ Rediger sag
+
+
+
+
Opgavebeskrivelse
+
+
Printerincident med sporbar teknisk journal
+
Der er total printnedbrud i lokationen. Nedenfor er seneste handlinger skrevet ind i selve beskrivelsen, saa teamet hurtigt kan onboarde:
+
+
06:31 - Spooler restart og queue cleanup uden varig effekt
+
06:41 - Driver-konflikt identificeret pa klientprofil
+
06:50 - Midlertidig rollback igangsat for at genskabe drift
+
+
+
+
+
+
Kommentarer (6)
+
+
CTChristian Thomas06:52
+
Rollback koerer nu. Forventer bekræftelse fra kunden indenfor 10 min.
+
+
+
KUKunde07:01
+
Bekraeftet. Faktura-print virker igen paa alle stationer.