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 from app.timetracking.backend.economic_export import economic_service from app.timetracking.backend.models import TModuleEconomicExportRequest 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) @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") # Ensure selected invoice candidates are approved and invoice-billable. selected_invoice_ids = [ int(r["id"]) for r in rows if bool(r.get("billable", True)) and (r.get("billing_method") or "invoice") == "invoice" ] if not selected_invoice_ids: raise HTTPException(status_code=400, detail="No selected entries are invoice-billable") placeholders_invoice = ",".join(["%s"] * len(selected_invoice_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_invoice_ids), ) rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list) for row in rows: if int(row["id"]) in selected_invoice_ids: rows_by_customer[int(row["customer_id"])].append(row) export_results = [] for cust_id, cust_rows in rows_by_customer.items(): order_id = _create_order_from_selected(cust_id, cust_rows, user_id) export_result = await economic_service.export_order( TModuleEconomicExportRequest(order_id=order_id, force=False), user_id=user_id, ) export_results.append( { "customer_id": cust_id, "order_id": order_id, "success": bool(export_result.success), "dry_run": bool(export_result.dry_run), "message": export_result.message, "economic_draft_id": export_result.economic_draft_id, "economic_order_number": export_result.economic_order_number, } ) return { "success": True, "selected": len(ids), "invoice_candidates": len(selected_invoice_ids), "exports": export_results, } 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")