2026-10-08 08:58:02 +02:00
from app . services . invoice_fee_service import with_invoice_fee , invoice_fee_line
2026-05-04 16:24:38 +02:00
import logging
from collections import defaultdict
2026-05-06 07:01:43 +02:00
from datetime import date
2026-05-04 16:24:38 +02:00
from decimal import Decimal
2026-05-06 07:01:43 +02:00
import json
2026-05-04 16:24:38 +02:00
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 )
2026-08-25 01:09:45 +02:00
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 )
2026-10-08 18:00:47 +02:00
line_mode : str = Field ( default = " separate " , pattern = " ^(separate|combined)$ " )
2026-08-25 01:09:45 +02:00
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 )
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
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
2026-05-04 16:24:38 +02:00
@router.get ( " /time-queue " )
async def list_hub_time_queue (
customer_id : Optional [ int ] = Query ( None , gt = 0 ) ,
status : Optional [ str ] = Query ( None ) ,
2026-09-24 09:12:25 +02:00
case_state : Optional [ str ] = Query ( None ) ,
2026-05-04 16:24:38 +02:00
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 " ,
2026-08-25 01:09:45 +02:00
" t.economy_order_draft_id IS NULL " ,
2026-05-04 16:24:38 +02:00
" t.status <> ' billed ' " ,
]
params : List [ Any ] = [ ]
2026-08-25 01:09:45 +02:00
# 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.
2026-05-04 16:24:38 +02:00
if customer_id is not None :
2026-08-25 01:09:45 +02:00
conditions . append ( " COALESCE(s.customer_id, effective_customer.hub_customer_id) = %s " )
2026-05-04 16:24:38 +02:00
params . append ( customer_id )
if status :
conditions . append ( " t.status = %s " )
params . append ( status )
2026-09-24 09:12:25 +02:00
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 " )
2026-05-04 16:24:38 +02:00
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 "
2026-08-25 01:09:45 +02:00
" COALESCE(case_customer.name, effective_customer.name, ' ' ) ILIKE %s OR "
2026-05-04 16:24:38 +02:00
" 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 ,
2026-08-25 01:09:45 +02:00
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 ,
2026-05-04 16:24:38 +02:00
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 ,
2026-08-25 01:09:45 +02:00
t . faktisk_tid_min ,
t . fakturerbar_tid_min ,
t . round_block_min ,
2026-05-04 16:24:38 +02:00
t . rounded_to ,
t . worked_date ,
t . description ,
t . entry_type ,
2026-08-25 01:09:45 +02:00
t . work_type ,
2026-05-04 16:24:38 +02:00
t . kilde ,
t . case_id ,
t . sag_id ,
2026-08-25 01:09:45 +02:00
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 ,
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
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
2026-05-04 16:24:38 +02:00
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 ) )
2026-10-09 00:45:21 +02:00
_attach_case_tags ( rows )
2026-10-08 18:00:47 +02:00
if settings . WORKFLOWS_ENABLED :
_attach_workflow_decisions ( rows )
return { " items " : rows , " count " : len ( rows ) , " workflow_enabled " : settings . WORKFLOWS_ENABLED }
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
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 ,
2026-05-04 16:24:38 +02:00
COUNT ( * ) : : int AS open_count
FROM tmodule_times t
2026-08-25 01:09:45 +02:00
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
2026-05-04 16:24:38 +02:00
AND t . vtiger_id IS NULL
AND t . billed_via_thehub_id IS NULL
2026-08-25 01:09:45 +02:00
AND t . economy_order_draft_id IS NULL
2026-05-04 16:24:38 +02:00
AND t . status = ' pending '
2026-08-25 01:09:45 +02:00
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
2026-05-04 16:24:38 +02:00
"""
)
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 " )
2026-08-25 01:09:45 +02:00
async def list_prepaid_cards ( customer_id : Optional [ int ] = Query ( None , gt = 0 ) ) :
2026-05-04 16:24:38 +02:00
try :
2026-08-25 01:09:45 +02:00
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 )
2026-05-04 16:24:38 +02:00
cards = execute_query (
2026-08-25 01:09:45 +02:00
f """
SELECT id , card_number , customer_id , purchased_hours AS total_hours , used_hours ,
remaining_hours , rounding_minutes , status , expires_at
2026-05-04 16:24:38 +02:00
FROM tticket_prepaid_cards
2026-08-25 01:09:45 +02:00
{ where_sql }
2026-05-04 16:24:38 +02:00
ORDER BY remaining_hours DESC , id DESC
2026-08-25 01:09:45 +02:00
""" ,
tuple ( params ) ,
2026-05-04 16:24:38 +02:00
)
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 )
2026-10-08 18:00:47 +02:00
if payload . billing_method is not None or payload . billable is not None :
_protect_workflow_selection ( ids )
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
2026-05-04 16:24:38 +02:00
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 )
2026-10-08 18:00:47 +02:00
_protect_workflow_selection ( ids )
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
2026-05-04 16:24:38 +02:00
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 )
2026-10-08 18:00:47 +02:00
_protect_workflow_selection ( ids )
2026-05-04 16:24:38 +02:00
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
2026-08-25 01:09:45 +02:00
AND economy_order_draft_id IS NULL
2026-05-04 16:24:38 +02:00
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
2026-10-08 08:58:02 +02:00
fee = invoice_fee_line ( customer . get ( " hub_customer_id " ) )
2026-05-04 16:24:38 +02:00
subtotal = ( total_hours * hourly_rate ) . quantize ( Decimal ( " 0.01 " ) )
2026-10-08 08:58:02 +02:00
if fee :
subtotal + = Decimal ( str ( fee [ " amount " ] ) )
2026-05-04 16:24:38 +02:00
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 " ] ,
) ,
)
2026-10-08 08:58:02 +02:00
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 ' ] ) )
2026-05-04 16:24:38 +02:00
return int ( order_id )
2026-10-08 18:00:47 +02:00
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 :
2026-08-25 01:09:45 +02:00
""" Create an order draft for the actual Hub customer on the case.
2026-05-06 07:01:43 +02:00
2026-08-25 01:09:45 +02:00
` 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 ,
2026-10-08 08:58:02 +02:00
special_freight_price , supplier_service_enrolled , invoice_fee_amount , invoice_fee_exempt
2026-08-25 01:09:45 +02:00
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 " )
2026-05-06 07:01:43 +02:00
2026-08-25 01:09:45 +02:00
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 ) )
2026-05-06 07:01:43 +02:00
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 } "
2026-10-08 18:00:47 +02:00
if line_mode == " separate " :
group_key = str ( row [ ' id ' ] )
elif line_mode == " combined " :
group_key = str ( row . get ( ' workflow_product_number ' ) or ' ' )
2026-05-06 07:01:43 +02:00
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 " ) )
2026-10-08 18:00:47 +02:00
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 ' ] )
2026-05-06 07:01:43 +02:00
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 " ,
2026-10-08 18:00:47 +02:00
" product_id " : workflow_product_id ,
" product_number " : workflow_product_number ,
" economic_product_number " : workflow_product_number ,
2026-05-06 07:01:43 +02:00
" selected " : True ,
" amount " : float ( amount ) ,
2026-08-25 01:09:45 +02:00
" customer_id " : int ( hub_customer_id ) ,
" customer_name " : customer_name ,
2026-05-06 07:01:43 +02:00
" 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 (
{
2026-08-25 01:09:45 +02:00
" line_key " : f " freight: { hub_customer_id } " ,
2026-05-06 07:01:43 +02:00
" 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 " ) ) ) ,
2026-08-25 01:09:45 +02:00
" customer_id " : int ( hub_customer_id ) ,
" customer_name " : customer_name ,
2026-05-06 07:01:43 +02:00
" sag_id " : None ,
" time_entry_ids " : [ ] ,
" time_date " : None ,
}
)
2026-10-08 08:58:02 +02:00
line_payloads = with_invoice_fee ( line_payloads , hub_customer_id , legacy = True , customer = hub_customer )
2026-05-06 07:01:43 +02:00
if not line_payloads :
raise HTTPException ( status_code = 400 , detail = " No order lines generated from selected entries " )
2026-08-25 01:09:45 +02:00
draft_title = f " Timefaktura { customer_name } - { date . today ( ) . isoformat ( ) } "
invoice_aggregate_key = f " timequeue-customer- { hub_customer_id } "
2026-05-06 07:01:43 +02:00
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 ,
2026-08-25 01:09:45 +02:00
int ( hub_customer_id ) ,
2026-05-06 07:01:43 +02:00
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 " ] )
2026-05-05 07:40:58 +02:00
def _resolve_tmodule_customer_id ( raw_customer_id : Optional [ int ] , sag_id : Optional [ int ] ) - > Optional [ int ] :
2026-08-25 01:09:45 +02:00
""" Resolve the actual Hub customer that must be invoiced.
2026-05-05 07:40:58 +02:00
2026-08-25 01:09:45 +02:00
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 .
2026-05-05 07:40:58 +02:00
"""
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 :
2026-08-25 01:09:45 +02:00
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 " ] )
2026-05-05 07:40:58 +02:00
return None
2026-08-25 01:09:45 +02:00
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 ,
2026-09-24 09:12:25 +02:00
s . status AS case_status ,
2026-08-25 01:09:45 +02:00
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 )
2026-10-08 18:00:47 +02:00
if settings . WORKFLOWS_ENABLED :
_attach_workflow_decisions ( rows )
2026-08-25 01:09:45 +02:00
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 " ) )
2026-10-08 18:00:47 +02:00
workflow_decision = row . get ( ' workflow_decision ' )
2026-08-25 01:09:45 +02:00
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 " ,
2026-09-24 09:12:25 +02:00
" case_status " : row . get ( " case_status " ) ,
2026-08-25 01:09:45 +02:00
" customer_name " : row . get ( " customer_name " ) or " Ukendt kunde " ,
" hours " : hours ,
2026-09-24 09:12:25 +02:00
" actual_hours " : float ( row . get ( " faktisk_tid_min " ) or round ( float ( row . get ( " original_hours " ) or 0 ) * 60 ) ) / 60.0 ,
2026-08-25 01:09:45 +02:00
" method " : method ,
" method_label " : _settlement_label ( method ) ,
" valid " : True ,
" message " : None ,
}
2026-10-08 18:00:47 +02:00
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 :
2026-08-25 01:09:45 +02:00
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 { }
2026-09-24 09:12:25 +02:00
# Invoice quantities must match the draft that will be created below.
# Per-entry rounding only applies when debiting a prepaid card.
2026-08-25 01:09:45 +02:00
hours = sum (
2026-09-24 09:12:25 +02:00
float ( row . get ( " approved_hours " ) or row . get ( " original_hours " ) or 0 )
2026-08-25 01:09:45 +02:00
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. """
2026-10-08 18:00:47 +02:00
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 )
2026-08-25 01:09:45 +02:00
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. " ,
}
2026-05-04 16:24:38 +02:00
@router.post ( " /time-queue/send-to-invoices " )
async def send_selected_to_invoices ( payload : BulkSendRequest , request : Request ) :
2026-08-25 01:09:45 +02:00
# 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
)
2026-05-04 16:24:38 +02:00
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 " )
2026-05-05 07:30:09 +02:00
# Local order creation must not depend on e-conomic data/mapping.
2026-05-05 07:40:58 +02:00
# Selected entries are converted to local orders regardless of billing method.
selected_order_ids = [ int ( r [ " id " ] ) for r in rows ]
2026-05-04 16:24:38 +02:00
2026-05-05 07:30:09 +02:00
if not selected_order_ids :
2026-05-05 07:40:58 +02:00
raise HTTPException ( status_code = 400 , detail = " No selected entries found " )
2026-05-04 16:24:38 +02:00
2026-05-05 07:30:09 +02:00
placeholders_invoice = " , " . join ( [ " %s " ] * len ( selected_order_ids ) )
2026-05-04 16:24:38 +02:00
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 '
""" ,
2026-05-05 07:30:09 +02:00
tuple ( selected_order_ids ) ,
2026-05-04 16:24:38 +02:00
)
rows_by_customer : Dict [ int , List [ Dict [ str , Any ] ] ] = defaultdict ( list )
2026-05-05 07:30:09 +02:00
skipped_missing_customer : List [ int ] = [ ]
2026-05-04 16:24:38 +02:00
for row in rows :
2026-05-05 07:30:09 +02:00
if int ( row [ " id " ] ) not in selected_order_ids :
continue
2026-05-05 07:40:58 +02:00
resolved_customer_id = _resolve_tmodule_customer_id ( row . get ( " customer_id " ) , row . get ( " sag_id " ) )
if not resolved_customer_id :
2026-05-05 07:30:09 +02:00
skipped_missing_customer . append ( int ( row [ " id " ] ) )
continue
2026-05-05 07:40:58 +02:00
rows_by_customer [ int ( resolved_customer_id ) ] . append ( row )
2026-05-04 16:24:38 +02:00
2026-05-06 07:01:43 +02:00
created_drafts = [ ]
2026-05-05 07:40:58 +02:00
failed_customers : List [ Dict [ str , Any ] ] = [ ]
2026-05-04 16:24:38 +02:00
for cust_id , cust_rows in rows_by_customer . items ( ) :
2026-05-05 07:40:58 +02:00
try :
2026-05-06 07:01:43 +02:00
draft_id = _create_ordre_draft_from_selected ( cust_id , cust_rows , user_id )
created_drafts . append ( { " customer_id " : cust_id , " draft_id " : draft_id } )
2026-05-05 07:40:58 +02:00
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 ) ,
}
)
2026-05-05 07:24:45 +02:00
2026-05-06 07:01:43 +02:00
if not created_drafts :
2026-05-05 07:30:09 +02:00
if skipped_missing_customer :
raise HTTPException (
status_code = 400 ,
detail = " No local orders created: selected entries are missing customer linkage " ,
)
2026-05-05 07:40:58 +02:00
if failed_customers :
raise HTTPException (
status_code = 400 ,
detail = " No local orders created: customer data is invalid for selected entries " ,
)
2026-05-05 07:30:09 +02:00
raise HTTPException ( status_code = 400 , detail = " No local orders created " )
2026-05-05 07:24:45 +02:00
# Time queue must never push directly to e-conomic.
# Orders are created locally and can be transferred manually from Orders page.
2026-05-06 07:01:43 +02:00
draft_ids = [ o [ " draft_id " ] for o in created_drafts ]
2026-05-05 07:42:46 +02:00
orders_url = " /ordre "
2026-05-06 07:01:43 +02:00
if len ( draft_ids ) == 1 :
orders_url = f " /ordre/ { draft_ids [ 0 ] } "
2026-05-04 16:24:38 +02:00
return {
" success " : True ,
" selected " : len ( ids ) ,
2026-05-05 07:40:58 +02:00
" order_candidates " : len ( selected_order_ids ) ,
2026-05-06 07:01:43 +02:00
" created_drafts " : created_drafts ,
" created_orders " : [ { " customer_id " : d [ " customer_id " ] , " order_id " : d [ " draft_id " ] } for d in created_drafts ] ,
2026-05-05 07:30:09 +02:00
" skipped_missing_customer " : skipped_missing_customer ,
2026-05-05 07:40:58 +02:00
" failed_customers " : failed_customers ,
2026-05-05 07:24:45 +02:00
" orders_url " : orders_url ,
2026-05-06 07:01:43 +02:00
" message " : " Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel. " ,
2026-05-04 16:24:38 +02:00
}
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 " )
2026-10-08 18:00:47 +02:00
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 ' )
2026-10-09 00:45:21 +02:00
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 [ ]
2026-10-08 18:00:47 +02:00
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 }
2026-10-09 00:45:21 +02:00
customer_activation = { }
2026-10-08 18:00:47 +02:00
for row in rows :
decision = by_id . get ( int ( row [ ' id ' ] ) )
row [ ' workflow_decision ' ] = decision
2026-10-09 00:45:21 +02:00
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 ]
2026-10-08 18:00:47 +02:00
row [ ' workflow_stale ' ] = bool ( decision and decision [ ' revision ' ] != decision [ ' entry_revision ' ] )
if row [ ' workflow_active ' ] and decision and decision . get ( ' method ' ) :
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 ' )