Derive automatic billing rules from saved resource coverage

This commit is contained in:
Christian 2026-10-08 22:23:14 +02:00
parent 4a06ed8e00
commit b499b2dfea
8 changed files with 176 additions and 6 deletions

View File

@ -13,6 +13,20 @@ def coverage_allows(resource, context):
required = coverage.get('tag_ids') or [] required = coverage.get('tag_ids') or []
return not required or bool(tags.intersection(required)) return not required or bool(tags.intersection(required))
def automatic_coverage_choices(context):
"""Compile saved resource coverage into billing choices without duplicate rules."""
choices = []
for pool, method, reference in (('agreements', 'subscription', 'agreement_id'), ('cards', 'prepaid', 'card_id')):
for resource in context.get(pool, []):
coverage = resource.get('workflow_coverage') or {}
if not isinstance(coverage, dict) or coverage.get('auto_billing') is False:
continue
configured = coverage.get('auto_billing') is True or bool(coverage.get('tag_ids') or coverage.get('excluded_tag_ids'))
if configured and coverage_allows(resource, context):
choices.append({'type': 'set_billing', 'method': method, reference: resource['id'],
'source': 'resource_coverage', 'resource_type': 'agreement' if pool == 'agreements' else 'card'})
return choices
def validate_definition(definition): def validate_definition(definition):
if not isinstance(definition, dict) or definition.get('trigger') not in TRIGGERS: if not isinstance(definition, dict) or definition.get('trigger') not in TRIGGERS:
raise ValueError('Unknown workflow trigger') raise ValueError('Unknown workflow trigger')
@ -102,6 +116,7 @@ def evaluate(workflows, context, trigger):
if context.get('customer_mapping_missing'): if context.get('customer_mapping_missing'):
return {'status':'manual_review','method':None,'reason':'missing_customer_mapping','actions':[],'trace':[]} return {'status':'manual_review','method':None,'reason':'missing_customer_mapping','actions':[],'trace':[]}
trace, actions, selected = [], [], None trace, actions, selected = [], [], None
stopped = False
groups = defaultdict(list) groups = defaultdict(list)
for workflow in workflows: for workflow in workflows:
if context.get('target_workflow_id') and str(workflow.get('id')) != str(context['target_workflow_id']): continue if context.get('target_workflow_id') and str(workflow.get('id')) != str(context['target_workflow_id']): continue
@ -133,9 +148,19 @@ def evaluate(workflows, context, trigger):
if len(keys) > 1: return {'status': 'manual_review', 'method': None, 'reason': 'conflicting_rules', 'actions': actions, 'trace': trace} if len(keys) > 1: return {'status': 'manual_review', 'method': None, 'reason': 'conflicting_rules', 'actions': actions, 'trace': trace}
selected = candidates[0] selected = candidates[0]
break break
if stop: break if stop:
stopped = True
break
if context.get('pending_billing_tag_suggestions') or any(action['type']=='suggest_tag' for action in actions): if context.get('pending_billing_tag_suggestions') or any(action['type']=='suggest_tag' for action in actions):
return {'status': 'manual_review', 'method': None, 'reason': 'pending_tag_approval', 'actions': actions, 'trace': trace} return {'status': 'manual_review', 'method': None, 'reason': 'pending_tag_approval', 'actions': actions, 'trace': trace}
if selected is None and not stopped and not context.get('target_workflow_id') and trigger in ('time.created', 'time.updated'):
automatic = automatic_coverage_choices(context)
trace.append({'source': 'resource_coverage', 'matched': bool(automatic), 'choices': automatic})
if len(automatic) > 1:
return {'status': 'manual_review', 'method': None, 'reason': 'ambiguous_resource_coverage', 'actions': actions, 'trace': trace}
if automatic:
selected = automatic[0]
actions.append(selected)
if selected: if selected:
if selected['method'] in ('subscription','prepaid'): if selected['method'] in ('subscription','prepaid'):
pool_key, ref_key = ('agreements','agreement_id') if selected['method']=='subscription' else ('cards','card_id') pool_key, ref_key = ('agreements','agreement_id') if selected['method']=='subscription' else ('cards','card_id')
@ -153,5 +178,5 @@ def evaluate(workflows, context, trigger):
hours=(hours/interval).to_integral_value(rounding=ROUND_CEILING)*interval hours=(hours/interval).to_integral_value(rounding=ROUND_CEILING)*interval
if Decimal(str(eligible[0].get('remaining_hours') or 0))<hours: if Decimal(str(eligible[0].get('remaining_hours') or 0))<hours:
return {'status':'manual_review','method':None,'reason':'insufficient_card_balance','actions':actions,'trace':trace} return {'status':'manual_review','method':None,'reason':'insufficient_card_balance','actions':actions,'trace':trace}
return {'status': 'decided', 'method': selected['method'], 'reason': 'matched_rule', 'decision': selected, 'actions': actions, 'trace': trace} return {'status': 'decided', 'method': selected['method'], 'reason': 'resource_coverage' if selected.get('source') == 'resource_coverage' else 'matched_rule', 'decision': selected, 'actions': actions, 'trace': trace}
return {'status': 'manual_review', 'method': None, 'reason': 'no_matching_rule', 'actions': actions, 'trace': trace} return {'status': 'manual_review', 'method': None, 'reason': 'no_matching_rule', 'actions': actions, 'trace': trace}

View File

@ -12,7 +12,7 @@
<h5 class="mt-3">Historik</h5><div id="decision-history"><div class="workflow-empty">Henter historik…</div></div></div> <h5 class="mt-3">Historik</h5><div id="decision-history"><div class="workflow-empty">Henter historik…</div></div></div>
{% endblock %} {% endblock %}
{% block extra_js %}<script> {% block extra_js %}<script>
(()=>{'use strict';const stateNames={decided:'Valgt',manual_review:'Manuel vurdering',overridden:'Overstyret',settled:'Afregnet',shadow:'Skyggetilstand',active:'Aktiv'};const methodNames={invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'},reasons={matched_rule:'En regel valgte afregningen.',manual_override:'Afregningen er valgt manuelt.',no_matching_rule:'Ingen regel valgte en afregningsmetode.',conflicting_rules:'Flere regler valgte forskellige metoder. Bogholderiet skal gennemgå tiden.',pending_tag_approval:'Afventer godkendelse af tags, der kan påvirke afregningen.',insufficient_card_balance:'Klippekortet har ikke timer nok.',ambiguous_or_missing_cards:'Klippekort mangler eller kan ikke vælges entydigt.',ambiguous_or_missing_agreements:'Fastprisaftale mangler eller kan ikke vælges entydigt.'};function explain(result){$('explanation').replaceChildren();const p=document.createElement('p');p.textContent=reasons[result.reason]||'Beslutningen skal gennemgås.';$('explanation').append(p);if(result.override_reason){const p=document.createElement('p');p.textContent='Begrundelse: '+result.override_reason;$('explanation').append(p);}for(const trace of result.trace||[]){const p=document.createElement('div');p.className='small';p.textContent=`Workflow #${trace.workflow_id}, gren ${trace.branch+1}: ${trace.matched?'betingelser opfyldt':'betingelser ikke opfyldt'}`;$('explanation').append(p);const walk=(node,depth=0)=>{if(!node)return;const line=document.createElement('div');line.className='small';line.style.paddingLeft=(depth*16)+'px';const fields={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',status:'Status'};line.textContent=(node.matched?'✓ ':'× ')+(node.children?(node.kind==='all'?'Alle betingelser':'Mindst én betingelse'):node.kind==='else'?'Ellers':(fields[node.field]||node.field));$('explanation').append(line);for(const child of node.children||[])walk(child,depth+1);};walk(trace.condition);}}const id={{ time_id|tojson }},$=name=>document.getElementById('decision-'+name),base='/api/v1/timetracking/time/'+id; (()=>{'use strict';const stateNames={decided:'Valgt',manual_review:'Manuel vurdering',overridden:'Overstyret',settled:'Afregnet',shadow:'Skyggetilstand',active:'Aktiv'};const methodNames={invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'},reasons={resource_coverage:'Afregningen er valgt ud fra aftalens eller klippekortets gemte dækning.',ambiguous_resource_coverage:'Flere aftaler eller klippekort matcher dækningen. Tiden kræver manuel vurdering.',matched_rule:'En regel valgte afregningen.',manual_override:'Afregningen er valgt manuelt.',no_matching_rule:'Ingen regel valgte en afregningsmetode.',conflicting_rules:'Flere regler valgte forskellige metoder. Bogholderiet skal gennemgå tiden.',pending_tag_approval:'Afventer godkendelse af tags, der kan påvirke afregningen.',insufficient_card_balance:'Klippekortet har ikke timer nok.',ambiguous_or_missing_cards:'Klippekort mangler eller kan ikke vælges entydigt.',ambiguous_or_missing_agreements:'Fastprisaftale mangler eller kan ikke vælges entydigt.'};function explain(result){$('explanation').replaceChildren();const p=document.createElement('p');p.textContent=reasons[result.reason]||'Beslutningen skal gennemgås.';$('explanation').append(p);if(result.override_reason){const p=document.createElement('p');p.textContent='Begrundelse: '+result.override_reason;$('explanation').append(p);}for(const trace of result.trace||[]){const p=document.createElement('div');p.className='small';if(trace.source==='resource_coverage'){const choices=(trace.choices||[]).map(choice=>choice.resource_type==='agreement'?`Fastprisaftale #${choice.agreement_id}`:`Klippekort #${choice.card_id}`);p.textContent='Automatisk aftaledækning: '+(choices.length?choices.join(', '):'ingen aftaler eller klippekort matcher');}else{p.textContent=`Workflow #${trace.workflow_id}, gren ${trace.branch+1}: ${trace.matched?'betingelser opfyldt':'betingelser ikke opfyldt'}`;}$('explanation').append(p);const walk=(node,depth=0)=>{if(!node)return;const line=document.createElement('div');line.className='small';line.style.paddingLeft=(depth*16)+'px';const fields={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',status:'Status'};line.textContent=(node.matched?'✓ ':'× ')+(node.children?(node.kind==='all'?'Alle betingelser':'Mindst én betingelse'):node.kind==='else'?'Ellers':(fields[node.field]||node.field));$('explanation').append(line);for(const child of node.children||[])walk(child,depth+1);};walk(trace.condition);}}const id={{ time_id|tojson }},$=name=>document.getElementById('decision-'+name),base='/api/v1/timetracking/time/'+id;
async function api(path,body){const r=await fetch(base+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;} async function api(path,body){const r=await fetch(base+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
function message(text,error=false){$('message').className='alert '+(error?'alert-danger':'alert-info');$('message').textContent=text;} function message(text,error=false){$('message').className='alert '+(error?'alert-danger':'alert-info');$('message').textContent=text;}
async function load(){const data=await api('/workflow-decision');const d=data.current,shadow=!d&&data.executions?.[0];$('summary').textContent=d?`Metode: ${methodNames[d.method]||'Manuel vurdering'} · status: ${stateNames[d.status]||d.status}${d.manual_override?' · manuelt overstyret':''}${data.stale?' · forældet beslutning':''}`:shadow?'Skyggetilstand · foreslået metode: '+(methodNames[shadow.result.method]||'Manuel vurdering')+' · ingen afregning ændret':'Ingen beslutning endnu';explain(d?.details||shadow?.result||{});$('details').textContent=JSON.stringify(d?.details||shadow?.result||{},null,2);$('history').replaceChildren();if(!data.history.length&&!data.executions?.length)$('history').append(Object.assign(document.createElement('div'),{className:'workflow-empty',textContent:'Der er ingen tidligere beslutninger.'}));[...data.history,...(data.executions||[]).map(x=>({created_at:x.created_at,method:x.result.method,status:x.mode,details:x.result,is_current:false}))].forEach(row=>{const detail=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');summary.textContent=`${row.created_at} · ${methodNames[row.method]||'Manuel vurdering'} · ${stateNames[row.status]||row.status}${row.is_current?' · aktuel':''}`;pre.textContent=JSON.stringify(row.details,null,2);detail.append(summary,pre);$('history').append(detail);});} async function load(){const data=await api('/workflow-decision');const d=data.current,shadow=!d&&data.executions?.[0];$('summary').textContent=d?`Metode: ${methodNames[d.method]||'Manuel vurdering'} · status: ${stateNames[d.status]||d.status}${d.manual_override?' · manuelt overstyret':''}${data.stale?' · forældet beslutning':''}`:shadow?'Skyggetilstand · foreslået metode: '+(methodNames[shadow.result.method]||'Manuel vurdering')+' · ingen afregning ændret':'Ingen beslutning endnu';explain(d?.details||shadow?.result||{});$('details').textContent=JSON.stringify(d?.details||shadow?.result||{},null,2);$('history').replaceChildren();if(!data.history.length&&!data.executions?.length)$('history').append(Object.assign(document.createElement('div'),{className:'workflow-empty',textContent:'Der er ingen tidligere beslutninger.'}));[...data.history,...(data.executions||[]).map(x=>({created_at:x.created_at,method:x.result.method,status:x.mode,details:x.result,is_current:false}))].forEach(row=>{const detail=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');summary.textContent=`${row.created_at} · ${methodNames[row.method]||'Manuel vurdering'} · ${stateNames[row.status]||row.status}${row.is_current?' · aktuel':''}`;pre.textContent=JSON.stringify(row.details,null,2);detail.append(summary,pre);$('history').append(detail);});}

View File

@ -2,13 +2,13 @@
{% set wp = workflow_permissions|default({}) %} {% set wp = workflow_permissions|default({}) %}
{% block title %}Aftaledækning – BMC Hub{% endblock %} {% block title %}Aftaledækning – BMC Hub{% endblock %}
{% block extra_css %}{% include "workflows/frontend/_style.html" %}{% endblock %} {% block extra_css %}{% include "workflows/frontend/_style.html" %}{% endblock %}
{% block content %}<div class="container-fluid workflow-page"><header class="workflow-heading"><div><div class="workflow-eyebrow">Administration · afregning</div><h1>Aftaler og klippekort</h1><p>Vælg hvilke tags en aftale dækker. Gemte ændringer bevares i historikken.</p></div></header>{% include "workflows/frontend/_nav.html" %}<div id="resource-message" role="status" class="my-2"></div><label class="d-block my-3">Kunde<select id="resource-customer" class="form-select" style="max-width:420px"><option value="">Alle kunder</option></select></label><div id="resource-list"><div class="workflow-empty">Henter aftaler og klippekort…</div></div></div>{% endblock %} {% block content %}<div class="container-fluid workflow-page"><header class="workflow-heading"><div><div class="workflow-eyebrow">Administration · afregning</div><h1>Aftaler og klippekort</h1><p>Vælg hvilke tags aftalen eller klippekortet dækker. Gem dækning opretter og vedligeholder automatisk afregningsvalget. Du skal ikke oprette en separat workflowregel.</p></div></header>{% include "workflows/frontend/_nav.html" %}<div class="alert alert-info mt-3" id="resource-activation-help">Automatisk afregning bruges, når workflowmotoren og workflows for kunden er aktive. Aktivér kunden på kundekortet i CRM. Hvis flere aftaler eller klippekort matcher, skal afregningen vurderes manuelt. Tid godkendes og afregnes fortsat separat.</div><div id="resource-message" role="status" class="my-2"></div><label class="d-block my-3">Kunde<select id="resource-customer" class="form-select" style="max-width:420px"><option value="">Alle kunder</option></select></label><div id="resource-list"><div class="workflow-empty">Henter aftaler og klippekort…</div></div></div>{% endblock %}
{% block extra_js %}<script> {% block extra_js %}<script>
(()=>{'use strict';const $=id=>document.getElementById('resource-'+id);let tags=[],customers=[]; (()=>{'use strict';const $=id=>document.getElementById('resource-'+id);let tags=[],customers=[];
async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'PUT':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(typeof d.detail==='string'?d.detail:JSON.stringify(d.detail));return d;} async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'PUT':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(typeof d.detail==='string'?d.detail:JSON.stringify(d.detail));return d;}
function el(tag,text,cls){const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;} function el(tag,text,cls){const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;}
function choices(selected){const e=el('select','','form-select');e.multiple=true;e.size=6;for(const t of tags){const o=el('option',t.label||t.name);o.value=t.id;o.selected=(selected||[]).includes(t.id);e.append(o);}return e;} function choices(selected){const e=el('select','','form-select');e.multiple=true;e.size=6;for(const t of tags){const o=el('option',t.label||t.name);o.value=t.id;o.selected=(selected||[]).includes(t.id);e.append(o);}return e;}
async function load(){const data=await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',data.cards]])for(const row of rows){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris':'Klippekort'} ${row.agreement_number||row.card_number||row.id} · ${name}`),el('p','Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt med historik.';await load();});card.append(save);$('list').append(card);}} async function load(){const data=await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',data.cards]])for(const row of rows){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris':'Klippekort'} ${row.agreement_number||row.card_number||row.id} · ${name}`),el('p','Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt og bruges automatisk ved afregningsvalg, når workflows er aktive for kunden. Ændringen er gemt i historikken.';await load();});card.append(save);$('list').append(card);}}
let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}} let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
$('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));customers.forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load();}); $('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));customers.forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load();});
})();</script>{% endblock %} })();</script>{% endblock %}

View File

@ -34,6 +34,8 @@ def update_resource(kind,resource_id,payload,user):
if kind=='card' and 'monthly_price' in payload.model_fields_set: if kind=='card' and 'monthly_price' in payload.model_fields_set:
raise HTTPException(422,'Månedspris gælder kun fastprisaftaler') raise HTTPException(422,'Månedspris gælder kun fastprisaftaler')
coverage=payload.workflow_coverage.model_dump() coverage=payload.workflow_coverage.model_dump()
# Explicitly saving empty coverage means all tags; untouched legacy {} stays inert.
coverage['auto_billing']=True
tag_ids=sorted(set(coverage['tag_ids']+coverage['excluded_tag_ids'])) tag_ids=sorted(set(coverage['tag_ids']+coverage['excluded_tag_ids']))
if any(tag_id<=0 for tag_id in tag_ids): raise HTTPException(422,'Tag-ID skal være positivt') if any(tag_id<=0 for tag_id in tag_ids): raise HTTPException(422,'Tag-ID skal være positivt')
with transaction() as cursor: with transaction() as cursor:

View File

@ -355,8 +355,9 @@ Notér ved fejl: testnummer, lokal URL, tidspunkt, brugerrolle, kunde-/sag-/tids
| T20 Deaktivering | | | | T20 Deaktivering | | |
| T21 Kundekortaktivering | | | | T21 Kundekortaktivering | | |
| T22 V5-afregning | | | | T22 V5-afregning | | |
| T23 Automatisk aftaledækning | | |
Grundforløbet er manuelt accepteret, når T01–T17, T20, T21 og T22 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion. Grundforløbet er manuelt accepteret, når T01–T17, T20, T21, T22 og T23 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion.
Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `notification.created` er endnu ikke tilkoblet og kan ikke accepteres med denne plan. Registrér dem som **Ikke implementeret**, ikke Bestået. Sagoprettelse via workflow er heller ikke implementeret. Se [status](workflow-status.md). Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `notification.created` er endnu ikke tilkoblet og kan ikke accepteres med denne plan. Registrér dem som **Ikke implementeret**, ikke Bestået. Sagoprettelse via workflow er heller ikke implementeret. Se [status](workflow-status.md).
@ -370,3 +371,15 @@ Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `
6. Gentag en eksisterende V4-registrering: dens tidligere afregningsvalg skal fortsat fungere. 6. Gentag en eksisterende V4-registrering: dens tidligere afregningsvalg skal fortsat fungere.
**Forventet:** V5 har kun manuelt afregningsvalg under Tidsregistrering. Registrering og workflowoverstyring trækker ikke korttimer. **Forventet:** V5 har kun manuelt afregningsvalg under Tidsregistrering. Registrering og workflowoverstyring trækker ikke korttimer.
### T23 – Automatisk regel fra aftaledækning
1. Aktivér en testkunde og motoren i aktiv tilstand. Brug en kunde uden særskilte afregningsregler.
2. Vælg ABA som fastprisaftalens dækning på Aftaler og klippekort, og gem. Registrér ny tid på en ABA-sag.
3. Kontrollér Fastpris under Tidsregistrering og forklaringen Automatisk aftaledækning. Der skal ikke oprettes en regel manuelt i builderen.
4. Registrér tid på en sag uden ABA: ingen automatisk fastpris. Tilføj ABA til udelukkelserne og gem: ubogført tid skal genberegnes uden fastpris.
5. Lad to aftaler, eller en aftale og et kort, dække ABA: manuel vurdering, aldrig vilkårligt valg.
6. Kontrollér et entydigt kortmatch: Klippekort vælges, saldoen ændres først ved økonomisk godkendelse.
7. Kontrollér tom gemt dækning (alle tags), under-tags og en særskilt Faktura-regel med forrang. Slå kunden fra: nye tider får ingen aktiv beslutning.
**Forventet:** gemt dækning vedligeholder den automatiske regel, mens aktivering, rettigheder og økonomisk godkendelse fortsat gælder.

View File

@ -2,6 +2,8 @@
## Implementeret lokalt ## Implementeret lokalt
- Gemte aftale-/klippekortdækninger udleder automatisk afregningsregler for ny eller ændret tid. Eksisterende tagdækning bruges også. En eksplicit gemt tom dækning gælder alle tags; urørte gamle aftaler/kort med `{}` aktiverer ingen automatisk regel. Eksisterende særskilte afregningsregler har forrang, og flere match giver manuel vurdering. Reglen følger dækningen direkte og oprettes ikke som en dobbelt builderregel. Motor- og kundeaktivering kræves fortsat.
- Sag V5: hurtigregistrering, timer og `/tid` vælger ikke afregning manuelt. Tiden starter som kladde, workflowets valg vises, og manuelt valg findes under Tidsregistrering. Aktive workflowtider ændres med begrundelse og overstyringsrettighed. V4 bevarer sine tidligere valg. - Sag V5: hurtigregistrering, timer og `/tid` vælger ikke afregning manuelt. Tiden starter som kladde, workflowets valg vises, og manuelt valg findes under Tidsregistrering. Aktive workflowtider ændres med begrundelse og overstyringsrettighed. V4 bevarer sine tidligere valg.
- Ren regelmotor med AND/OR, IF/ELSE IF/ELSE, kunderegler, prioritet, stopregel, konflikter og fuldt evalueringsspor. - Ren regelmotor med AND/OR, IF/ELSE IF/ELSE, kunderegler, prioritet, stopregel, konflikter og fuldt evalueringsspor.

View File

@ -0,0 +1,127 @@
"""Saved resource coverage acts as an automatic billing rule for time only."""
from copy import deepcopy
import pytest
from app.workflows.engine import evaluate
def agreement(coverage=None, resource_id=41):
return {'id': resource_id, 'workflow_coverage': coverage if coverage is not None else {'tag_ids': [7]}}
def card(coverage=None, resource_id=51, balance=10):
return {'id': resource_id, 'remaining_hours': balance,
'workflow_coverage': coverage if coverage is not None else {'tag_ids': [7]}}
def context(**values):
return dict({'customer_id': '24', 'hours': 0.5, 'tag_ids': [7],
'concrete_tag_ids': [7], 'agreements': [agreement()]}, **values)
def workflow(action, *, stop=False):
return {'id': 2, 'definition': {'trigger': 'time.created',
'stop_after_match': stop,
'branches': [{'condition': None, 'actions': [action]}]}}
@pytest.mark.parametrize('trigger', ['time.created', 'time.updated'])
def test_saved_aba_coverage_selects_fixed_price_agreement(trigger):
result = evaluate([], context(), trigger)
assert result['status'] == 'decided'
assert result['method'] == 'subscription'
assert result['decision']['agreement_id'] == 41
def test_matching_card_selects_prepaid():
result = evaluate([], context(agreements=[], cards=[card()]), 'time.created')
assert result['method'] == 'prepaid'
assert result['decision']['card_id'] == 51
@pytest.mark.parametrize('coverage', [
{'tag_ids': [8]},
{'tag_ids': [7], 'excluded_tag_ids': [7]},
{'tag_ids': [7], 'auto_billing': False},
{},
])
def test_mismatch_exclusion_disabled_and_untouched_coverage_do_not_select(coverage):
result = evaluate([], context(agreements=[agreement(coverage)]), 'time.created')
assert result['status'] == 'manual_review'
assert result['method'] is None
@pytest.mark.parametrize('include_descendants,expected', [(False, None), (True, 'subscription')])
def test_descendant_coverage_requires_opt_in(include_descendants, expected):
resource = agreement({'tag_ids': [7], 'include_descendants': include_descendants})
result = evaluate([], context(agreements=[resource], tag_ids=[7, 8], concrete_tag_ids=[8]), 'time.created')
assert result['method'] == expected
@pytest.mark.parametrize('resources', [
{'agreements': [agreement(resource_id=41), agreement(resource_id=42)]},
{'agreements': [agreement()], 'cards': [card()]},
{'agreements': [], 'cards': [card(resource_id=51), card(resource_id=52)]},
])
def test_multiple_matching_resources_require_review(resources):
result = evaluate([], context(**resources), 'time.created')
assert result['status'] == 'manual_review'
assert result['reason'] == 'ambiguous_resource_coverage'
def test_explicit_billing_rule_precedes_automatic_coverage():
rule = workflow({'type': 'set_billing', 'method': 'invoice'})
result = evaluate([rule], context(), 'time.created')
assert result['method'] == 'invoice'
def test_stop_rule_without_billing_blocks_automatic_coverage():
rule = workflow({'type': 'add_tag', 'tag_id': 9}, stop=True)
result = evaluate([rule], context(), 'time.created')
assert result['status'] == 'manual_review'
assert result['method'] is None
assert result['actions'][0]['tag_id'] == 9
def test_targeted_workflow_does_not_use_unrelated_automatic_coverage():
result = evaluate([], context(target_workflow_id=99), 'time.created')
assert result['method'] is None
@pytest.mark.parametrize('trigger', ['case.created', 'case.updated', 'tag.approved'])
def test_non_time_events_do_not_choose_billing(trigger):
assert evaluate([], context(), trigger)['method'] is None
def test_saved_empty_coverage_explicitly_enables_all_tags():
result = evaluate([], context(agreements=[agreement({'auto_billing': True, 'tag_ids': []})]), 'time.created')
assert result['method'] == 'subscription'
def test_exclusions_only_saved_coverage_is_an_automatic_rule():
result = evaluate([], context(agreements=[agreement({'excluded_tag_ids': [8]})]), 'time.created')
assert result['method'] == 'subscription'
def test_pending_tag_suggestions_block_automatic_billing():
result = evaluate([], context(pending_billing_tag_suggestions=True), 'time.created')
assert result['reason'] == 'pending_tag_approval'
def test_automatic_card_keeps_existing_balance_check():
result = evaluate([], context(agreements=[], cards=[card(balance=0.25)]), 'time.created')
assert result['reason'] == 'insufficient_card_balance'
def test_automatic_card_keeps_rounding_balance_check():
resource = dict(card(balance=0.2), rounding_minutes=15)
result = evaluate([], context(hours=0.1, agreements=[], cards=[resource]), 'time.created')
assert result['reason'] == 'insufficient_card_balance'
def test_automatic_evaluation_does_not_mutate_resources_or_context():
supplied = context()
before = deepcopy(supplied)
evaluate([], supplied, 'time.created')
assert supplied == before

View File

@ -36,4 +36,5 @@ def test_resource_audit_and_explicit_null_monthly_price(monkeypatch):
update=next(call for call in cursor.execute.call_args_list if call.args[0].startswith('UPDATE customer_fixed')) update=next(call for call in cursor.execute.call_args_list if call.args[0].startswith('UPDATE customer_fixed'))
assert 'monthly_price=%s' in update.args[0] assert 'monthly_price=%s' in update.args[0]
assert update.args[1][1] is None assert update.args[1][1] is None
assert update.args[1][0].adapted['auto_billing'] is True
assert any('INSERT INTO bmc_workflow_resource_history' in call.args[0] for call in cursor.execute.call_args_list) assert any('INSERT INTO bmc_workflow_resource_history' in call.args[0] for call in cursor.execute.call_args_list)