bmc_hub/app/workflows/decisions.py

136 lines
7.9 KiB
Python
Raw Normal View History

"""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)})