import logging from collections import defaultdict from decimal import Decimal 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) 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 @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.status <> 'billed'", ] params: List[Any] = [] if customer_id is not None: conditions.append("t.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(cust.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, t.customer_id, cust.name AS customer_name, 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.rounded_to, t.worked_date, t.description, t.entry_type, t.kilde, t.case_id, t.sag_id, COALESCE(c.title, s.titel, 'No title') AS case_title, t.created_at, t.updated_at FROM tmodule_times t LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id LEFT JOIN tmodule_cases c ON c.id = t.case_id LEFT JOIN sag_sager s ON s.id = t.sag_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 t.customer_id, COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name, COUNT(*)::int AS open_count FROM tmodule_times t LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id WHERE t.customer_id IS NOT NULL AND t.vtiger_id IS NULL AND t.billed_via_thehub_id IS NULL AND t.status = 'pending' GROUP BY t.customer_id, cust.name ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.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(): try: cards = execute_query( """ SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at FROM tticket_prepaid_cards WHERE status IN ('active', 'depleted') ORDER BY remaining_hours DESC, id DESC """ ) 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 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 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 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 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 _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]: """Resolve any incoming customer reference to a valid tmodule_customers.id. Accepts: - direct tmodule customer id - hub customer id (customers.id) via tmodule_customers.hub_customer_id - fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id """ def _find_by_tmodule_id(candidate_id: int) -> Optional[int]: row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,)) return int(row["id"]) if row else None def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]: row = execute_query_single( """ SELECT id FROM tmodule_customers WHERE hub_customer_id = %s ORDER BY id ASC LIMIT 1 """, (hub_customer_id,), ) return int(row["id"]) if row else None if raw_customer_id is not None: try: cid = int(raw_customer_id) except (TypeError, ValueError): cid = None if cid and cid > 0: direct = _find_by_tmodule_id(cid) if direct: return direct mapped = _find_by_hub_customer_id(cid) if mapped: return mapped 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: mapped = _find_by_hub_customer_id(int(hub_customer_id)) if mapped: return mapped return None @router.post("/time-queue/send-to-invoices") async def send_selected_to_invoices(payload: BulkSendRequest, request: 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_orders = [] failed_customers: List[Dict[str, Any]] = [] for cust_id, cust_rows in rows_by_customer.items(): try: order_id = _create_order_from_selected(cust_id, cust_rows, user_id) created_orders.append({"customer_id": cust_id, "order_id": order_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_orders: 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. order_ids = [o["order_id"] for o in created_orders] orders_url = "/ordre" if len(order_ids) == 1: orders_url = f"/ordre/{order_ids[0]}" return { "success": True, "selected": len(ids), "order_candidates": len(selected_order_ids), "created_orders": created_orders, "skipped_missing_customer": skipped_missing_customer, "failed_customers": failed_customers, "orders_url": orders_url, "message": "Lokale ordrer oprettet. Overfoer til e-conomic fra Ordre-siden.", } 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")