From cdb9d8c1789623f686a8f75e4b05e7f37d82dedf Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 9 Oct 2026 00:45:21 +0200 Subject: [PATCH] Make economy time queue actionable and show case tags --- app/economy/backend/router.py | 27 ++++++++- app/economy/frontend/time_queue.html | 44 ++++++++------ docs/tidsko-manuel-testplan.md | 13 +++++ tests/test_economy_time_queue.py | 7 +++ .../test_economy_workflow_activation_cache.py | 38 ++++++++++++ tests/test_smart_time_queue.js | 58 +++++++++++++++++++ tests/test_workflow_employee_ui.py | 6 +- 7 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 docs/tidsko-manuel-testplan.md create mode 100644 tests/test_economy_workflow_activation_cache.py create mode 100644 tests/test_smart_time_queue.js diff --git a/app/economy/backend/router.py b/app/economy/backend/router.py index 29ca0b4..b4eb33b 100644 --- a/app/economy/backend/router.py +++ b/app/economy/backend/router.py @@ -225,6 +225,7 @@ async def list_hub_time_queue( params.append(limit) rows = execute_query(query, tuple(params)) + _attach_case_tags(rows) if settings.WORKFLOWS_ENABLED: _attach_workflow_decisions(rows) return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED} @@ -1230,6 +1231,26 @@ def _protect_workflow_selection(ids): raise HTTPException(409, 'Brug workflowbeslutningens begrundede overstyring og afregning') +def _attach_case_tags(rows): + """Load tags once for all visible cases, including legacy case-only tags.""" + case_ids = sorted({int(row['sag_id']) for row in rows if row.get('sag_id')}) + by_case = {} + if case_ids: + tags = execute_query('''SELECT et.entity_id AS sag_id,t.id,t.name,t.color,t.icon + FROM entity_tags et JOIN tags t ON t.id=et.tag_id + WHERE et.entity_type='case' AND et.entity_id=ANY(%s) + ORDER BY t.type,t.name,t.id''', (case_ids,)) or [] + for tag in tags: + by_case.setdefault(int(tag['sag_id']), []).append({key: tag.get(key) for key in ('id','name','color','icon')}) + missing = [case_id for case_id in case_ids if case_id not in by_case] + if missing: + legacy = execute_query('SELECT sag_id,id,tag_navn AS name FROM sag_tags WHERE sag_id=ANY(%s) AND deleted_at IS NULL ORDER BY tag_navn,id', (missing,)) or [] + for tag in legacy: + by_case.setdefault(int(tag['sag_id']), []).append({'id':tag['id'],'name':tag['name'],'color':None,'icon':None}) + for row in rows: + row['case_tags'] = by_case.get(int(row['sag_id']), []) if row.get('sag_id') else [] + + def _attach_workflow_decisions(rows): if not rows: return @@ -1238,10 +1259,14 @@ def _attach_workflow_decisions(rows): decisions=execute_query('''SELECT d.*,t.workflow_revision AS entry_revision FROM bmc_billing_decisions d JOIN tmodule_times t ON t.id=d.time_id WHERE d.time_id=ANY(%s) AND d.is_current''',(ids,)) or [] by_id={int(d['time_id']):d for d in decisions} + customer_activation = {} for row in rows: decision=by_id.get(int(row['id'])) row['workflow_decision']=decision - row['workflow_active']=active_for_customer(row.get('billing_customer_id') or row.get('customer_id')) + customer_id = row.get('billing_customer_id') or row.get('customer_id') + if customer_id not in customer_activation: + customer_activation[customer_id] = active_for_customer(customer_id) + row['workflow_active']=customer_activation[customer_id] row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision']) if row['workflow_active'] and decision and decision.get('method'): row['billing_method']=decision['method'] diff --git a/app/economy/frontend/time_queue.html b/app/economy/frontend/time_queue.html index 0701d9b..8d3d15f 100644 --- a/app/economy/frontend/time_queue.html +++ b/app/economy/frontend/time_queue.html @@ -14,7 +14,7 @@ .time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; } .time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; } .time-row .col-12.col-lg-4,.time-row .col-6,.time-row .col-8 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; } -.time-row > .mt-2 { display:none; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; } +.time-row > .mt-2 { display:block; margin-top:.2rem!important; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }.time-row > .queue-tags { white-space:normal; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; } @media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}} @media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}} .queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem} @@ -24,39 +24,49 @@
Økonomi

Tidskø til afregning

Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.

-
Registreringer
–
Timer i alt
–
Klar til faktura
–
Sager løst/lukket
–
Udvalgt
0
Forhåndsvisning kontrollerer; den ændrer ikke data.
+
Registreringer
–
Timer i alt
–
Faktura i køen
–
Sager løst/lukket
–
Udvalgt
0
Forhåndsvisning kontrollerer; den ændrer ikke data.

Afregning for valgte tidsregistreringer

Vælg type og eventuelt klippekort eller aftale. Løst/lukket sagsstatus forhindrer ikke afregning.
0 valgt
Vælg tidsregistreringer for at se kundens klippekort.
-

Afregningsmetoden er valgt af workflows. Brug Hvorfor? for at overstyre med begrundelse.

Forhåndsvisning af afregning
Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.
+

Afregningsmetoden er valgt af workflows. Brug Hvorfor? for at overstyre med begrundelse.

Forhåndsvisning af afregning
Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.
+
Foreløbig vurdering. Forhåndsvisning kontrollerer endeligt.
0 tidsregistreringer valgtVælg forhåndsvisning før afregning.
{% endblock %} diff --git a/docs/tidsko-manuel-testplan.md b/docs/tidsko-manuel-testplan.md new file mode 100644 index 0000000..68e4fc9 --- /dev/null +++ b/docs/tidsko-manuel-testplan.md @@ -0,0 +1,13 @@ +# Manuel test af den smartere tidskø + +Test lokalt på `/economy/time-queue` med en workflowkunde og en kunde uden workflows. + +1. Kontrollér, at Fastpris og Klippekort vises ud fra den aktuelle beslutning. En kladde, manglende beslutning eller manuel vurdering må ikke vises som Faktura. +2. Skift mellem alle tider, klar til gennemgang og kræver handling. Kontrollér tællere og forklaringer. En forældet beslutning skal kræve handling. +3. Filtrér på firma, søgning og metode. Vælg de synlige tider, der er klar til gennemgang: skjulte tider må ikke blive valgt. +4. Vælg både workflowtid og almindelig tid. Køen skal bede om separate udvalg frem for at overstyre workflowvalget. +5. Forhåndsvis et udvalg. Ændr derefter metode, aftale, kort eller udvalgte tider: gennemførsel skal kræve en ny forhåndsvisning. +6. Åbn sagsdetaljer og saglink. Kontrollér sagens tags, beskrivelse, medarbejder, dato, timer og afregningsvalg. +7. Brug kun særskilte testregistreringer ved faktisk afregning. Kontrollér ordrekladde/kortsaldo før og efter; en forhåndsvisning alene må ikke ændre dem. + +Klar til gennemgang er en indledende sortering. Forhåndsvisningen kontrollerer fortsat aftaler, saldo, beløb og aktuelle beslutninger inden gennemførsel. diff --git a/tests/test_economy_time_queue.py b/tests/test_economy_time_queue.py index 94c4f9a..503793c 100644 --- a/tests/test_economy_time_queue.py +++ b/tests/test_economy_time_queue.py @@ -1,5 +1,7 @@ from pathlib import Path import asyncio +import pytest +from app.core.config import settings from app.economy.backend.router import ( SettlementRequest, @@ -12,6 +14,11 @@ from app.economy.backend.router import ( ROOT = Path(__file__).resolve().parents[1] +@pytest.fixture(autouse=True) +def legacy_queue_mode(monkeypatch): + monkeypatch.setattr(settings, "WORKFLOWS_ENABLED", False) + + def test_time_queue_normalises_legacy_settlement_names(): assert _normalise_billing_method("prepaid_card") == "prepaid" assert _normalise_billing_method("fixed_price") == "subscription" diff --git a/tests/test_economy_workflow_activation_cache.py b/tests/test_economy_workflow_activation_cache.py new file mode 100644 index 0000000..a1ed49b --- /dev/null +++ b/tests/test_economy_workflow_activation_cache.py @@ -0,0 +1,38 @@ +from app.economy.backend import router +from app.workflows import integration + + +def test_queue_checks_activation_once_per_customer_without_changing_methods(monkeypatch): + calls=[] + def active(customer): + calls.append(customer) + return customer==17 + monkeypatch.setattr(integration,'active_for_customer',active) + monkeypatch.setattr(router,'execute_query',lambda *args:[{'time_id':1,'revision':2,'entry_revision':2,'method':'subscription','status':'decided','details':{'billing':{'agreement_id':2}}}]) + rows=[{'id':1,'customer_id':17,'billing_method':'draft'}, {'id':2,'customer_id':17,'billing_method':'draft'}, {'id':3,'customer_id':18,'billing_method':'invoice'}] + router._attach_workflow_decisions(rows) + assert calls==[17,18] + assert rows[0]['billing_method']=='subscription' + assert rows[0]['fixed_price_agreement_id']==2 + assert rows[1]['workflow_active'] is True + assert rows[2]['billing_method']=='invoice' + assert rows[2]['workflow_active'] is False + + +def test_case_tags_are_batched_and_legacy_used_only_for_cases_without_generic_tags(monkeypatch): + calls=[] + def query(sql,params): + calls.append((sql,params)) + if 'FROM entity_tags' in sql: + return [{'sag_id':196,'id':7,'name':'ABA','color':'#009999','icon':'bi-tag'}] + return [{'sag_id':197,'id':9,'name':'Legacy'}] + monkeypatch.setattr(router,'execute_query',query) + rows=[{'id':1,'sag_id':196},{'id':2,'sag_id':196},{'id':3,'sag_id':197},{'id':4,'sag_id':None}] + router._attach_case_tags(rows) + assert len(calls)==2 + assert calls[0][1]==([196,197],) + assert calls[1][1]==([197],) + assert rows[0]['case_tags']==rows[1]['case_tags'] + assert rows[0]['case_tags'][0]['name']=='ABA' + assert rows[2]['case_tags'][0]['name']=='Legacy' + assert rows[3]['case_tags']==[] diff --git a/tests/test_smart_time_queue.js b/tests/test_smart_time_queue.js new file mode 100644 index 0000000..ddc05c8 --- /dev/null +++ b/tests/test_smart_time_queue.js @@ -0,0 +1,58 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const vm=require('node:vm'); +const fs=require('node:fs'); +const html=fs.readFileSync('app/economy/frontend/time_queue.html','utf8'); +function fixture(rows=[]){ + const controls={};for(const id of ['settlementMethod','prepaidCard','agreementId','workflowLineMode','workflowLineModeHost','setMethodBtn','previewBtn','stickyPreviewBtn','selectionText','previewPanel','settleBtn','filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'])controls[id]={value:'',disabled:false,classList:{add(){},toggle(){}}}; + controls.workflowLineMode.value='separate'; + const state={items:rows,selected:new Set(rows.map(x=>x.id)),triage:'all',preview:{valid:true},previewPayload:{ids:[1]},previewFingerprint:'old'}; + const ctx={state,$:id=>controls[id],norm:v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[v]||v||'draft',ids:()=>[...state.selected],selectedRows:()=>rows,Number,JSON}; + vm.createContext(ctx);vm.runInContext(html.slice(html.indexOf(' function effectiveMethod('),html.indexOf(' function status(')),ctx); + return {ctx,controls,state}; +} +test('workflow stale, absent or manual review never inherits invoice fallback',()=>{ + const {ctx}=fixture(); + assert.equal(ctx.effectiveMethod({workflow_active:true,billing_method:'invoice'}),'draft'); + assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_stale:true,workflow_decision:{method:'invoice',status:'decided'}}),'draft'); + assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_decision:{method:'invoice',status:'manual_review'}}),'manual_review'); + assert.match(ctx.reviewIssue({workflow_active:true,workflow_stale:true}),/Genberegn/); +}); +test('readiness flags missing card/agreement and accepts concrete workflow selection',()=>{ + const {ctx}=fixture();const base={original_hours:1,customer_id:9,status:'pending'}; + assert.match(ctx.reviewIssue({...base,billing_method:'prepaid'}),/klippekort/); + assert.match(ctx.reviewIssue({...base,billing_method:'fixed_price'}),/aftale/); + assert.equal(ctx.reviewIssue({...base,workflow_active:true,workflow_decision:{method:'subscription',status:'decided'},fixed_price_agreement_id:2}),''); + assert.match(ctx.reviewIssue({...base,billing_method:'invoice',billable:false}),/ikke-fakturerbar/); +}); +test('mixed selection disables bulk overrides and preview instead of leaking configuration',()=>{ + const {ctx,controls}=fixture([{id:1,workflow_active:true},{id:2,workflow_active:false}]); + controls.settlementMethod.value='invoice';controls.prepaidCard.value='7';ctx.syncWorkflowControls(); + assert.equal(controls.setMethodBtn.disabled,true);assert.equal(controls.previewBtn.disabled,true);assert.equal(controls.stickyPreviewBtn.disabled,true);assert.equal(controls.settlementMethod.value,'');assert.equal(controls.prepaidCard.value,'');assert.match(controls.selectionText.textContent,/hver for sig/); +}); +test('preview fingerprint tracks billing inputs, filters, selection and line grouping',()=>{ + const {ctx,controls,state}=fixture([{id:1,workflow_active:true}]);const initial=ctx.fingerprint(); + controls.workflowLineMode.value='combined';assert.notEqual(ctx.fingerprint(),initial); + const grouped=ctx.fingerprint();controls.filterQuery.value='ABA';assert.notEqual(ctx.fingerprint(),grouped); + const filtered=ctx.fingerprint();state.selected.add(2);assert.notEqual(ctx.fingerprint(),filtered); + const selected=ctx.fingerprint();controls.agreementId.value='42';assert.notEqual(ctx.fingerprint(),selected); + ctx.invalidatePreview();assert.equal(state.preview,null);assert.equal(state.previewPayload,null);assert.equal(controls.settleBtn.disabled,true); +}); +test('settlement sends frozen preview request, never a mutated billing configuration',async()=>{ + const {ctx,controls,state}=fixture([{id:1}]);let sent=null;ctx.message=()=>{};ctx.api=async(url,options)=>{sent=JSON.parse(options.body);return {}};ctx.loadCustomers=async()=>{};ctx.load=async()=>{}; + state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint(); + vm.runInContext(html.slice(html.indexOf(' async function settle(){'),html.indexOf(' function selectionChanged()')),ctx); + await ctx.settle();assert.equal(sent.billing_method,'invoice');assert.deepEqual(sent.ids,[1]); + state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint();controls.agreementId.value='99';sent=null; + await ctx.settle();assert.equal(sent,null);assert.equal(state.preview,null); +}); +test('triage and select-ready respect the visible billing filter',()=>{ + const rows=[{id:1,billing_method:'invoice',original_hours:1,customer_id:9},{id:2,billing_method:'draft',original_hours:1},{id:3,billing_method:'prepaid',original_hours:1,prepaid_card_id:8}]; + const {ctx,controls,state}=fixture(rows);vm.runInContext(html.slice(html.indexOf(' function methodFilteredItems('),html.indexOf(' function render(){')),ctx); + state.triage='action';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[2]);state.triage='ready';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[1,3]); + controls.filterMethod.value='prepaid';ctx.selectionChanged=()=>{};vm.runInContext(html.slice(html.indexOf("$('selectReadyBtn').onclick="),html.indexOf(";['workflowLineMode'",html.indexOf("$('selectReadyBtn').onclick="))),Object.assign(ctx,{ $:id=>id==='selectReadyBtn'?(controls.selectReadyBtn??={}):controls[id]}));controls.selectReadyBtn.onclick();assert.deepEqual([...state.selected],[3]); +}); +test('case tags escape names without inserting customer-controlled styles',()=>{ + const {ctx}=fixture();ctx.esc=v=>String(v).replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); + const out=ctx.caseTags({case_tags:[{name:'ABA