1518 lines
69 KiB
Python
1518 lines
69 KiB
Python
from app.services.invoice_fee_service import with_invoice_fee, invoice_fee_line
|
|
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
|
|
prepaid_card_id: Optional[int] = Field(None, gt=0)
|
|
fixed_price_agreement_id: Optional[int] = Field(None, gt=0)
|
|
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)
|
|
line_mode: str = Field(default="separate", pattern="^(separate|combined)$")
|
|
|
|
|
|
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),
|
|
case_state: Optional[str] = Query(None),
|
|
billable: Optional[bool] = Query(None),
|
|
q: Optional[str] = Query(None),
|
|
limit: int = Query(500, ge=1, le=2000),
|
|
scope: Optional[str] = Query(None, pattern="^(active|history|all)$"),
|
|
request: Request = None,
|
|
):
|
|
"""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'",
|
|
]
|
|
if scope in {'active', 'history', 'all'}:
|
|
conditions = ["t.vtiger_id IS NULL", "t.status <> 'rejected'"]
|
|
if scope == 'active':
|
|
conditions += ["t.billed_via_thehub_id IS NULL", "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", "t.economy_settled_at IS NULL"]
|
|
elif scope == 'history':
|
|
conditions.append("(t.status IN ('approved','billed') OR t.economy_order_draft_id IS NOT NULL OR t.billed_via_thehub_id IS NOT NULL OR t.economy_settled_at IS NOT NULL)")
|
|
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 case_state == "closed":
|
|
conditions.append("LOWER(TRIM(COALESCE(s.status, ''))) IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')")
|
|
elif case_state == "open":
|
|
conditions.append("s.id IS NOT NULL AND LOWER(TRIM(COALESCE(s.status, ''))) NOT IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')")
|
|
elif case_state == "no_case":
|
|
conditions.append("s.id IS NULL")
|
|
elif case_state:
|
|
raise HTTPException(status_code=400, detail="Ugyldigt sagsfilter")
|
|
|
|
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,
|
|
t.workflow_revision,
|
|
t.economy_order_draft_id, t.economy_settled_at, t.billed_via_thehub_id,
|
|
draft.sync_status AS order_sync_status,
|
|
draft.economic_invoice_number
|
|
FROM tmodule_times t
|
|
LEFT JOIN ordre_drafts draft ON draft.id = t.economy_order_draft_id
|
|
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))
|
|
_attach_case_tags(rows)
|
|
if settings.WORKFLOWS_ENABLED:
|
|
_attach_workflow_decisions(rows)
|
|
if scope in {"active", "history", "all"}:
|
|
from app.core.auth_dependencies import get_current_user
|
|
actor = await _queue_actor(request) if request is not None else {}
|
|
permissions = actor.get('permissions') or []
|
|
can_override = actor.get('is_superadmin') or all(p in permissions for p in ('workflows.override','workflows.decisions.view'))
|
|
for row in rows:
|
|
row.update(_queue_lifecycle(row))
|
|
row['entry_fingerprint'] = _queue_review_fingerprint(row)
|
|
row['workflow_can_override'] = bool(can_override and row['can_change_billing'])
|
|
can_approve = bool(actor.get('is_superadmin') or 'billing.approve' in permissions)
|
|
if row.get('workflow_active'):
|
|
can_approve = can_approve and bool(actor.get('is_superadmin') or 'workflows.settle' in permissions)
|
|
row['can_approve'] = bool(row['can_approve'] and can_approve)
|
|
row['can_settle'] = bool(row['can_settle'] and can_approve)
|
|
row['can_change_billing'] = bool(row['can_change_billing'] and (can_override if row.get('workflow_active') else actor.get('is_superadmin') or 'billing.edit' in permissions))
|
|
return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED}
|
|
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, request: Request = None):
|
|
if {'prepaid_card_id', 'fixed_price_agreement_id'}.intersection(payload.model_fields_set):
|
|
from app.core.database import atomic_transaction
|
|
from app.core.auth_dependencies import require_permission
|
|
actor = await _queue_actor(request)
|
|
await require_permission('billing.edit')(actor)
|
|
with atomic_transaction():
|
|
execute_query('SELECT id FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (_ensure_ids(payload.ids),))
|
|
return await _bulk_update_time_queue(payload)
|
|
return await _bulk_update_time_queue(payload)
|
|
|
|
|
|
async def _bulk_update_time_queue(payload: BulkUpdateRequest):
|
|
ids = _ensure_ids(payload.ids)
|
|
if payload.billing_method is not None or payload.billable is not None or {"prepaid_card_id","fixed_price_agreement_id"}.intersection(payload.model_fields_set):
|
|
_protect_workflow_selection(ids)
|
|
|
|
# Financial choices are frozen once the employee's registration is reviewed.
|
|
financial_edit = bool({'prepaid_card_id','fixed_price_agreement_id'}.intersection(payload.model_fields_set))
|
|
if financial_edit:
|
|
current = execute_query('SELECT id,status,economy_order_draft_id,economy_settled_at,billed_via_thehub_id FROM tmodule_times WHERE id=ANY(%s)', (ids,)) or []
|
|
if len(current) != len(ids) or any(row.get('status') in {'approved','billed','rejected'} or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('billed_via_thehub_id') for row in current):
|
|
raise HTTPException(409, 'Afregning kan kun ændres før godkendelse')
|
|
if payload.billing_method is not None and _normalise_billing_method(payload.billing_method) not in VALID_SETTLEMENT_METHODS | {'draft','operations','development'}:
|
|
raise HTTPException(422, 'Ugyldig afregningsmetode')
|
|
|
|
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 "prepaid_card_id" in payload.model_fields_set:
|
|
updates.append("prepaid_card_id = %s")
|
|
values.append(payload.prepaid_card_id)
|
|
if "fixed_price_agreement_id" in payload.model_fields_set:
|
|
updates.append("fixed_price_agreement_id = %s")
|
|
values.append(payload.fixed_price_agreement_id)
|
|
|
|
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'
|
|
{"AND status NOT IN ('approved','rejected') AND economy_settled_at IS NULL" if financial_edit else ""}
|
|
RETURNING id
|
|
"""
|
|
if financial_edit:
|
|
changed = execute_query(query, tuple(values + ids)) or []
|
|
if len(changed) != len(ids):
|
|
raise HTTPException(409, "En tid blev ændret under behandlingen. Genindlæs køen.")
|
|
else:
|
|
execute_update(query, tuple(values + ids))
|
|
return {"success": True, "updated": len(ids)}
|
|
except HTTPException:
|
|
raise
|
|
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)
|
|
_protect_workflow_selection(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)
|
|
_protect_workflow_selection(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
|
|
|
|
fee = invoice_fee_line(customer.get("hub_customer_id"))
|
|
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
|
|
if fee:
|
|
subtotal += Decimal(str(fee["amount"]))
|
|
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"],
|
|
),
|
|
)
|
|
|
|
if fee:
|
|
execute_insert("""INSERT INTO tmodule_order_lines
|
|
(order_id, line_number, description, quantity, unit_price, line_total,
|
|
time_entry_ids, product_number, product_id, source_type)
|
|
VALUES (%s, %s, %s, 1, %s, %s, %s, %s, %s, 'invoice_fee') RETURNING id""",
|
|
(order_id, len(line_payloads) + 1, fee['description'], fee['unit_price'],
|
|
fee['amount'], [], fee['product_number'], fee['product_id']))
|
|
|
|
return int(order_id)
|
|
|
|
|
|
def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int], *, line_mode: Optional[str] = None) -> 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, invoice_fee_exempt
|
|
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))
|
|
|
|
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}"
|
|
if line_mode == "separate":
|
|
group_key = str(row['id'])
|
|
elif line_mode == "combined":
|
|
group_key = str(row.get('workflow_product_number') or '')
|
|
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"))
|
|
workflow_product_number = group['rows'][0].get('workflow_product_number')
|
|
workflow_product_id = None
|
|
if line_mode is not None and workflow_product_number:
|
|
products = execute_query('''SELECT id FROM products WHERE deleted_at IS NULL
|
|
AND status='active' AND lifecycle_status NOT IN ('inactive', 'phasing_out')
|
|
AND is_active_in_economic IS DISTINCT FROM FALSE
|
|
AND (economic_product_number=%s OR sku_internal=%s)''',
|
|
(workflow_product_number, workflow_product_number)) or []
|
|
if len(products) != 1:
|
|
raise HTTPException(409, 'Workflowets varenummer skal være knyttet til præcis én aktiv Hub-vare')
|
|
workflow_product_id = int(products[0]['id'])
|
|
|
|
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": workflow_product_id,
|
|
"product_number": workflow_product_number,
|
|
"economic_product_number": workflow_product_number,
|
|
"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,
|
|
}
|
|
)
|
|
|
|
line_payloads = with_invoice_fee(line_payloads, hub_customer_id, legacy=True, customer=hub_customer)
|
|
|
|
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,
|
|
s.status AS case_status,
|
|
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)
|
|
if settings.WORKFLOWS_ENABLED:
|
|
_attach_workflow_decisions(rows)
|
|
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"))
|
|
workflow_decision = row.get('workflow_decision')
|
|
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",
|
|
"case_status": row.get("case_status"),
|
|
"customer_name": row.get("customer_name") or "Ukendt kunde",
|
|
"hours": hours,
|
|
"actual_hours": float(row.get("faktisk_tid_min") or round(float(row.get("original_hours") or 0) * 60)) / 60.0,
|
|
"method": method,
|
|
"method_label": _settlement_label(method),
|
|
"valid": True,
|
|
"message": None,
|
|
}
|
|
if row.get('workflow_active') and (not workflow_decision or workflow_decision.get('status') not in {'decided','overridden'}):
|
|
item.update(valid=False, message="Afventer workflowbeslutning eller manuel vurdering")
|
|
elif row.get('workflow_stale'):
|
|
item.update(valid=False, message="Workflowbeslutningen skal genberegnes")
|
|
elif 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 {}
|
|
# Invoice quantities must match the draft that will be created below.
|
|
# Per-entry rounding only applies when debiting a prepaid card.
|
|
hours = sum(
|
|
float(row.get("approved_hours") or row.get("original_hours") or 0)
|
|
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."""
|
|
if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active':
|
|
from app.workflows.integration import active_for_customer
|
|
from app.workflows.settlement import workflow_customer, settle_workflow_entries
|
|
scoped = [active_for_customer(workflow_customer(tid)) for tid in payload.ids]
|
|
if any(scoped):
|
|
if not all(scoped):
|
|
raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig')
|
|
if payload.billing_method or payload.prepaid_card_id or payload.fixed_price_agreement_id:
|
|
raise HTTPException(409, 'Brug begrundet overstyring af workflowbeslutningen før afregning')
|
|
from app.core.auth_dependencies import get_current_user, require_permission
|
|
actor = await get_current_user(request, None)
|
|
await require_permission('workflows.settle')(actor)
|
|
return settle_workflow_entries(payload.ids, actor['id'], payload.line_mode)
|
|
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")
|
|
|
|
|
|
def _protect_workflow_selection(ids):
|
|
if not (settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE=='active'):
|
|
return
|
|
from app.workflows.integration import active_for_customer
|
|
from app.workflows.settlement import workflow_customer
|
|
if any(active_for_customer(workflow_customer(tid)) for tid in ids):
|
|
raise HTTPException(409, 'Brug workflowbeslutningens begrundede overstyring og afregning')
|
|
|
|
|
|
def _attach_case_tags(rows):
|
|
"""Load tags once for all visible cases, including legacy case-only tags."""
|
|
case_ids = sorted({int(row['sag_id']) for row in rows if row.get('sag_id')})
|
|
by_case = {}
|
|
if case_ids:
|
|
tags = execute_query('''SELECT et.entity_id AS sag_id,t.id,t.name,t.color,t.icon
|
|
FROM entity_tags et JOIN tags t ON t.id=et.tag_id
|
|
WHERE et.entity_type='case' AND et.entity_id=ANY(%s)
|
|
ORDER BY t.type,t.name,t.id''', (case_ids,)) or []
|
|
for tag in tags:
|
|
by_case.setdefault(int(tag['sag_id']), []).append({key: tag.get(key) for key in ('id','name','color','icon')})
|
|
missing = [case_id for case_id in case_ids if case_id not in by_case]
|
|
if missing:
|
|
legacy = execute_query('SELECT sag_id,id,tag_navn AS name FROM sag_tags WHERE sag_id=ANY(%s) AND deleted_at IS NULL ORDER BY tag_navn,id', (missing,)) or []
|
|
for tag in legacy:
|
|
by_case.setdefault(int(tag['sag_id']), []).append({'id':tag['id'],'name':tag['name'],'color':None,'icon':None})
|
|
for row in rows:
|
|
row['case_tags'] = by_case.get(int(row['sag_id']), []) if row.get('sag_id') else []
|
|
|
|
|
|
def _attach_workflow_decisions(rows):
|
|
if not rows:
|
|
return
|
|
from app.workflows.integration import active_for_customer
|
|
ids=[int(row['id']) for row in rows]
|
|
decisions=execute_query('''SELECT d.*,t.workflow_revision AS entry_revision FROM bmc_billing_decisions d
|
|
JOIN tmodule_times t ON t.id=d.time_id WHERE d.time_id=ANY(%s) AND d.is_current''',(ids,)) or []
|
|
by_id={int(d['time_id']):d for d in decisions}
|
|
customer_activation = {}
|
|
for row in rows:
|
|
decision=by_id.get(int(row['id']))
|
|
row['workflow_decision']=decision
|
|
customer_id = row.get('billing_customer_id') or row.get('customer_id')
|
|
if customer_id not in customer_activation:
|
|
customer_activation[customer_id] = active_for_customer(customer_id)
|
|
row['workflow_active']=customer_activation[customer_id]
|
|
row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision'])
|
|
if row['workflow_active'] and decision and decision.get('method') and not (row.get('economy_settled_at') or row.get('economy_order_draft_id') or row.get('billed_via_thehub_id')):
|
|
row['billing_method']=decision['method']
|
|
row['billable']=decision['method'] in ('invoice','prepaid')
|
|
config=(decision.get('details') or {}).get('billing') or (decision.get('details') or {}).get('decision') or {}
|
|
row['prepaid_card_id']=config.get('card_id')
|
|
row['fixed_price_agreement_id']=config.get('agreement_id')
|
|
|
|
|
|
class ReviewedTimeSnapshot(BaseModel):
|
|
id: int = Field(..., gt=0)
|
|
updated_at: str
|
|
entry_fingerprint: str
|
|
billing_method: str
|
|
prepaid_card_id: Optional[int] = None
|
|
fixed_price_agreement_id: Optional[int] = None
|
|
workflow_revision: Optional[int] = None
|
|
workflow_decision_id: Optional[int] = None
|
|
|
|
|
|
class ReviewedTimeRequest(BaseModel):
|
|
model_config = {'extra': 'forbid'}
|
|
ids: List[int] = Field(..., min_length=1)
|
|
snapshots: List[ReviewedTimeSnapshot] = Field(..., min_length=1)
|
|
line_mode: str = Field(default='separate', pattern='^(separate|combined)$')
|
|
|
|
|
|
def _queue_lifecycle(row):
|
|
method = _normalise_billing_method(row.get('billing_method'))
|
|
sent = bool(row.get('economy_order_draft_id') or row.get('billed_via_thehub_id'))
|
|
invoice = bool(row.get('economic_invoice_number')) or row.get('order_sync_status') in {'posted', 'paid'}
|
|
settled = bool(row.get('economy_settled_at') or row.get('status') == 'billed')
|
|
if method != 'invoice' and settled:
|
|
stage, label = 'settled', 'Afregnet'
|
|
elif invoice:
|
|
stage, label = 'invoiced', 'Faktureret'
|
|
elif sent:
|
|
stage, label = 'sent', 'Sendt til fakturering'
|
|
elif row.get('status') == 'approved':
|
|
stage, label = 'approved', 'Godkendt'
|
|
elif row.get('status') == 'billed':
|
|
# Legacy "billed" alone does not establish a verified invoice.
|
|
stage, label = 'settled', 'Afregnet'
|
|
else:
|
|
stage, label = 'pending', 'Afventer godkendelse'
|
|
return dict(lifecycle_status=stage, lifecycle_label=label,
|
|
can_approve=stage == 'pending' and row.get('status') != 'rejected',
|
|
can_change_billing=stage == 'pending' and row.get('status') != 'rejected',
|
|
can_settle=stage in {'pending', 'approved'} and row.get('status') != 'rejected',
|
|
order_url=f"/ordre/{int(row['economy_order_draft_id'])}" if row.get('economy_order_draft_id') else None)
|
|
|
|
|
|
def _validate_reviewed_rows(payload, raw):
|
|
ids = _ensure_ids(payload.ids)
|
|
snapshots = {item.id: item for item in payload.snapshots}
|
|
if len(snapshots) != len(payload.snapshots) or set(snapshots) != set(ids):
|
|
raise HTTPException(422, 'Der skal være præcis én gennemgået registrering for hver valgt tid')
|
|
if {int(row['id']) for row in raw} != set(ids):
|
|
raise HTTPException(404, 'En tidsregistrering findes ikke længere')
|
|
if any(row.get('vtiger_id') or row.get('billed_via_thehub_id') or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('status') in {'billed', 'rejected'} for row in raw):
|
|
raise HTTPException(409, 'En valgt tid er allerede behandlet eller afvist. Genindlæs køen.')
|
|
selected = _selected_time_entries(ids)
|
|
if settings.WORKFLOWS_ENABLED:
|
|
_attach_workflow_decisions(selected)
|
|
by_id = {int(row['id']): row for row in selected}
|
|
for row in raw:
|
|
snapshot = snapshots[int(row['id'])]
|
|
current = by_id.get(int(row['id']))
|
|
if not current:
|
|
raise HTTPException(409, 'Tiden findes ikke længere i køen')
|
|
if snapshot.entry_fingerprint != _queue_review_fingerprint(current):
|
|
raise HTTPException(409, 'Tid, kunde eller beskrivelse er ændret. Genindlæs køen.')
|
|
# Compare the database timestamp, not a rounded display value.
|
|
timestamp = row.get('updated_at')
|
|
from datetime import datetime
|
|
try:
|
|
reviewed_stamp = datetime.fromisoformat(snapshot.updated_at.replace('Z', '+00:00'))
|
|
if isinstance(timestamp, str):
|
|
timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(409, 'Genindlæs tiden før godkendelse')
|
|
if timestamp != reviewed_stamp:
|
|
raise HTTPException(409, 'Tiden er ændret siden gennemgangen. Genindlæs køen.')
|
|
for key in ('billing_method', 'prepaid_card_id', 'fixed_price_agreement_id'):
|
|
if getattr(snapshot, key) != current.get(key):
|
|
raise HTTPException(409, 'Afregningsvalget er ændret. Genindlæs køen.')
|
|
if current.get('workflow_active') and snapshot.workflow_revision != int(row.get('workflow_revision') or 0):
|
|
raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.')
|
|
if current.get('workflow_active') and snapshot.workflow_decision_id != (current.get('workflow_decision') or {}).get('id'):
|
|
raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.')
|
|
if current.get('workflow_active') and current.get('workflow_stale'):
|
|
raise HTTPException(409, 'Workflowbeslutningen er forældet')
|
|
for current in selected:
|
|
if current.get('workflow_active'):
|
|
from app.workflows.settlement import current_decision
|
|
from app.workflows.service import load_time_context
|
|
from app.workflows.classification import enrich_time_context
|
|
from app.core.database import _transaction_connection
|
|
from psycopg2.extras import RealDictCursor
|
|
current_decision(current['id'], by_id[current['id']].get('workflow_decision', {}).get('entry_revision'))
|
|
with _transaction_connection.get().cursor(cursor_factory=RealDictCursor) as cursor:
|
|
context = enrich_time_context(cursor, load_time_context(current['id'], cursor))
|
|
if context.get('pending_billing_tag_suggestions'):
|
|
raise HTTPException(409, 'Afvent godkendelse eller afvisning af relevante tags')
|
|
preview = _build_settlement_preview(SettlementRequest(ids=ids, line_mode=payload.line_mode))
|
|
if not preview['valid']:
|
|
raise HTTPException(409, {'message': 'Ret fejlene før godkendelse', 'preview': preview})
|
|
return selected
|
|
|
|
|
|
@router.post('/time-queue/approve-reviewed')
|
|
async def approve_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request):
|
|
from app.core.auth_dependencies import get_current_user, require_permission
|
|
from app.core.database import atomic_transaction
|
|
actor = await _queue_actor(request)
|
|
await require_permission('billing.approve')(actor)
|
|
ids = _ensure_ids(payload.ids)
|
|
with atomic_transaction():
|
|
raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or []
|
|
rows = _validate_reviewed_rows(payload, raw)
|
|
if any(row.get('workflow_active') for row in rows):
|
|
await require_permission('workflows.settle')(actor)
|
|
changed = [int(row['id']) for row in raw if row.get('status') != 'approved']
|
|
if changed:
|
|
execute_update("""UPDATE tmodule_times SET status='approved',entry_status='godkendt',
|
|
approved_hours=COALESCE(approved_hours,original_hours), approved_at=now(),approved_by=%s,
|
|
updated_at=now(),aktiv_timer=false WHERE id=ANY(%s)""", (actor['id'], changed))
|
|
return {'success': True, 'approved': len(changed), 'already_approved': len(ids)-len(changed),
|
|
'message': 'Tiderne er godkendt. Der er ikke trukket timer eller oprettet ordrekladder.'}
|
|
|
|
|
|
@router.post('/time-queue/send-reviewed')
|
|
async def send_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request):
|
|
"""Revalidate the reviewed selection under locks before any local financial effects."""
|
|
from app.core.auth_dependencies import get_current_user, require_permission
|
|
from app.core.database import atomic_transaction
|
|
from app.workflows.integration import active_for_customer
|
|
from app.workflows.settlement import workflow_customer, settle_workflow_entries
|
|
actor = await _queue_actor(request)
|
|
await require_permission('billing.approve')(actor)
|
|
ids = _ensure_ids(payload.ids)
|
|
scoped = [active_for_customer(workflow_customer(tid)) for tid in ids] if settings.WORKFLOWS_ENABLED else [False]*len(ids)
|
|
if any(scoped):
|
|
if not all(scoped):
|
|
raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig')
|
|
await require_permission('workflows.settle')(actor)
|
|
return settle_workflow_entries(ids, actor['id'], payload.line_mode,
|
|
review_validator=lambda raw: _validate_reviewed_rows(payload, raw))
|
|
with atomic_transaction():
|
|
raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or []
|
|
rows = _validate_reviewed_rows(payload, raw)
|
|
if any(row.get('workflow_active') for row in rows):
|
|
raise HTTPException(409, 'Workflowaktivering er ændret. Genindlæs køen.')
|
|
return await settle_time_queue(SettlementRequest(ids=ids, line_mode=payload.line_mode), request)
|
|
|
|
|
|
async def _queue_actor(request):
|
|
from app.core.auth_dependencies import get_current_user
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
header = request.headers.get('authorization', '')
|
|
credentials = None
|
|
if header.lower().startswith('bearer '):
|
|
credentials = HTTPAuthorizationCredentials(scheme='Bearer', credentials=header[7:].strip())
|
|
return await get_current_user(request, credentials)
|
|
|
|
|
|
def _queue_review_fingerprint(row):
|
|
import hashlib
|
|
fields = ('billing_method','prepaid_card_id','fixed_price_agreement_id','original_hours',
|
|
'approved_hours','faktisk_tid_min','fakturerbar_tid_min','round_block_min',
|
|
'worked_date','description','billing_customer_id','sag_id','case_id','billable')
|
|
values = {}
|
|
for field in fields:
|
|
value = row.get(field)
|
|
if field in {'original_hours','approved_hours'} and value is not None:
|
|
value = str(Decimal(str(value)).normalize())
|
|
elif field == 'worked_date' and value is not None:
|
|
value = str(value)
|
|
values[field] = value
|
|
encoded = json.dumps(values, sort_keys=True, default=str, ensure_ascii=False, separators=(',', ':'))
|
|
return hashlib.sha256(encoded.encode('utf-8')).hexdigest()
|