Show concrete billing prediction in V5 time capture

This commit is contained in:
Christian 2026-10-08 23:06:55 +02:00
parent 8a142dccf2
commit be1a899ccb
7 changed files with 168 additions and 2 deletions

View File

@ -35,7 +35,7 @@
'groupId': case.assigned_group_id,
'userId': case.ansvarlig_bruger_id
}|tojson }}</script>
<script src="/static/js/case-v5.js?v=20261008-3"></script>
<script src="/static/js/case-v5.js?v=20261008-4"></script>
<script src="/static/js/case-commands.js?v=20261006-1"></script>
<script src="/static/js/case-command-suggestions.js?v=20261006-1"></script>
<script src="/static/js/case-command-composer.js?v=20261008-3"></script>

View File

@ -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

39
app/workflows/preview.py Normal file
View File

@ -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

View File

@ -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';

View File

@ -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

View File

@ -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'

View File

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