diff --git a/app/modules/sag/templates/detail_v5.html b/app/modules/sag/templates/detail_v5.html
index b00ee4e..8d60e23 100644
--- a/app/modules/sag/templates/detail_v5.html
+++ b/app/modules/sag/templates/detail_v5.html
@@ -35,7 +35,7 @@
'groupId': case.assigned_group_id,
'userId': case.ansvarlig_bruger_id
}|tojson }}
-
+
diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py
index 04b6112..5e7a6e1 100644
--- a/app/timetracking/backend/router.py
+++ b/app/timetracking/backend/router.py
@@ -3032,6 +3032,22 @@ def _case_billing_choice(sag_id: int, choice: str) -> Dict[str, Any]:
raise HTTPException(400, "Ugyldigt afregningsvalg")
+@router.get("/cases/{sag_id}/billing-preview", tags=["Internal"])
+async def get_case_billing_preview(
+ sag_id: int,
+ minutes: float = Query(30, gt=0, le=1440),
+ description: str = Query('', max_length=4000),
+ time_type: str = Query('support', max_length=100),
+):
+ from app.workflows.preview import preview_case_billing
+ try:
+ return preview_case_billing(sag_id, minutes, description, time_type)
+ except ValueError as exc:
+ if str(exc) == 'Case not found':
+ raise HTTPException(404, 'Sag ikke fundet') from exc
+ raise
+
+
@router.get("/cases/{sag_id}/billing-options", tags=["Internal"])
async def get_case_billing_options(sag_id: int):
from app.core.config import settings
diff --git a/app/workflows/preview.py b/app/workflows/preview.py
new file mode 100644
index 0000000..121bd3f
--- /dev/null
+++ b/app/workflows/preview.py
@@ -0,0 +1,39 @@
+"""Read-only billing prediction for a time registration that has not been saved."""
+from .service import transaction, load_case_context, json_safe
+from .integration import active_for_customer
+from .classification import enrich_entity_context
+from .engine import evaluate
+
+
+def preview_case_billing(case_id, minutes=30, description='', time_type='support'):
+ with transaction() as cursor:
+ context = load_case_context(case_id, cursor)
+ if context.get('deleted_at'):
+ raise ValueError('Case not found')
+ if not active_for_customer(context.get('customer_id')):
+ return {'status': 'draft', 'method': 'draft', 'reason': 'workflows_inactive'}
+ context.pop('subject', None)
+ context.update(entity_type='time_entry', time_id=None,
+ case_title=context.get('titel'), case_description=context.get('beskrivelse'),
+ hours=minutes / 60, original_hours=minutes / 60,
+ description=description, time_type=time_type, status='pending', worked_date=None)
+ customer_id = context.get('customer_id')
+ if customer_id:
+ cursor.execute("SELECT * FROM customer_fixed_price_agreements WHERE customer_id=%s AND status IN ('active','pending_cancellation') AND start_date<=COALESCE(%s::date,CURRENT_DATE) AND (end_date IS NULL OR end_date>=COALESCE(%s::date,CURRENT_DATE)) AND (cancellation_effective_date IS NULL OR cancellation_effective_date>COALESCE(%s::date,CURRENT_DATE))", (int(customer_id), None, None, None))
+ context['agreements'] = cursor.fetchall()
+ cursor.execute("SELECT * FROM tticket_prepaid_cards WHERE customer_id=%s AND status='active' AND (expires_at IS NULL OR expires_at>now())", (int(customer_id),))
+ context['cards'] = cursor.fetchall()
+ else:
+ context['agreements'], context['cards'] = [], []
+ context['has_active_agreement'] = bool(context['agreements'])
+ context['has_active_card'] = bool(context['cards'])
+ context['remaining_card_hours'] = context['cards'][0].get('remaining_hours') if len(context['cards']) == 1 else (0 if not context['cards'] else None)
+ context['has_available_card'] = any(float(card.get('remaining_hours') or 0) > 0 for card in context['cards'])
+ context = enrich_entity_context(cursor, json_safe(context), 'time_entry', None)
+ 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 = evaluate(cursor.fetchall(), context, 'time.created')
+ prediction = {key: result.get(key) for key in ('status', 'method', 'reason')}
+ for key in ('agreement_id', 'card_id'):
+ if result.get('decision', {}).get(key) is not None:
+ prediction[key] = result['decision'][key]
+ return prediction
diff --git a/static/js/case-v5.js b/static/js/case-v5.js
index 16f0320..7448cae 100644
--- a/static/js/case-v5.js
+++ b/static/js/case-v5.js
@@ -26,12 +26,38 @@
for(const id of ['time_internal','sol_time_internal']){const checkbox=byId(id);if(checkbox){checkbox.checked=false;checkbox.disabled=true;const wrapper=checkbox.closest('.form-check');if(wrapper)wrapper.hidden=true;}}
}
setAutomaticRegistrationControls();
+ const previewFields=[
+ {host:compactBillingHost,minutes:'caseQuickTimeMinutes',description:'caseQuickTimeDescription',type:'caseQuickTimeType'},
+ {host:billingHost,minutes:'commentQuickTimeMinutes',description:'commentText',type:'commentQuickTimeType'}
+ ];
+ let previewTimer;
+ async function refreshBillingPreview() {
+ const names={subscription:'Fastpris',prepaid:'Klippekort',invoice:'Faktura',internal:'Intern tid',non_billable:'Ikke fakturerbar',draft:'Kladde'};
+ await Promise.all(previewFields.map(async fields=>{
+ const sequence=(fields.sequence||0)+1;fields.sequence=sequence;
+ const hint=fields.host.querySelector('.form-text');
+ const params=new URLSearchParams({minutes:Math.max(1,Number(byId(fields.minutes)?.value)||30),description:(byId(fields.description)||document.querySelector('textarea[name=indhold]'))?.value||'',time_type:byId(fields.type)?.value||'support'});
+ try {
+ const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-preview?${params}`,{credentials:'include'});
+ const data=await response.json();if(!response.ok)throw new Error('Preview failed');
+ if(sequence!==fields.sequence)return;
+ hint.textContent='Forslag: '+(data.status==='manual_review'?'Manuel vurdering':names[data.method]||'Manuel vurdering');
+ } catch(error) {if(sequence===fields.sequence)hint.textContent='Afregningsforslag kunne ikke beregnes.';}
+ }));
+ }
+ function scheduleBillingPreview(){clearTimeout(previewTimer);previewTimer=setTimeout(refreshBillingPreview,250);}
+ for(const fields of previewFields)for(const id of [fields.minutes,fields.description,fields.type]){
+ const input=byId(id)||(id==='commentText'?document.querySelector('textarea[name=indhold]'):null);if(input){input.addEventListener('input',scheduleBillingPreview);input.addEventListener('change',scheduleBillingPreview);input.addEventListener('focus',scheduleBillingPreview);}
+ }
+ const tagList=byId('case-tags-module');
+ if(tagList)new MutationObserver(scheduleBillingPreview).observe(tagList,{childList:true,subtree:true});
async function loadBillingOptions() {
try {
const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-options`,{credentials:'include'});
const data=await response.json();
if(!response.ok)throw new Error(data.detail || 'Afregningsmuligheder kunne ikke hentes');
setAutomaticRegistrationControls(data.workflow_active===true);
+ await refreshBillingPreview();
const anchor=byId('timeV1Date');
if(anchor&&!byId('v5TabBilling')) {
const host=document.createElement('div');host.className='col-12';
diff --git a/tests/test_case_v5_billing_preview_ui.py b/tests/test_case_v5_billing_preview_ui.py
new file mode 100644
index 0000000..7b2b815
--- /dev/null
+++ b/tests/test_case_v5_billing_preview_ui.py
@@ -0,0 +1,20 @@
+from pathlib import Path
+import shutil
+import subprocess
+import pytest
+
+@pytest.mark.skipif(not shutil.which('node'), reason='Node required')
+def test_preview_displays_actual_method_and_review_without_manual_choice():
+ source=Path('static/js/case-v5.js').read_text()
+ function=source.split(' async function refreshBillingPreview()',1)[1].split(' function scheduleBillingPreview()',1)[0]
+ script='''
+const assert=require('node:assert/strict');
+const hints=[{},{}],previewFields=hints.map(host=>({host:{querySelector:()=>host},minutes:'minutes',description:'description',type:'type'}));
+const config={caseId:196},byId=id=>({value:id==='minutes'?'30':id==='description'?'ABA support':'support'});
+let result={status:'decided',method:'subscription'};
+const fetch=async url=>{assert.match(url,/billing-preview/);assert.match(url,/minutes=30/);return{ok:true,json:async()=>result};};
+async function refreshBillingPreview() BODY
+(async()=>{await refreshBillingPreview();assert.equal(hints[0].textContent,'Forslag: Fastpris');assert.equal(hints[1].textContent,'Forslag: Fastpris');result={status:'manual_review',method:null};await refreshBillingPreview();assert.equal(hints[0].textContent,'Forslag: Manuel vurdering');result={status:'draft',method:'draft'};await refreshBillingPreview();assert.equal(hints[0].textContent,'Forslag: Kladde');})().catch(e=>{console.error(e);process.exitCode=1;});
+'''.replace('BODY',function)
+ result=subprocess.run(['node','-e',script],capture_output=True,text=True)
+ assert result.returncode==0,result.stderr
diff --git a/tests/test_workflow_billing_preview.py b/tests/test_workflow_billing_preview.py
new file mode 100644
index 0000000..7c4934d
--- /dev/null
+++ b/tests/test_workflow_billing_preview.py
@@ -0,0 +1,65 @@
+from contextlib import contextmanager
+
+from app.workflows import preview
+
+
+class ReadOnlyCursor:
+ def __init__(self, agreements=(), cards=(), rules=()):
+ self.pools = {'customer_fixed_price_agreements': agreements, 'tticket_prepaid_cards': cards, 'bmc_workflows': rules}
+ self.queries = []
+ self.rows = []
+
+ def execute(self, sql, params=None):
+ assert sql.startswith('SELECT '), 'Preview must not write'
+ self.queries.append((sql, params))
+ self.rows = next((rows for table, rows in self.pools.items() if table in sql), [])
+
+ def fetchall(self):
+ return self.rows
+
+
+def setup_preview(monkeypatch, cursor, active=True):
+ @contextmanager
+ def transaction():
+ yield cursor
+ monkeypatch.setattr(preview, 'transaction', transaction)
+ monkeypatch.setattr(preview, 'load_case_context', lambda *args: {'id': 10, 'customer_id': '7', 'tag_ids': [3], 'concrete_tag_ids': [3], 'titel': 'Test', 'beskrivelse': 'Case text'})
+ monkeypatch.setattr(preview, 'active_for_customer', lambda customer_id: active)
+ monkeypatch.setattr(preview, 'enrich_entity_context', lambda cursor, context, entity_type, entity_id: context)
+
+
+def test_inactive_returns_draft_without_loading_rules_or_resources(monkeypatch):
+ cursor = ReadOnlyCursor()
+ setup_preview(monkeypatch, cursor, active=False)
+ assert preview.preview_case_billing(10)['method'] == 'draft'
+ assert cursor.queries == []
+
+
+def test_coverage_predicts_fast_price_without_saving_or_exposing_trace(monkeypatch):
+ cursor = ReadOnlyCursor(agreements=[{'id': 9, 'workflow_coverage': {'tag_ids': [3]}}])
+ setup_preview(monkeypatch, cursor)
+ assert preview.preview_case_billing(10) == {'status': 'decided', 'method': 'subscription', 'reason': 'resource_coverage', 'agreement_id': 9}
+ assert all(params is None or params[0] == 7 for sql, params in cursor.queries)
+ assert 'expires_at>now()' in cursor.queries[1][0]
+ assert 'cancellation_effective_date' in cursor.queries[0][0]
+
+
+def test_explicit_rule_takes_precedence_over_coverage(monkeypatch):
+ rule = {'id': 1, 'definition': {'trigger': 'time.created', 'branches': [{'condition': {'field': 'description', 'operator': 'contains', 'value': 'intern'}, 'actions': [{'type': 'set_billing', 'method': 'internal'}]}]}}
+ cursor = ReadOnlyCursor(agreements=[{'id': 9, 'workflow_coverage': {'tag_ids': [3]}}], rules=[rule])
+ setup_preview(monkeypatch, cursor)
+ assert preview.preview_case_billing(10, description='intern opgave')['method'] == 'internal'
+
+
+def test_prepaid_prediction_checks_requested_minutes(monkeypatch):
+ cursor = ReadOnlyCursor(cards=[{'id': 4, 'remaining_hours': .5, 'workflow_coverage': {'tag_ids': [3]}}])
+ setup_preview(monkeypatch, cursor)
+ assert preview.preview_case_billing(10, minutes=30)['method'] == 'prepaid'
+ assert preview.preview_case_billing(10, minutes=60)['reason'] == 'insufficient_card_balance'
+
+
+def test_pending_tag_review_remains_blocking(monkeypatch):
+ cursor = ReadOnlyCursor(agreements=[{'id': 9, 'workflow_coverage': {'tag_ids': [3]}}])
+ setup_preview(monkeypatch, cursor)
+ monkeypatch.setattr(preview, 'enrich_entity_context', lambda cursor, context, entity_type, entity_id: dict(context, pending_billing_tag_suggestions=['case_proposal:1']))
+ assert preview.preview_case_billing(10)['reason'] == 'pending_tag_approval'
diff --git a/tests/test_workflow_employee_ui.py b/tests/test_workflow_employee_ui.py
index 25e11a3..4aafb35 100644
--- a/tests/test_workflow_employee_ui.py
+++ b/tests/test_workflow_employee_ui.py
@@ -17,7 +17,7 @@ for(const id of ['v5QuickBilling','time_billing_method','v5CompactBilling'])cont
controls.v5BillingHint={textContent:''};
const byId=id=>controls[id],config={caseId:1};
const billingHost={querySelector:()=>({textContent:''})},compactBillingHost={querySelector:()=>({textContent:''})};
-const document={createElement:()=>({})};
+const document={createElement:()=>({}),querySelector:()=>null};
function Option(label,value){this.label=label;this.value=value;}
const active=ACTIVE;
const fetch=async()=>({ok:true,json:async()=>({workflow_active:active,has_customer:true,hourly_rate:100,prepaid_cards:[],agreements:[]})});