bmc_hub/app/economy/backend/router.py

1183 lines
48 KiB
Python
Raw Normal View History

import logging
from collections import defaultdict
from datetime import date
from decimal import Decimal
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.core.config import settings
from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/economy", tags=["Economy"])
class BulkIdsRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
class BulkUpdateRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
description: Optional[str] = None
original_hours: Optional[float] = Field(None, gt=0)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkSoftDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
reason: Optional[str] = "Soft deleted from economy queue"
class BulkApproveRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkPrepaidRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
prepaid_card_id: int = Field(..., gt=0)
class BulkSendRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
2026-08-25 01:09:45 +02:00
class SettlementRequest(BaseModel):
"""Validate or complete settlement of selected time entries.
An explicit method deliberately applies to every selected line. Without it,
each line keeps its own selected method.
"""
ids: List[int] = Field(..., min_length=1)
billing_method: Optional[str] = None
prepaid_card_id: Optional[int] = Field(None, gt=0)
fixed_price_agreement_id: Optional[int] = Field(None, gt=0)
VALID_SETTLEMENT_METHODS = {"invoice", "prepaid", "subscription", "internal", "non_billable"}
def _normalise_billing_method(value: Optional[str]) -> str:
method = str(value or "invoice").strip().lower()
if method in {"prepaid_card", "clippekort"}:
return "prepaid"
if method in {"fixed_price", "abonnement"}:
return "subscription"
return method
def _settlement_label(method: str) -> str:
return {
"invoice": "Faktura",
"prepaid": "Klippekort",
"subscription": "Abonnement / fast pris",
"internal": "Intern tid",
"non_billable": "Ikke-fakturerbar tid",
}.get(method, method)
def _ensure_ids(ids: List[int]) -> List[int]:
clean = sorted(set(int(i) for i in ids if int(i) > 0))
if not clean:
raise HTTPException(status_code=400, detail="No valid ids provided")
return clean
2026-08-25 01:09:45 +02:00
def _hours_for_prepaid_card(row: Dict[str, Any], rounding_minutes: int) -> float:
"""Calculate a card debit per registration using the card's own rounding.
Rounding a combined total undercharges short registrations. The actual
duration is therefore rounded individually before the hours are added.
"""
actual_minutes = row.get("faktisk_tid_min")
if actual_minutes is None:
actual_minutes = round(float(row.get("original_hours") or 0) * 60)
actual_minutes = max(0, int(actual_minutes or 0))
if actual_minutes == 0:
return 0.0
block = max(1, int(rounding_minutes or row.get("round_block_min") or 30))
return ((actual_minutes + block - 1) // block * block) / 60.0
@router.get("/time-queue")
async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0),
status: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(500, ge=1, le=2000),
):
"""List non-billed Hub-created time entries for the economy queue."""
try:
conditions = [
"t.vtiger_id IS NULL",
"t.billed_via_thehub_id IS NULL",
2026-08-25 01:09:45 +02:00
"t.economy_order_draft_id IS NULL",
"t.status <> 'billed'",
]
params: List[Any] = []
2026-08-25 01:09:45 +02:00
# A time entry may retain an old customer_id after a case has been
# reassigned. The case is the source of truth whenever it has a customer.
if customer_id is not None:
2026-08-25 01:09:45 +02:00
conditions.append("COALESCE(s.customer_id, effective_customer.hub_customer_id) = %s")
params.append(customer_id)
if status:
conditions.append("t.status = %s")
params.append(status)
if billable is not None:
conditions.append("COALESCE(t.billable, true) = %s")
params.append(billable)
if q:
conditions.append(
"("
"COALESCE(t.description, '') ILIKE %s OR "
2026-08-25 01:09:45 +02:00
"COALESCE(case_customer.name, effective_customer.name, '') ILIKE %s OR "
"COALESCE(c.title, s.titel, '') ILIKE %s"
")"
)
like = f"%{q}%"
params.extend([like, like, like])
where_sql = " AND ".join(conditions)
query = f"""
SELECT
t.id,
2026-08-25 01:09:45 +02:00
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
t.customer_id AS recorded_customer_id,
t.status,
t.entry_status,
t.billable,
t.billing_method,
t.prepaid_card_id,
t.fixed_price_agreement_id,
t.original_hours,
t.approved_hours,
2026-08-25 01:09:45 +02:00
t.faktisk_tid_min,
t.fakturerbar_tid_min,
t.round_block_min,
t.rounded_to,
t.worked_date,
t.description,
t.entry_type,
2026-08-25 01:09:45 +02:00
t.work_type,
t.kilde,
t.case_id,
t.sag_id,
2026-08-25 01:09:45 +02:00
COALESCE(c.title, s.titel, 'Ingen sagstitel') AS case_title,
s.status AS case_status,
s.customer_id AS hub_customer_id,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id,
COALESCE(NULLIF(u.full_name, ''), NULLIF(u.username, ''), NULLIF(t.user_name, ''), 'Ukendt medarbejder') AS employee_name,
CONCAT_WS(' ', NULLIF(cont.first_name, ''), NULLIF(cont.last_name, '')) AS contact_name,
t.created_at,
t.updated_at
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
2026-08-25 01:09:45 +02:00
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
LEFT JOIN LATERAL (
SELECT tc.id
FROM tmodule_customers tc
WHERE tc.hub_customer_id = s.customer_id
ORDER BY tc.id ASC
LIMIT 1
) sag_customer ON s.customer_id IS NOT NULL
LEFT JOIN tmodule_customers effective_customer
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
LEFT JOIN users u ON u.user_id = t.medarbejder_id
LEFT JOIN LATERAL (
SELECT sk.contact_id
FROM sag_kontakter sk
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC NULLS LAST, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN contacts cont ON cont.id = primary_contact.contact_id
WHERE {where_sql}
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
LIMIT %s
"""
params.append(limit)
rows = execute_query(query, tuple(params))
return {"items": rows, "count": len(rows)}
except HTTPException:
raise
except Exception as e:
logger.error("Failed listing economy time queue: %s", e)
raise HTTPException(status_code=500, detail="Failed to list time queue")
@router.get("/time-queue/customers")
async def list_time_queue_customers():
"""List customers that currently have queue-relevant (not billed) Hub entries."""
try:
rows = execute_query(
"""
SELECT
2026-08-25 01:09:45 +02:00
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
COALESCE(case_customer.name, effective_customer.name, CONCAT('Kunde #', COALESCE(s.customer_id, effective_customer.hub_customer_id)::text)) AS customer_name,
COUNT(*)::int AS open_count
FROM tmodule_times t
2026-08-25 01:09:45 +02:00
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
LEFT JOIN LATERAL (
SELECT tc.id
FROM tmodule_customers tc
WHERE tc.hub_customer_id = s.customer_id
ORDER BY tc.id ASC
LIMIT 1
) sag_customer ON s.customer_id IS NOT NULL
LEFT JOIN tmodule_customers effective_customer
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
WHERE COALESCE(s.customer_id, effective_customer.hub_customer_id) IS NOT NULL
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
2026-08-25 01:09:45 +02:00
AND t.economy_order_draft_id IS NULL
AND t.status = 'pending'
2026-08-25 01:09:45 +02:00
GROUP BY COALESCE(s.customer_id, effective_customer.hub_customer_id), case_customer.name, effective_customer.name
ORDER BY COALESCE(case_customer.name, effective_customer.name, CONCAT('Kunde #', COALESCE(s.customer_id, effective_customer.hub_customer_id)::text)) ASC
"""
)
return {"items": rows, "count": len(rows)}
except Exception as e:
logger.error("Failed listing time queue customers: %s", e)
raise HTTPException(status_code=500, detail="Failed listing customer filter options")
@router.get("/time-queue/prepaid-cards")
2026-08-25 01:09:45 +02:00
async def list_prepaid_cards(customer_id: Optional[int] = Query(None, gt=0)):
try:
2026-08-25 01:09:45 +02:00
where_sql = "WHERE status IN ('active', 'depleted')"
params: List[Any] = []
if customer_id is not None:
# Prepaid cards belong to Hub customers (not the historic
# tmodule_customers record stored on a time entry).
where_sql += " AND customer_id = %s"
params.append(customer_id)
cards = execute_query(
2026-08-25 01:09:45 +02:00
f"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours,
remaining_hours, rounding_minutes, status, expires_at
FROM tticket_prepaid_cards
2026-08-25 01:09:45 +02:00
{where_sql}
ORDER BY remaining_hours DESC, id DESC
2026-08-25 01:09:45 +02:00
""",
tuple(params),
)
return {"items": cards, "count": len(cards)}
except Exception as e:
logger.error("Failed listing prepaid cards: %s", e)
raise HTTPException(status_code=500, detail="Failed to list prepaid cards")
@router.patch("/time-queue/bulk-update")
async def bulk_update_time_queue(payload: BulkUpdateRequest):
ids = _ensure_ids(payload.ids)
updates: List[str] = []
values: List[Any] = []
if payload.description is not None:
updates.append("description = %s")
values.append(payload.description)
if payload.original_hours is not None:
updates.append("original_hours = %s")
values.append(payload.original_hours)
if payload.billable is not None:
updates.append("billable = %s")
values.append(payload.billable)
if payload.billable is False and payload.billing_method is None:
updates.append("billing_method = 'internal'")
if payload.billing_method is not None:
updates.append("billing_method = %s")
values.append(payload.billing_method)
if not updates:
raise HTTPException(status_code=400, detail="No update fields provided")
try:
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(updates)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(values + ids))
return {"success": True, "updated": len(ids)}
except Exception as e:
logger.error("Failed bulk update: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk update")
@router.post("/time-queue/bulk-soft-delete")
async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
ids = _ensure_ids(payload.ids)
reason = (payload.reason or "Soft deleted from economy queue").strip()
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'rejected',
entry_status = 'kladde',
approval_note = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
AND status <> 'billed'
""",
tuple([reason] + ids),
)
return {"success": True, "soft_deleted": len(ids)}
except Exception as e:
logger.error("Failed bulk soft delete: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk soft delete")
@router.post("/time-queue/bulk-approve")
async def bulk_approve_time_queue(payload: BulkApproveRequest):
ids = _ensure_ids(payload.ids)
try:
set_parts = [
"status = 'approved'",
"entry_status = 'godkendt'",
"approved_hours = COALESCE(approved_hours, original_hours)",
"approved_at = CURRENT_TIMESTAMP",
"updated_at = CURRENT_TIMESTAMP",
]
params: List[Any] = []
if payload.billable is not None:
set_parts.append("billable = %s")
params.append(payload.billable)
if payload.billing_method is not None:
set_parts.append("billing_method = %s")
params.append(payload.billing_method)
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(set_parts)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(params + ids))
return {"success": True, "approved": len(ids)}
except Exception as e:
logger.error("Failed bulk approve: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk approve")
@router.post("/time-queue/bulk-apply-prepaid")
async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
ids = _ensure_ids(payload.ids)
card = execute_query_single(
"SELECT id FROM tticket_prepaid_cards WHERE id = %s",
(payload.prepaid_card_id,),
)
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET prepaid_card_id = %s,
billing_method = 'prepaid',
billable = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
AND status <> 'billed'
""",
tuple([payload.prepaid_card_id] + ids),
)
return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id}
except Exception as e:
logger.error("Failed applying prepaid card: %s", e)
raise HTTPException(status_code=500, detail="Failed applying prepaid card")
def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
total_hours = Decimal("0")
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
line_total = (qty * hourly_rate).quantize(Decimal("0.01"))
line_payloads.append(
{
"description": group["case_title"],
"quantity": qty,
"line_total": line_total,
"time_entry_ids": ids,
"case_id": group["case_id"],
"sag_id": group["sag_id"],
"time_date": latest_date,
}
)
total_hours += qty
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
vat_rate = Decimal("25.00")
vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
total_amount = subtotal + vat_amount
order_id = execute_insert(
"""
INSERT INTO tmodule_orders
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
VALUES
(%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
customer_id,
customer.get("hub_customer_id"),
total_hours,
hourly_rate,
subtotal,
vat_rate,
vat_amount,
total_amount,
user_id,
),
)
for idx, line in enumerate(line_payloads, start=1):
execute_insert(
"""
INSERT INTO tmodule_order_lines
(order_id, case_id, sag_id, line_number, description, quantity, unit_price,
line_total, time_entry_ids, case_contact, time_date, is_travel)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE)
RETURNING id
""",
(
order_id,
line["case_id"],
line["sag_id"],
idx,
line["description"],
line["quantity"],
hourly_rate,
line["line_total"],
line["time_entry_ids"],
line["time_date"],
),
)
return int(order_id)
2026-08-25 01:09:45 +02:00
def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
"""Create an order draft for the actual Hub customer on the case.
2026-08-25 01:09:45 +02:00
`tmodule_customers` is legacy tracking data and can contain stale names or
outdated mappings. It must never decide which legal customer is invoiced.
"""
hub_customer = execute_query_single(
"""
SELECT id, name, standard_hourly_rate, standard_margin_percent,
special_freight_price, supplier_service_enrolled, invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
if not hub_customer:
raise HTTPException(status_code=404, detail=f"Kunden på sagen ({hub_customer_id}) findes ikke")
2026-08-25 01:09:45 +02:00
customer_name = hub_customer.get("name") or f"Kunde {hub_customer_id}"
hourly_rate = Decimal(str(hub_customer.get("standard_hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
invoice_fee_amount = Decimal(
str(
(hub_customer or {}).get("invoice_fee_amount")
if (hub_customer or {}).get("invoice_fee_amount") is not None
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
)
)
special_freight_price = (hub_customer or {}).get("special_freight_price")
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
standard_margin_percent = Decimal(
str(
(hub_customer or {}).get("standard_margin_percent")
if (hub_customer or {}).get("standard_margin_percent") is not None
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
)
)
base_hourly_rate = Decimal(
str(
(hub_customer or {}).get("standard_hourly_rate")
if (hub_customer or {}).get("standard_hourly_rate") is not None
else hourly_rate
)
)
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
amount = (qty * unit_price).quantize(Decimal("0.01"))
line_payloads.append(
{
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
"source_type": "timequeue",
"source_id": ids[0] if ids else None,
"description": group["case_title"],
"quantity": float(qty),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "timer",
"product_id": None,
"selected": True,
"amount": float(amount),
2026-08-25 01:09:45 +02:00
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": group["sag_id"],
"time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None,
"meta": {
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
"standard_margin_percent": float(effective_margin_percent),
},
}
)
if special_freight_amount > 0:
line_payloads.append(
{
2026-08-25 01:09:45 +02:00
"line_key": f"freight:{hub_customer_id}",
"source_type": "freight",
"source_id": None,
"description": "Særlig fragtpris",
"quantity": 1.0,
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
2026-08-25 01:09:45 +02:00
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
}
)
# Fee line is included by default unless customer-specific value is 0.
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
2026-08-25 01:09:45 +02:00
"line_key": f"invoice_fee:{hub_customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
"quantity": 1.0,
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
2026-08-25 01:09:45 +02:00
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
"meta": {
"standard_margin_percent": float(standard_margin_percent),
"supplier_service_enrolled": supplier_service_enrolled,
},
}
)
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
2026-08-25 01:09:45 +02:00
draft_title = f"Timefaktura {customer_name} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id}"
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
draft_title,
2026-08-25 01:09:45 +02:00
int(hub_customer_id),
json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue",
1,
user_id,
json.dumps({}, ensure_ascii=False),
invoice_aggregate_key,
),
)
if not draft:
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
return int(draft["id"])
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
2026-08-25 01:09:45 +02:00
"""Resolve the actual Hub customer that must be invoiced.
2026-08-25 01:09:45 +02:00
The historic function name remains for callers, but its return value is a
`customers.id`. The case company is authoritative; tracking rows are only
a fallback for time entries without a case.
"""
if sag_id is not None:
try:
sid = int(sag_id)
except (TypeError, ValueError):
sid = None
if sid and sid > 0:
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id:
2026-08-25 01:09:45 +02:00
return int(hub_customer_id)
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct_hub_customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (cid,))
if direct_hub_customer:
return int(direct_hub_customer["id"])
tracking_customer = execute_query_single(
"SELECT hub_customer_id FROM tmodule_customers WHERE id = %s", (cid,)
)
if (tracking_customer or {}).get("hub_customer_id"):
return int(tracking_customer["hub_customer_id"])
return None
2026-08-25 01:09:45 +02:00
def _selected_time_entries(ids: List[int]) -> List[Dict[str, Any]]:
"""Fetch queue-eligible records once, with the context needed for validation."""
placeholders = ",".join(["%s"] * len(ids))
return execute_query(
f"""
SELECT
t.id, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
t.customer_id AS recorded_customer_id, t.case_id, t.sag_id, t.status, t.billable,
t.billing_method, t.prepaid_card_id, t.fixed_price_agreement_id,
t.original_hours, t.approved_hours, t.faktisk_tid_min,
t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description,
COALESCE(c.title, s.titel, 'Tidsregistrering') AS case_title,
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS hub_customer_id,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
LEFT JOIN LATERAL (
SELECT tc.id
FROM tmodule_customers tc
WHERE tc.hub_customer_id = s.customer_id
ORDER BY tc.id ASC
LIMIT 1
) sag_customer ON s.customer_id IS NOT NULL
LEFT JOIN tmodule_customers effective_customer
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
WHERE t.id IN ({placeholders})
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.economy_order_draft_id IS NULL
AND t.status <> 'billed'
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)), t.id
""",
tuple(ids),
) or []
def _active_prepaid_card(card_id: int) -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT id, card_number, customer_id, remaining_hours, rounding_minutes, expires_at
FROM tticket_prepaid_cards
WHERE id = %s AND status = 'active'
AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
""",
(card_id,),
)
def _active_agreement(agreement_id: int) -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT id, agreement_number, customer_id, monthly_hours
FROM customer_fixed_price_agreements
WHERE id = %s AND status = 'active'
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
""",
(agreement_id,),
)
def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
ids = _ensure_ids(payload.ids)
override_method = _normalise_billing_method(payload.billing_method) if payload.billing_method else None
if override_method and override_method not in VALID_SETTLEMENT_METHODS:
raise HTTPException(status_code=400, detail="Ugyldig afregningstype")
rows = _selected_time_entries(ids)
found_ids = {int(row["id"]) for row in rows}
missing_ids = [entry_id for entry_id in ids if entry_id not in found_ids]
items: List[Dict[str, Any]] = []
invoice_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
prepaid_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
subscription_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
errors: List[Dict[str, Any]] = [
{"id": entry_id, "message": "Tiden findes ikke længere i køen"}
for entry_id in missing_ids
]
for row in rows:
method = override_method or _normalise_billing_method(row.get("billing_method"))
hours = float(row.get("approved_hours") or row.get("original_hours") or 0)
item = {
"id": int(row["id"]),
"title": row.get("case_title") or "Tidsregistrering",
"customer_name": row.get("customer_name") or "Ukendt kunde",
"hours": hours,
"method": method,
"method_label": _settlement_label(method),
"valid": True,
"message": None,
}
if method not in VALID_SETTLEMENT_METHODS:
item.update(valid=False, message="Vælg en gyldig afregningstype")
elif hours <= 0:
item.update(valid=False, message="Tiden mangler et positivt timeantal")
elif method == "invoice":
customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not customer_id:
item.update(valid=False, message="Mangler tilknyttet kunde til fakturering")
elif row.get("billable") is False:
item.update(valid=False, message="Intern/ikke-fakturerbar tid kan ikke sendes til faktura")
else:
item["resolved_customer_id"] = customer_id
invoice_groups[int(customer_id)].append(row)
elif method == "prepaid":
card_id = payload.prepaid_card_id or row.get("prepaid_card_id")
card = _active_prepaid_card(int(card_id)) if card_id else None
expected_customer_id = row.get("billing_customer_id") or row.get("hub_customer_id")
if not card:
item.update(valid=False, message="Vælg et aktivt klippekort")
elif expected_customer_id and int(card["customer_id"]) != int(expected_customer_id):
item.update(valid=False, message="Klippekortet tilhører ikke kunden på sagen")
else:
item["prepaid_card_id"] = int(card["id"])
prepaid_groups[int(card["id"])].append(row)
elif method == "subscription":
agreement_id = payload.fixed_price_agreement_id or row.get("fixed_price_agreement_id")
agreement = _active_agreement(int(agreement_id)) if agreement_id else None
expected_customer_id = row.get("billing_customer_id") or row.get("hub_customer_id")
if not agreement:
item.update(valid=False, message="Vælg en aktiv abonnements- eller fastprisaftale")
elif expected_customer_id and int(agreement["customer_id"]) != int(expected_customer_id):
item.update(valid=False, message="Aftalen tilhører ikke kunden på sagen")
else:
item["fixed_price_agreement_id"] = int(agreement["id"])
subscription_groups[int(agreement["id"])].append(row)
if not item["valid"]:
errors.append({"id": item["id"], "message": item["message"]})
items.append(item)
invoice_preview = []
for customer_id, group_rows in invoice_groups.items():
customer = execute_query_single(
"SELECT name, COALESCE(standard_hourly_rate, %s) AS hourly_rate FROM customers WHERE id = %s",
(settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id),
) or {}
hours = sum(
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
for row in group_rows
)
rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)
invoice_preview.append({
"customer_id": customer_id,
"customer_name": customer.get("name") or f"Kunde #{customer_id}",
"entries": [int(row["id"]) for row in group_rows],
"hours": hours,
"hourly_rate": rate,
"amount_ex_vat": round(hours * rate, 2),
})
prepaid_preview = []
for card_id, group_rows in prepaid_groups.items():
card = _active_prepaid_card(card_id) or {}
hours = sum(float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows)
if hours > float(card.get("remaining_hours") or 0):
message = f"Klippekortet mangler {round(hours - float(card.get('remaining_hours') or 0), 2)} timer"
for item in items:
if item.get("prepaid_card_id") == card_id:
item.update(valid=False, message=message)
errors.append({"id": item["id"], "message": message})
prepaid_preview.append({"card_id": card_id, "card_number": card.get("card_number"), "hours": hours, "remaining_hours": float(card.get("remaining_hours") or 0), "rounding_minutes": int(card.get("rounding_minutes") or 0)})
return {
"valid": not errors,
"selected": len(ids),
"items": items,
"errors": errors,
"invoice_groups": invoice_preview,
"prepaid_groups": prepaid_preview,
"subscription_groups": [
{"agreement_id": agreement_id, "entries": [int(row["id"]) for row in group_rows], "hours": sum(float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows)}
for agreement_id, group_rows in subscription_groups.items()
],
}
@router.post("/time-queue/preview-settlement")
async def preview_time_queue_settlement(payload: SettlementRequest):
"""Validate selected time and return a reviewable settlement preview. No writes."""
return _build_settlement_preview(payload)
@router.post("/time-queue/settle")
async def settle_time_queue(payload: SettlementRequest, request: Request):
"""Complete a previously reviewable settlement without mixing payment methods."""
preview = _build_settlement_preview(payload)
if not preview["valid"]:
raise HTTPException(status_code=409, detail={"message": "Ret fejlene før afregning", "preview": preview})
ids = _ensure_ids(payload.ids)
rows = _selected_time_entries(ids)
user_id = getattr(request.state, "user_id", None)
by_method: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for row in rows:
method = _normalise_billing_method(payload.billing_method or row.get("billing_method"))
by_method[method].append(row)
created_drafts = []
settled_ids: List[int] = []
for method, method_rows in by_method.items():
if method == "invoice":
by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in method_rows:
customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if customer_id:
by_customer[int(customer_id)].append(row)
for customer_id, customer_rows in by_customer.items():
draft_id = _create_ordre_draft_from_selected(customer_id, customer_rows, user_id)
customer_entry_ids = [int(row["id"]) for row in customer_rows]
placeholders = ",".join(["%s"] * len(customer_entry_ids))
execute_update(
f"""UPDATE tmodule_times SET status = 'approved', entry_status = 'godkendt',
approved_hours = COALESCE(approved_hours, original_hours), approved_at = CURRENT_TIMESTAMP,
economy_order_draft_id = %s, economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders}) AND status <> 'billed'""",
tuple([draft_id, user_id] + customer_entry_ids),
)
settled_ids.extend(customer_entry_ids)
created_drafts.append({"customer_id": customer_id, "draft_id": draft_id, "entry_ids": customer_entry_ids})
elif method == "prepaid":
by_card: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in method_rows:
card_id = payload.prepaid_card_id or row.get("prepaid_card_id")
by_card[int(card_id)].append(row)
for card_id, card_rows in by_card.items():
card = _active_prepaid_card(card_id)
if not card:
raise HTTPException(status_code=409, detail="Klippekortet er ikke længere aktivt")
hours = sum(
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
for row in card_rows
)
debited = execute_query(
"""UPDATE tticket_prepaid_cards SET used_hours = used_hours + %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND status = 'active' AND remaining_hours >= %s
RETURNING id, remaining_hours""",
(hours, card_id, hours),
)
if not debited:
raise HTTPException(status_code=409, detail="Klippekortet har ikke længere nok timer")
entry_ids = [int(row["id"]) for row in card_rows]
execute_insert(
"""INSERT INTO tticket_prepaid_transactions (card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, 'usage', %s, %s, %s, %s) RETURNING id""",
(card_id, -hours, debited[0]["remaining_hours"], f"Tidskø: {', '.join(map(str, entry_ids))}", user_id),
)
placeholders = ",".join(["%s"] * len(entry_ids))
execute_update(
f"""UPDATE tmodule_times SET status = 'billed', entry_status = 'godkendt', billable = TRUE,
billing_method = 'prepaid', prepaid_card_id = %s, approved_hours = COALESCE(approved_hours, original_hours),
approved_at = CURRENT_TIMESTAMP, economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})""",
tuple([card_id, user_id] + entry_ids),
)
settled_ids.extend(entry_ids)
else:
entry_ids = [int(row["id"]) for row in method_rows]
agreement_id = payload.fixed_price_agreement_id if method == "subscription" else None
if method == "subscription" and agreement_id is None:
agreement_id = method_rows[0].get("fixed_price_agreement_id")
placeholders = ",".join(["%s"] * len(entry_ids))
execute_update(
f"""UPDATE tmodule_times SET status = 'billed', entry_status = 'godkendt',
billable = %s, billing_method = %s, fixed_price_agreement_id = %s,
approved_hours = COALESCE(approved_hours, original_hours), approved_at = CURRENT_TIMESTAMP,
economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})""",
tuple([False, method, agreement_id, user_id] + entry_ids),
)
settled_ids.extend(entry_ids)
return {
"success": True,
"settled_ids": sorted(set(settled_ids)),
"created_drafts": created_drafts,
"orders_url": f"/ordre/{created_drafts[0]['draft_id']}" if len(created_drafts) == 1 else "/ordre",
"message": "Tiderne er afregnet. Ordrekladder er fortsat lokale og skal godkendes fra Ordre.",
}
@router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
2026-08-25 01:09:45 +02:00
# Backwards-compatible endpoint for older clients. It now explicitly uses
# the invoice path rather than silently invoicing whatever method a row had.
return await settle_time_queue(
SettlementRequest(ids=payload.ids, billing_method="invoice"), request
)
ids = _ensure_ids(payload.ids)
user_id = getattr(request.state, "user_id", None)
try:
placeholders = ",".join(["%s"] * len(ids))
rows = execute_query(
f"""
SELECT
t.id,
t.customer_id,
t.case_id,
t.sag_id,
t.status,
t.billable,
t.billing_method,
t.original_hours,
t.approved_hours,
t.worked_date,
COALESCE(c.title, s.titel, 'Time entries') AS case_title
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.id IN ({placeholders})
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status <> 'billed'
""",
tuple(ids),
)
if not rows:
raise HTTPException(status_code=400, detail="No eligible entries found")
# Local order creation must not depend on e-conomic data/mapping.
# Selected entries are converted to local orders regardless of billing method.
selected_order_ids = [int(r["id"]) for r in rows]
if not selected_order_ids:
raise HTTPException(status_code=400, detail="No selected entries found")
placeholders_invoice = ",".join(["%s"] * len(selected_order_ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'approved',
entry_status = 'godkendt',
approved_hours = COALESCE(approved_hours, original_hours),
approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders_invoice})
AND status <> 'billed'
""",
tuple(selected_order_ids),
)
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
skipped_missing_customer: List[int] = []
for row in rows:
if int(row["id"]) not in selected_order_ids:
continue
resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not resolved_customer_id:
skipped_missing_customer.append(int(row["id"]))
continue
rows_by_customer[int(resolved_customer_id)].append(row)
created_drafts = []
failed_customers: List[Dict[str, Any]] = []
for cust_id, cust_rows in rows_by_customer.items():
try:
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
except HTTPException as ex:
failed_customers.append(
{
"customer_id": cust_id,
"entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
"error": str(ex.detail),
}
)
if not created_drafts:
if skipped_missing_customer:
raise HTTPException(
status_code=400,
detail="No local orders created: selected entries are missing customer linkage",
)
if failed_customers:
raise HTTPException(
status_code=400,
detail="No local orders created: customer data is invalid for selected entries",
)
raise HTTPException(status_code=400, detail="No local orders created")
# Time queue must never push directly to e-conomic.
# Orders are created locally and can be transferred manually from Orders page.
draft_ids = [o["draft_id"] for o in created_drafts]
orders_url = "/ordre"
if len(draft_ids) == 1:
orders_url = f"/ordre/{draft_ids[0]}"
return {
"success": True,
"selected": len(ids),
"order_candidates": len(selected_order_ids),
"created_drafts": created_drafts,
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
"skipped_missing_customer": skipped_missing_customer,
"failed_customers": failed_customers,
"orders_url": orders_url,
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed send-to-invoices flow: %s", e)
raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")