Show concrete billing prediction in V5 time capture
This commit is contained in:
parent
8a142dccf2
commit
be1a899ccb
@ -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>
|
||||
|
||||
@ -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
39
app/workflows/preview.py
Normal 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
|
||||
@ -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';
|
||||
|
||||
20
tests/test_case_v5_billing_preview_ui.py
Normal file
20
tests/test_case_v5_billing_preview_ui.py
Normal 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
|
||||
65
tests/test_workflow_billing_preview.py
Normal file
65
tests/test_workflow_billing_preview.py
Normal 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'
|
||||
@ -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:[]})});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user