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:[]})});