531 lines
18 KiB
Python
531 lines
18 KiB
Python
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")
|