2026-10-08 18:00:47 +02:00
""" Audited decision review; this module never settles or exports time. """
from typing import Optional , Literal
from fastapi import APIRouter , Depends , HTTPException , Request
from fastapi . responses import HTMLResponse
from fastapi . templating import Jinja2Templates
from pydantic import BaseModel , Field
from psycopg2 . extras import Json
from app . core . auth_dependencies import require_permission
from app . workflows import engine , service
from app . workflows . integration import active_for_customer
router = APIRouter ( prefix = ' /timetracking/time ' )
views = APIRouter ( )
view_access = require_permission ( ' workflows.decisions.view ' )
edit_access = require_permission ( ' workflows.override ' )
def _require_active ( context ) :
if not active_for_customer ( context . get ( ' customer_id ' ) ) :
raise HTTPException ( 409 , ' Automatisk afregning er ikke aktiv for denne kunde ' )
class OverrideRequest ( BaseModel ) :
method : Literal [ ' invoice ' , ' subscription ' , ' prepaid ' , ' internal ' , ' non_billable ' ]
reason : str = Field ( min_length = 3 , max_length = 2000 )
agreement_id : Optional [ int ] = Field ( default = None , gt = 0 )
card_id : Optional [ int ] = Field ( default = None , gt = 0 )
product_number : Optional [ str ] = Field ( default = None , max_length = 100 )
class RecalculateRequest ( BaseModel ) :
reset_override : bool = False
reason : Optional [ str ] = Field ( default = None , max_length = 2000 )
def _locked_time ( cursor , time_id ) :
cursor . execute ( ' SELECT * FROM tmodule_times WHERE id= %s FOR UPDATE ' , ( time_id , ) )
row = cursor . fetchone ( )
if not row :
raise HTTPException ( 404 , ' Tidsregistreringen findes ikke ' )
if ( row . get ( ' status ' ) in ( ' approved ' , ' billed ' ) or row . get ( ' economy_settled_at ' )
or row . get ( ' economy_order_draft_id ' ) or row . get ( ' exported_at ' ) or row . get ( ' invoice_id ' ) ) :
raise HTTPException ( 409 , ' Afregnet eller eksporteret tid kræver en særskilt korrektion ' )
cursor . execute ( ' SELECT * FROM bmc_billing_decisions WHERE time_id= %s AND is_current FOR UPDATE ' , ( time_id , ) )
previous = cursor . fetchone ( )
if previous and previous [ ' status ' ] == ' settled ' :
raise HTTPException ( 409 , ' Afregnet tid kræver en særskilt korrektion ' )
return dict ( row ) , dict ( previous ) if previous else None
def _save ( cursor , time_id , revision , result , manual = False ) :
cursor . execute ( ' UPDATE bmc_billing_decisions SET is_current=FALSE WHERE time_id= %s AND is_current ' , ( time_id , ) )
cursor . execute ( ' INSERT INTO bmc_billing_decisions(time_id,revision,status,method,details,manual_override) VALUES( %s , %s , %s , %s , %s , %s ) RETURNING * ' ,
( time_id , revision , result [ ' status ' ] , result . get ( ' method ' ) , Json ( service . json_safe ( result ) ) , manual ) )
return dict ( cursor . fetchone ( ) )
@router.get ( ' / {time_id} /workflow-decision ' )
def history ( time_id : int , user = Depends ( view_access ) ) :
with service . transaction ( ) as cursor :
cursor . execute ( ' SELECT id,workflow_revision FROM tmodule_times WHERE id= %s ' , ( time_id , ) )
entry = cursor . fetchone ( )
if not entry :
raise HTTPException ( 404 , ' Tidsregistreringen findes ikke ' )
cursor . execute ( ' SELECT * FROM bmc_billing_decisions WHERE time_id= %s ORDER BY id DESC ' , ( time_id , ) )
rows = [ dict ( row ) for row in cursor . fetchall ( ) ]
current = next ( ( row for row in rows if row [ ' is_current ' ] ) , None )
cursor . execute ( " SELECT x.* FROM bmc_workflow_executions x JOIN bmc_workflow_events e ON e.id=x.event_id WHERE e.entity_type IN ( ' time ' , ' time_entry ' ) AND e.entity_id= %s ORDER BY x.created_at DESC,x.id DESC LIMIT 30 " , ( str ( time_id ) , ) )
executions = [ dict ( row ) for row in cursor . fetchall ( ) ]
return { ' time_id ' : time_id , ' revision ' : entry [ ' workflow_revision ' ] , ' current ' : current ,
' stale ' : bool ( current and current [ ' revision ' ] != entry [ ' workflow_revision ' ] ) , ' history ' : rows ,
' executions ' : executions }
@router.post ( ' / {time_id} /workflow-override ' )
def override ( time_id : int , payload : OverrideRequest , user = Depends ( edit_access ) ) :
if len ( payload . reason . strip ( ) ) < 3 :
raise HTTPException ( 422 , ' Angiv en begrundelse ' )
with service . transaction ( ) as cursor :
entry , previous = _locked_time ( cursor , time_id )
context = service . load_time_context ( time_id , cursor )
_require_active ( context )
from app . workflows . classification import enrich_time_context
context = enrich_time_context ( cursor , context )
action = { ' type ' : ' set_billing ' , ' method ' : payload . method }
for key in ( ' agreement_id ' , ' card_id ' , ' product_number ' ) :
value = getattr ( payload , key )
if value is not None :
action [ key ] = value
# Reuse financial eligibility checks; pending AI suggestions remain blocking.
definition = { ' trigger ' : ' time.updated ' , ' branches ' : [ { ' condition ' : None , ' actions ' : [ action ] } ] }
result = engine . evaluate ( [ { ' id ' : 0 , ' priority ' : 0 , ' definition ' : definition } ] , context , ' time.updated ' )
if result [ ' status ' ] != ' decided ' :
raise HTTPException ( 409 , ' Afregningsvalget kan ikke anvendes: ' + result [ ' reason ' ] )
result . update ( reason = ' manual_override ' , override_reason = payload . reason . strip ( ) , actor_id = user [ ' id ' ] ,
previous_decision_id = previous [ ' id ' ] if previous else None , input = context , mode = ' active ' )
result [ ' billing ' ] = result [ ' decision ' ]
return _save ( cursor , time_id , entry . get ( ' workflow_revision ' , 0 ) , result , manual = True )
@router.post ( ' / {time_id} /workflow-recalculate ' )
def recalculate ( time_id : int , payload : RecalculateRequest , user = Depends ( edit_access ) ) :
if payload . reset_override :
if not ( user . get ( ' is_superadmin ' ) or ' workflows.activate ' in user . get ( ' permissions ' , [ ] ) ) :
raise HTTPException ( 403 , ' Kun workflowadministrator må ophæve en overstyring ' )
if not payload . reason or len ( payload . reason . strip ( ) ) < 3 :
raise HTTPException ( 422 , ' Begrund ophævelse af overstyringen ' )
with service . transaction ( ) as cursor :
entry , previous = _locked_time ( cursor , time_id )
preserve_manual = bool ( previous and previous [ ' manual_override ' ] and not payload . reset_override )
if preserve_manual and previous . get ( ' revision ' , entry . get ( ' workflow_revision ' , 0 ) ) == entry . get ( ' workflow_revision ' , 0 ) :
return { ' preserved_override ' : True , ' decision ' : previous }
context = service . load_time_context ( time_id , cursor )
_require_active ( context )
from app . workflows . classification import enrich_time_context
context = enrich_time_context ( cursor , context )
if preserve_manual :
saved = previous . get ( ' details ' , { } )
action = dict ( saved . get ( ' billing ' ) or saved . get ( ' decision ' ) or { } , type = ' set_billing ' , method = previous [ ' method ' ] )
definition = { ' trigger ' : ' time.updated ' , ' branches ' : [ { ' condition ' : None , ' actions ' : [ action ] } ] }
result = engine . evaluate ( [ { ' id ' : 0 , ' priority ' : 0 , ' definition ' : definition } ] , context , ' time.updated ' )
result [ ' override_reason ' ] = saved . get ( ' override_reason ' )
else :
cursor . execute ( ' SELECT w.*,v.id AS version_id,v.definition FROM bmc_workflows w JOIN bmc_workflow_versions v ON v.id=w.active_version_id AND v.workflow_id=w.id WHERE w.enabled=TRUE ' )
result = engine . evaluate ( cursor . fetchall ( ) , context , ' time.updated ' )
result . update ( input = context , mode = ' active ' , actor_id = user [ ' id ' ] , reset_override_reason = payload . reason ,
previous_decision_id = previous [ ' id ' ] if previous else None )
result [ ' billing ' ] = result . get ( ' decision ' , { } )
return { ' preserved_override ' : preserve_manual , ' decision ' : _save ( cursor , time_id , entry . get ( ' workflow_revision ' , 0 ) , result , manual = preserve_manual ) }
@views.get ( ' /workflows/time/ {time_id} ' , response_class = HTMLResponse )
def decision_page ( time_id : int , request : Request , user = Depends ( view_access ) ) :
2026-10-08 20:34:18 +02:00
from . ui_access import page_permissions
return Jinja2Templates ( directory = ' app ' ) . TemplateResponse ( ' workflows/frontend/decision.html ' , { ' request ' : request , ' time_id ' : time_id , ' workflow_permissions ' : page_permissions ( user ) } )