diff --git a/app/workflows/engine.py b/app/workflows/engine.py index 3437b82..552d360 100644 --- a/app/workflows/engine.py +++ b/app/workflows/engine.py @@ -13,6 +13,20 @@ def coverage_allows(resource, context): required = coverage.get('tag_ids') or [] 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): if not isinstance(definition, dict) or definition.get('trigger') not in TRIGGERS: raise ValueError('Unknown workflow trigger') @@ -102,6 +116,7 @@ def evaluate(workflows, context, trigger): if context.get('customer_mapping_missing'): return {'status':'manual_review','method':None,'reason':'missing_customer_mapping','actions':[],'trace':[]} trace, actions, selected = [], [], None + stopped = False groups = defaultdict(list) for workflow in workflows: 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} selected = candidates[0] 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): 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['method'] in ('subscription','prepaid'): 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 if Decimal(str(eligible[0].get('remaining_hours') or 0))Historik
Henter historik…
{% endblock %} {% block extra_js %}{% endblock %} diff --git a/app/workflows/resources.py b/app/workflows/resources.py index 8e35ef7..08e5355 100644 --- a/app/workflows/resources.py +++ b/app/workflows/resources.py @@ -34,6 +34,8 @@ def update_resource(kind,resource_id,payload,user): if kind=='card' and 'monthly_price' in payload.model_fields_set: raise HTTPException(422,'Månedspris gælder kun fastprisaftaler') 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'])) if any(tag_id<=0 for tag_id in tag_ids): raise HTTPException(422,'Tag-ID skal være positivt') with transaction() as cursor: diff --git a/docs/workflow-manuel-testplan.md b/docs/workflow-manuel-testplan.md index 65dc047..64cb506 100644 --- a/docs/workflow-manuel-testplan.md +++ b/docs/workflow-manuel-testplan.md @@ -355,8 +355,9 @@ Notér ved fejl: testnummer, lokal URL, tidspunkt, brugerrolle, kunde-/sag-/tids | T20 Deaktivering | | | | T21 Kundekortaktivering | | | | 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). @@ -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. **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. diff --git a/docs/workflow-status.md b/docs/workflow-status.md index 25089b0..df67b56 100644 --- a/docs/workflow-status.md +++ b/docs/workflow-status.md @@ -2,6 +2,8 @@ ## 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. - Ren regelmotor med AND/OR, IF/ELSE IF/ELSE, kunderegler, prioritet, stopregel, konflikter og fuldt evalueringsspor. diff --git a/tests/test_workflow_automatic_coverage.py b/tests/test_workflow_automatic_coverage.py new file mode 100644 index 0000000..7a05c52 --- /dev/null +++ b/tests/test_workflow_automatic_coverage.py @@ -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 diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index e597996..24281f6 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -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')) assert 'monthly_price=%s' in update.args[0] 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)