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) 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 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", "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", ] params: List[Any] = [] # 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: 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 " "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, 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, t.faktisk_tid_min, t.fakturerbar_tid_min, t.round_block_min, t.rounded_to, t.worked_date, t.description, t.entry_type, t.work_type, t.kilde, t.case_id, t.sag_id, 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 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 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 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 AND t.economy_order_draft_id IS NULL AND t.status = 'pending' 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") async def list_prepaid_cards(customer_id: Optional[int] = Query(None, gt=0)): try: 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( 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 {where_sql} ORDER BY remaining_hours DESC, id DESC """, 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 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 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 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 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) 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. `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") 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), "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( { "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"))), "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( { "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"))), "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") 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, 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]: """Resolve the actual Hub customer that must be invoiced. 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: 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 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): # 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")