136 lines
7.9 KiB
Python
136 lines
7.9 KiB
Python
"""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)):
|
|
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)})
|