1183 lines
48 KiB
Python
1183 lines
48 KiB
Python
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")
|