From 4a06ed8e00d181c2e5485908409789c572e6346f Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 8 Oct 2026 21:36:42 +0200 Subject: [PATCH] Make V5 quick time capture automatic and keep billing choices in time tab --- app/modules/sag/backend/commands.py | 7 +- app/modules/sag/templates/detail_v4.html | 30 ++++++--- app/modules/sag/templates/detail_v5.html | 4 +- app/timetracking/backend/router.py | 72 ++++++++++++++++++++- docs/workflow-manuel-testplan.md | 18 +++++- docs/workflow-status.md | 2 + static/js/case-command-composer.js | 22 ++----- static/js/case-v5.js | 46 +++++++------ static/js/time-billing-review.js | 43 ++++++++++++- tests/js/case-command-composer.test.cjs | 10 +-- tests/test_case_v5_automatic_billing.py | 81 +++++++++++++++++++++++ tests/test_case_v5_billing_scope.py | 71 ++++++++++++++++++++ tests/test_workflow_employee_ui.py | 10 +-- tests/test_workflow_manual_edit_scope.py | 82 ++++++++++++++++++++++++ 14 files changed, 428 insertions(+), 70 deletions(-) create mode 100644 tests/test_case_v5_automatic_billing.py create mode 100644 tests/test_case_v5_billing_scope.py create mode 100644 tests/test_workflow_manual_edit_scope.py diff --git a/app/modules/sag/backend/commands.py b/app/modules/sag/backend/commands.py index 5ad4eec..8a5ba33 100644 --- a/app/modules/sag/backend/commands.py +++ b/app/modules/sag/backend/commands.py @@ -79,7 +79,7 @@ def normalize(cursor, case, payload, actor): minutes=c.get('minutes') if isinstance(minutes,bool) or not isinstance(minutes,int) or not (0 if kind=='estimate' else 1)<=minutes<=525600: fail('Ugyldigt minuttal') if kind=='time': - text(c,'description'); text(c,'category',required=False); choice=str(c.get('settlement_choice') or 'draft') + text(c,'description'); text(c,'category',required=False); choice='draft' if payload.get('billing_mode')=='automatic' else str(c.get('settlement_choice') or 'draft') from app.timetracking.backend.router import _case_billing_choice c['billing']=_case_billing_choice(case['id'],choice) c['settlement_choice']=choice @@ -194,7 +194,10 @@ async def run(sag_id,payload,actor,execute=False): with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,)); case=cursor.fetchone() if not case: raise HTTPException(404,detail='Sagen findes ikke') - digest=hashlib.sha256(json.dumps({'comment':payload.get('comment'),'commands':payload.get('commands'),'internal':payload.get('internal',True)},sort_keys=True,ensure_ascii=False).encode()).hexdigest() + identity={'comment':payload.get('comment'),'commands':payload.get('commands'),'internal':payload.get('internal',True)} + # Keep old replay hashes valid while including new capture semantics. + if 'billing_mode' in payload: identity['billing_mode']=payload['billing_mode'] + digest=hashlib.sha256(json.dumps(identity,sort_keys=True,ensure_ascii=False).encode()).hexdigest() rid=None if execute: try: rid=str(UUID(str(payload.get('request_id')))) diff --git a/app/modules/sag/templates/detail_v4.html b/app/modules/sag/templates/detail_v4.html index 61c0702..e679ad0 100644 --- a/app/modules/sag/templates/detail_v4.html +++ b/app/modules/sag/templates/detail_v4.html @@ -12534,7 +12534,7 @@ description: content, work_type: workType, billing_method: 'invoice', - ...(document.getElementById('v5QuickBilling') ? {settlement_choice: document.getElementById('v5QuickBilling').value} : {}) + ...(document.getElementById('caseV5Data') ? {billing_mode: 'automatic'} : {}) }) }); @@ -13884,7 +13884,7 @@ }); - + - + - + {% endblock %} diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index ce16417..04b6112 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -1039,6 +1039,12 @@ async def update_entry_details( t = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,)) if not t: raise HTTPException(status_code=404, detail="Time entry not found") + from app.core.config import settings + if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active' and t.get('vtiger_id') is None: + from app.workflows.integration import active_for_customer + from app.workflows.settlement import workflow_customer + if active_for_customer(workflow_customer(time_id)): + raise HTTPException(409, 'Redigér workflowtid i Tidsregistrering. Afregning kræver begrundet workflowoverstyring.') updates = [] params = [] @@ -1070,6 +1076,8 @@ async def update_entry_details( """ return execute_query_single(query, (time_id,)) + except HTTPException: + raise except Exception as e: logger.error(f"❌ Failed to update entry {time_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -1089,6 +1097,14 @@ async def reset_to_pending( - user_id: ID på brugeren der nulstiller (optional) """ try: + from app.core.config import settings + if time_id > 0 and settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active': + entry = execute_query_single('SELECT vtiger_id FROM tmodule_times WHERE id=%s', (time_id,)) + if entry and entry.get('vtiger_id') is None: + from app.workflows.integration import active_for_customer + from app.workflows.settlement import workflow_customer + if active_for_customer(workflow_customer(time_id)): + raise HTTPException(409, 'Workflowtid kan ikke nulstilles gennem den gamle wizard. Brug en sporbar korrektion.') return wizard.reset_to_pending(time_id, reason=reason, user_id=user_id) except HTTPException: raise @@ -1141,11 +1157,22 @@ async def approve_all_case_entries( Afr under automatisk efter configured settings. """ try: + from app.core.config import settings + if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active': + from app.workflows.integration import active_for_customer + from app.workflows.settlement import workflow_customer + for entry in wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card): + entry_id = entry.get('id') if isinstance(entry, dict) else entry.id + vtiger_id = entry.get('vtiger_id') if isinstance(entry, dict) else getattr(entry, 'vtiger_id', None) + if vtiger_id is None and active_for_customer(workflow_customer(entry_id)): + raise HTTPException(409, 'Workflowtider skal godkendes i bogholderiets tidskø, ikke gennem den gamle wizard.') return wizard.approve_case_entries( case_id=case_id, user_id=user_id, exclude_time_card=exclude_time_card ) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -2422,6 +2449,7 @@ async def list_time_entries_v1( day: Optional[date] = Query(None), medarbejder_id: Optional[int] = Query(None, gt=0), current_user: Optional[dict] = Depends(get_optional_user), + billing_display: bool = False, ): """List tidsregistreringer for en sag med filtre til timeline UI.""" try: @@ -2462,7 +2490,8 @@ async def list_time_entries_v1( WHERE {where_sql} ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC """ - return execute_query(query, tuple([_resolve_current_user_id(current_user)] + params)) + entries = execute_query(query, tuple([_resolve_current_user_id(current_user)] + params)) + return _case_time_billing_display(entries, sag_id, current_user) if billing_display else entries except Exception as e: logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, e) raise HTTPException(status_code=500, detail="Failed to list time entries") @@ -2480,7 +2509,7 @@ async def start_live_timer_v1( raise HTTPException(status_code=400, detail="sag_id is required") customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id")) - selection = _case_billing_choice(sag_id, str(payload["settlement_choice"])) if "settlement_choice" in payload else None + selection = _case_registration_billing(sag_id, payload) bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id")) if not bruger_id: @@ -2940,6 +2969,43 @@ async def list_team_timer_status_v1( return {"scope": scope, "rows": rows} +def _case_time_billing_display(entries, sag_id, user): + """Opt-in V5 display: retain stored fields and expose only the current choice.""" + from app.core.config import settings + from app.workflows.integration import active_for_customer + entries = entries or [] + decisions = {} + active = False + if settings.WORKFLOWS_ENABLED and entries: + case = execute_query_single('SELECT customer_id FROM sag_sager WHERE id=%s', (sag_id,)) or {} + active = active_for_customer(case.get('customer_id')) + present = execute_query_single("SELECT to_regclass('public.bmc_billing_decisions') AS name") + if present and present.get('name'): + rows = execute_query('SELECT time_id,revision,status,method FROM bmc_billing_decisions WHERE is_current AND time_id=ANY(%s)', + ([int(entry['id']) for entry in entries],)) or [] + decisions = {int(row['time_id']): row for row in rows} + user = user or {} + can_override = bool(user.get('is_superadmin') or {'workflows.override', 'workflows.decisions.view'}.issubset(set(user.get('permissions') or []))) + for entry in entries: + decision = decisions.get(int(entry['id'])) + stale = bool(decision and int(decision['revision']) != int(entry.get('workflow_revision') or 0)) + selected = entry.get('billing_method') or 'draft' + if decision and not stale: + selected = decision.get('method') if decision['status'] != 'manual_review' else 'manual_review' + elif active: + selected = 'draft' + entry.update(selected_billing_method=selected or 'draft', workflow_active=active, + workflow_can_override=active and can_override, workflow_decision=decision, workflow_stale=stale) + return entries + + +def _case_registration_billing(sag_id: int, payload: Dict[str, Any]): + """V5 quick capture is draft-only; explicit tab/legacy choices retain validation.""" + if payload.get("billing_mode") == "automatic": + return _case_billing_choice(sag_id, "draft") + return _case_billing_choice(sag_id, str(payload["settlement_choice"])) if "settlement_choice" in payload else None + + def _case_billing_choice(sag_id: int, choice: str) -> Dict[str, Any]: from app.core.config import settings if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active': @@ -2991,7 +3057,7 @@ async def create_manual_time_v1( raise HTTPException(status_code=400, detail="sag_id is required") customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id")) - selection = _case_billing_choice(sag_id, str(payload["settlement_choice"])) if "settlement_choice" in payload else None + selection = _case_registration_billing(sag_id, payload) requested_user_ids = payload.get("medarbejder_ids") is_bulk_request = requested_user_ids is not None diff --git a/docs/workflow-manuel-testplan.md b/docs/workflow-manuel-testplan.md index 539e6c5..65dc047 100644 --- a/docs/workflow-manuel-testplan.md +++ b/docs/workflow-manuel-testplan.md @@ -124,7 +124,7 @@ Aktivér kun testreglerne. Eksisterende aktive regler kan påvirke resultatet; n 2. Registrér almindelig tid på sag B1 gennem den normale medarbejderskærm. 3. Åbn økonomikøen `/economy/time-queue` og kontrollér den eksisterende afregningsarbejdsgang. -**Forventet:** ingen ny automatisk workflowbeslutning og ingen ændring af de hidtidige valg. Sammenlign med arbejdsgangen fra før ændringen. Undlad afregning af kontroltiden. +**Forventet:** ingen ny automatisk workflowbeslutning. På V5 gemmes hurtigregistrering som kladde; manuelt valg findes under Tidsregistrering. V4 og den hidtidige økonomikø bevarer deres arbejdsgang. Undlad afregning af kontroltiden. ### T02 – Hierarki, søgning og cyklus @@ -183,7 +183,7 @@ Skift til aktiv test. Opret **nye** tidsregistreringer; tidligere hændelser, de 2. Kontrollér sagsskærm, tidscomposer og bundbar: afregningsvalg/direkte godkendelse må ikke være tilgængelige for denne aktive workflowkunde. 3. Registrér tid på B1 som kontrol. -**Forventet:** A-tiden går videre til bogholderiet uden medarbejdervalg af afregning. B følger eksisterende arbejdsgang. Ingen korttimer trækkes ved registrering. +**Forventet:** A-tiden går videre til bogholderiet uden medarbejdervalg af afregning. På V5 gemmes B som kladde; manuelt valg findes under Tidsregistrering. Ingen korttimer trækkes ved registrering. ### T08 – Fastpris, faktura, kort og manglende regel @@ -354,7 +354,19 @@ Notér ved fejl: testnummer, lokal URL, tidspunkt, brugerrolle, kunde-/sag-/tids | T19 e-conomic (valgfri) | | | | T20 Deaktivering | | | | T21 Kundekortaktivering | | | +| T22 V5-afregning | | | -Grundforløbet er manuelt accepteret, når T01–T17, T20 og T21 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 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. 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). + +### T22 – Afregning på Sag V5 + +1. Åbn `/sag//v5` for både en aktiv workflowkunde og en kunde uden aktive workflows. Registrér tid med hurtigkommentar, timer og `/tid`. +2. Kontrollér, at ingen af disse registreringsfelter tilbyder afregningsvalg. Kunden uden workflows skal få kladde. Den aktive kunde skal få kladde, indtil workflowet vælger afregning. +3. Åbn Tidsregistrering. Kontrollér, at den valgte metode vises, også når den lagrede tid fortsat er kladde. +4. Vælg afregning manuelt under fanen. For aktive workflows kræves `workflows.override` og `workflows.decisions.view` samt en begrundelse. Kontrollér beslutningshistorikken og uændret kortsaldo før økonomisk godkendelse. +5. Kontrollér, at allerede godkendt/afregnet workflowtid ikke kan ændres gennem denne knap. Kontrollér også, at en almindelig medarbejder uden overstyringsrettighed ikke får knappen for aktiv workflowtid. +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. diff --git a/docs/workflow-status.md b/docs/workflow-status.md index 3598717..25089b0 100644 --- a/docs/workflow-status.md +++ b/docs/workflow-status.md @@ -2,6 +2,8 @@ ## Implementeret lokalt +- 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. - Versioneret builder, read-only simulator, aktiveringskontrol, holdbare events, skyggetilstand, kundetilladelsesliste og separat aktivering for interne tider. - Tags, hierarki, nøgleord, AI-forslag med godkendelse, beslutningshistorik, manuel overstyring og afregning ved økonomisk godkendelse. diff --git a/static/js/case-command-composer.js b/static/js/case-command-composer.js index 9bd35be..4d3fb29 100644 --- a/static/js/case-command-composer.js +++ b/static/js/case-command-composer.js @@ -54,7 +54,7 @@ helpList.append(intro); const groups=[ ['Tid og planlægning','clock-history',[ - ['tid','Registrer tid og vælg afregning'],['timer','Start timer · brug /timer pause for at pause'], + ['tid','Registrer tid som kladde eller med automatisk afregning'],['timer','Start timer · brug /timer pause for at pause'], ['forventet','Sæt forventet tidsforbrug'],['deadline','Angiv seneste dato'],['udsat','Udsæt sagen inden deadline']]], ['Ansvar og opgaver','person-check',[ ['tildel','Vælg medarbejder med begrundelse'],['mig','Tildel sagen til dig selv'], @@ -118,6 +118,7 @@ const saved = key ? JSON.parse(sessionStorage.getItem(key) || 'null') : null; if (saved && !input.value && typeof saved.text === 'string') { input.value = saved.text; requestId = saved.requestId || null; frozenText = saved.frozenText || null; frozenBody=saved.frozenBody || null; + if(frozenBody&&frozenBody.billing_mode!=='automatic')message('En tidligere gemning med afregningsvalg afventer svar. Prøv den samme gemning igen; nye registreringer gemmes derefter uden afregningsvalg.'); if(Array.isArray(saved.settlements)) saved.settlements.forEach(([id,value])=>{if(typeof id==='string' && typeof value==='string') settlements.set(id,value);}); } } catch (_) { storageAvailable = false; } @@ -134,7 +135,7 @@ function message(text,error=false) { status.textContent = text; status.classList.toggle('is-error',error); } - function body() { return frozenBody || {comment:parsed.comment, commands:parsed.commands, internal:true}; } + function body() { return frozenBody || {comment:parsed.comment, commands:parsed.commands, internal:true,billing_mode:'automatic'}; } function label(command) { const names={time:'Tid',assign:'Tildel',estimate:'Estimat',subcase:'Opret undersag',self:'Tildel mig',todo:'Todo',waiting:'Afventer kunde',solution:'Løsning',deadline:'Deadline',defer:'Udsæt',file:'Vedhæft filer',help:'Hjælp',timer:'Timer',relation:'Relation',priority:'Prioritet',group:'Gruppe',mail:'Mailkladde',note:'Intern note'}; const target=command.scope==='main' ? 'Hovedsag' : `Undersag ${Number(command.scope)+1}`; @@ -203,19 +204,8 @@ chip.title = 'Klik for at rette kommandolinjen'; chip.addEventListener('click', () => editLine(command.line)); chips.append(chip); if(command.type==='time') { - if(workflowActive){ - delete command.settlement_choice; - const note=document.createElement('span');note.className='small text-muted';note.textContent='Afregning vælges automatisk';chips.append(note); - return; - } - const identity=`${command.line}:${input.value.split('\n')[command.line-1]}`; - command.settlement_choice=settlements.get(identity) || 'draft'; - const field=document.createElement('select'); field.className='case-command-settlement'; - field.setAttribute('aria-label',`Afregning for tid på linje ${command.line}`); - billingChoices.forEach(([value,text])=>field.add(new Option(text,value))); - field.value=command.settlement_choice; field.disabled=frozenText!==null; - field.addEventListener('change',()=>{settlements.set(identity,field.value); update();}); - chips.append(field); + delete command.settlement_choice; + const note=document.createElement('span');note.className='small text-muted';note.textContent=workflowActive?'Afregning vælges automatisk':'Tid gemmes som kladde · afregning vælges i Tidsregistrering';chips.append(note); } if (command.type === 'file') { const file = document.createElement('button'); file.type='button'; file.textContent='Vælg filer…'; @@ -225,7 +215,7 @@ }); renderSuggestions(); store(); updateSubmitLabel(); if(parsed.commands.some(command=>command.type==='help')) help.open=true; - if (frozenText !== null) { input.readOnly = true; message('En gemning afventer svar. Tryk Gem igen for at fortsætte sikkert.'); return; } + if (frozenText !== null) { input.readOnly = true; message(frozenBody&&frozenBody.billing_mode!=='automatic'?'En tidligere gemning med afregningsvalg afventer svar. Tryk Gem igen for at fortsætte den samme gemning sikkert.':'En gemning afventer svar. Tryk Gem igen for at fortsætte sikkert.'); return; } if (parsed.errors.length) message(parsed.errors.map(e=>`Linje ${e.line}: ${e.message}`).join('\n'),true); else if (parsed.commands.length) { message('Kontrollerer handlinger…'); const version=generation; timer=setTimeout(()=>preview(version),350); } else message(''); diff --git a/static/js/case-v5.js b/static/js/case-v5.js index 624b1bd..16f0320 100644 --- a/static/js/case-v5.js +++ b/static/js/case-v5.js @@ -15,36 +15,34 @@ compactBillingHost.querySelector('.form-text').removeAttribute('id'); byId('caseQuickTimeDraftForm').querySelector('.row').insertBefore(compactBillingHost,byId('caseQuickTimeSaveButton').parentElement); byId('caseQuickTimeSaveButton').innerHTML='Gem tid'; + function setAutomaticRegistrationControls(workflowActive = false) { + const text=workflowActive?'Afregning vælges automatisk':'Tid gemmes som kladde. Afregning vælges i Tidsregistrering.'; + for(const select of [byId('v5QuickBilling'),byId('v5CompactBilling'),byId('time_billing_method'),byId('quickTimeBillingMethod')].filter(Boolean)) { + select.replaceChildren(new Option('Automatisk / kladde','draft')); + select.value='draft';select.disabled=true;select.hidden=true;select.dataset.billingReady='true'; + const label=select.parentElement.querySelector(`label[for="${select.id}"]`);if(label)label.hidden=true; + } + for(const host of [billingHost,compactBillingHost])host.querySelector('.form-text').textContent=text; + 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(); async function loadBillingOptions() { - const quick=byId('v5QuickBilling'), modal=byId('time_billing_method'), compact=byId('v5CompactBilling'); 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'); - if(data.workflow_active === true) { - for(const select of [quick,modal,compact].filter(Boolean)) { - select.replaceChildren(new Option('Afregning vælges automatisk','draft')); - select.value='draft'; select.disabled=true; select.hidden=true; - select.dataset.billingReady='true'; select.dataset.workflowActive='true'; - const label=select.parentElement.querySelector(`label[for="${select.id}"]`); - if(label)label.hidden=true; - } - for(const host of [billingHost,compactBillingHost])host.querySelector('.form-text').textContent='Afregning vælges automatisk'; - if(modal){const hint=document.createElement('div');hint.className='form-text';hint.textContent='Afregning vælges automatisk';modal.after(hint);} - return; + setAutomaticRegistrationControls(data.workflow_active===true); + const anchor=byId('timeV1Date'); + if(anchor&&!byId('v5TabBilling')) { + const host=document.createElement('div');host.className='col-12'; + const label=document.createElement('label');label.htmlFor='v5TabBilling';label.className='form-label small';label.textContent='Afregning (valgfrit)'; + const select=document.createElement('select');select.id='v5TabBilling';select.className='form-select form-select-sm'; + select.add(new Option('Kladde / automatisk','draft')); + if(data.workflow_active!==true){if(data.has_customer)select.add(new Option('Faktura','invoice'));(data.prepaid_cards||[]).forEach(c=>select.add(new Option('Klippekort '+(c.card_number||c.id),'card_'+c.id)));(data.agreements||[]).forEach(a=>select.add(new Option('Fastpris '+(a.agreement_number||a.id),'fpa_'+a.id)));select.add(new Option('Intern tid','internal'));select.add(new Option('Ikke fakturerbar','non_billable'));} + const hint=document.createElement('div');hint.className='form-text';hint.textContent=data.workflow_active?'Afregningen vælges af workflows. Brug afregningsbeslutningen nedenfor for en begrundet ændring.':'Manuel afregning kan kun vælges her i Tidsregistrering.'; + host.append(label,select,hint);anchor.closest('.row')?.append(host); } - const rate=Number(data.hourly_rate).toLocaleString('da-DK',{minimumFractionDigits:2,maximumFractionDigits:2}); - const choices=[['draft','Kladde – skal kontrolleres af en chef'],['operations','Drift – chef vælger afregning'],['development','Udvikling – chef vælger afregning']]; - if(data.has_customer)choices.push(['invoice',`Faktura – ${rate} kr./time`]); - (data.prepaid_cards||[]).forEach(card=>choices.push([`card_${card.id}`,`Klippekort #${card.card_number || card.id} · ${Number(card.remaining_hours).toLocaleString('da-DK')} timer tilbage · ${data.customer_name}`])); - (data.agreements||[]).forEach(agreement=>choices.push([`fpa_${agreement.id}`,`Fastpris #${agreement.agreement_number || agreement.id} · ${Number(agreement.monthly_hours).toLocaleString('da-DK')} timer/måned · ${data.customer_name}`])); - for(const select of [quick,modal,compact].filter(Boolean)) { - select.replaceChildren(...choices.map(([value,label])=>new Option(label,value))); - select.value='draft';select.disabled=false;select.dataset.billingReady='true'; - } - if(modal){ const hint=document.createElement('div');hint.className='form-text';hint.textContent=`Kundens timepris: ${rate} kr. Kladder kontrolleres af en chef.`;modal.after(hint); } - byId('v5BillingHint').textContent=`Kundens timepris: ${rate} kr./time. Kladder kontrolleres af en chef. Kun aktive klippekort og fastprisaftaler til sagens firma kan vælges.`; - } catch(error){ byId('v5BillingHint').textContent=error.message; if(modal){modal.disabled=true;modal.dataset.billingReady='false';} } + } catch(error){byId('v5BillingHint').textContent='Tid kan stadig registreres som kladde. Afregningsmuligheder kunne ikke hentes.';} } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadBillingOptions,{once:true});else loadBillingOptions(); const deferredInput = byId('topbarDeferredInput'); diff --git a/static/js/time-billing-review.js b/static/js/time-billing-review.js index f180eaf..71870e9 100644 --- a/static/js/time-billing-review.js +++ b/static/js/time-billing-review.js @@ -2,6 +2,47 @@ window.TimeBillingReview = (() => { const labels={draft:'Kladde',operations:'Drift',development:'Udvikling'}; const needsReview=entry=>Object.hasOwn(labels,entry.billing_method || entry._billing_method || ''); const badge=entry=>needsReview(entry)?`${labels[entry.billing_method || entry._billing_method]} · Chefkontrol`:''; + const methods={invoice:'Faktura',prepaid:'Klippekort',subscription:'Fastpris',fixed_price:'Fastpris',internal:'Intern tid',non_billable:'Ikke fakturerbar',draft:'Kladde',operations:'Drift',development:'Udvikling'}; + const settled=entry=>entry.status==='billed'||(entry.workflow_active===true&&entry.status==='approved')||!!entry.economy_settled_at||!!entry.economy_order_draft_id||!!entry.billed_via_thehub_id; + const canChoose=entry=>!settled(entry)&&(entry.workflow_active===true?entry.workflow_can_override===true:needsReview(entry)); + const displayBadge=entry=>{ + const method=entry.selected_billing_method||entry.workflow_decision?.method||entry.billing_method||'draft'; + const text=method==='manual_review'?'Manuel vurdering':methods[method]||'Afregning afventer'; + return `${text}${entry.workflow_stale?' · skal genberegnes':''}`; + }; + async function choose(entry) { + if(!canChoose(entry))throw new Error('Afregningen kan ikke ændres for denne tidsregistrering.'); + if(entry.workflow_active!==true)return approve(entry); + const optionsResponse=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/settlement-options`,{credentials:'include'}); + if(!optionsResponse.ok)throw new Error('Afregningsmuligheder kunne ikke hentes'); + const data=await optionsResponse.json(); + const decisionResponse=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/workflow-decision`,{credentials:'include'}); + if(!decisionResponse.ok)throw new Error('Afregningsbeslutningen kunne ikke hentes'); + const history=await decisionResponse.json(),current=history.current; + if(current?.status==='settled')throw new Error('Afregnet tid kræver en særskilt korrektion.'); + const host=document.createElement('div');host.className='modal fade';host.tabIndex=-1; + host.innerHTML=''; + const select=host.querySelector('select');select.add(new Option('Faktura','invoice'));select.add(new Option('Intern tid','internal'));select.add(new Option('Ikke fakturerbar','non_billable')); + (data.prepaid_cards||[]).forEach(c=>select.add(new Option('Klippekort '+(c.card_number||c.id),'card_'+c.id))); + (data.agreements||[]).forEach(a=>select.add(new Option('Fastpris '+(a.agreement_number||a.id),'fpa_'+a.id))); + const details=current?.details||{},billing=details.billing||details.decision||{}; + select.value=current?.method==='prepaid'?'card_'+billing.card_id:current?.method==='subscription'?'fpa_'+billing.agreement_id:current?.method||entry.selected_billing_method||'invoice'; + if(!select.value)select.value='invoice'; + document.body.append(host);const modal=new bootstrap.Modal(host); + return new Promise(resolve=>{ + let changed=false; + host.addEventListener('hidden.bs.modal',()=>{modal.dispose();host.remove();resolve(changed);},{once:true}); + host.querySelector('form').onsubmit=async event=>{ + event.preventDefault();const reason=host.querySelector('textarea').value.trim(); + if(reason.length<3){host.querySelector('[role=status]').textContent='Angiv en kort begrundelse.';return;} + const value=select.value,payload={method:value,reason},button=host.querySelector('[type=submit]');button.disabled=true; + if(value.startsWith('card_')){payload.method='prepaid';payload.card_id=Number(value.slice(5));} + if(value.startsWith('fpa_')){payload.method='subscription';payload.agreement_id=Number(value.slice(4));} + if(billing.product_number)payload.product_number=billing.product_number; + try{const response=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/workflow-override`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const result=await response.json().catch(()=>({}));if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Afregningsvalget kunne ikke gemmes');changed=true;modal.hide();}catch(error){host.querySelector('[role=status]').textContent=error.message;button.disabled=false;} + };modal.show(); + }); + } async function approve(entry,hours) { const response=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/settlement-options`,{credentials:'include'}); if(!response.ok)throw new Error('Afregningsmuligheder kunne ikke hentes'); @@ -30,5 +71,5 @@ window.TimeBillingReview = (() => { };modal.show(); }); } - return {needsReview,badge,approve}; + return {needsReview,badge,approve,choose,canChoose,displayBadge}; })(); diff --git a/tests/js/case-command-composer.test.cjs b/tests/js/case-command-composer.test.cjs index 99ce84c..bc0b215 100644 --- a/tests/js/case-command-composer.test.cjs +++ b/tests/js/case-command-composer.test.cjs @@ -60,18 +60,18 @@ test('definite 409 allows editing instead of trapping the draft',async()=>{ await assert.rejects(f.controller.submit(),/Timer allerede/); assert.equal(f.input.readOnly,false);assert.equal(f.input.value,'/timer start "Support"'); }); -test('save freezes input before preview responds and preserves separate billing per line',async()=>{ +test('save freezes input before preview responds and uses automatic billing for every line',async()=>{ let release; const f=await fixture((url,body)=>url.endsWith('preview') ? new Promise(resolve=>{release=()=>resolve(response(200,{commands:body.commands}));}) : response(200,{results:[]})); f.type('/tid 30m remote "Support"\n/tid 30m remote "Support"'); - const choices=f.nodes.filter(node=>node.tag==='select').slice(-2); - choices[0].value='operations';choices[0].listeners.change(); + assert.equal(f.nodes.filter(node=>node.tag==='select').length,0); const actual=f.controller.getParsed().commands; - assert.equal(actual[0].settlement_choice,'operations');assert.equal(actual[1].settlement_choice,'draft'); + assert.equal(actual[0].settlement_choice,undefined);assert.equal(actual[1].settlement_choice,undefined); const promise=f.controller.submit();assert.equal(f.input.readOnly,true); release();await promise; const payload=f.calls.find(call=>call.url.endsWith('execute')).body; - assert.equal(payload.commands[0].settlement_choice,'operations');assert.equal(payload.commands[1].settlement_choice,'draft'); + assert.equal(payload.billing_mode,'automatic'); + assert.equal(payload.commands[0].settlement_choice,undefined);assert.equal(payload.commands[1].settlement_choice,undefined); }); diff --git a/tests/test_case_v5_automatic_billing.py b/tests/test_case_v5_automatic_billing.py new file mode 100644 index 0000000..618e9a4 --- /dev/null +++ b/tests/test_case_v5_automatic_billing.py @@ -0,0 +1,81 @@ +import asyncio +from importlib import import_module +from unittest.mock import MagicMock +import pytest +from app.modules.sag.backend import commands +from app.core.config import settings + +router = import_module('app.timetracking.backend.router') + + +@pytest.mark.parametrize('choice', ['invoice', 'card_7', 'fpa_9']) +def test_automatic_capture_ignores_manual_billing_and_saves_draft(monkeypatch, choice): + monkeypatch.setattr(router, '_resolve_case_customer_id', lambda *args: 77) + captured = {} + def choose(case, value): + assert value == 'draft' + return {'method': 'draft', 'status': 'kladde', 'card_id': None, 'agreement_id': None} + monkeypatch.setattr(router, '_case_billing_choice', choose) + def insert(sql, params): + captured['params'] = params + return [{'id': 3}] + monkeypatch.setattr(router, 'execute_query', insert) + asyncio.run(router.create_manual_time_v1({'sag_id': 186, 'faktisk_tid_min': 15, + 'billing_mode': 'automatic', 'settlement_choice': choice, 'billing_method': 'invoice'}, + {'id': 12, 'username': 'test'})) + assert captured['params'][9] == 'draft' + assert captured['params'][16] == 'kladde' + assert captured['params'][-4:-2] == (None, None) + + +def test_explicit_time_tab_choice_retains_existing_validation(monkeypatch): + choose = MagicMock(return_value={'method': 'invoice'}) + monkeypatch.setattr(router, '_case_billing_choice', choose) + assert router._case_registration_billing(186, {'billing_mode': 'manual', 'settlement_choice': 'invoice'}) == {'method': 'invoice'} + choose.assert_called_once_with(186, 'invoice') + + +def test_automatic_live_timer_is_draft_even_when_invoice_is_submitted(monkeypatch): + monkeypatch.setattr(router, '_resolve_case_customer_id', lambda *args: 77) + monkeypatch.setattr(router, '_resolve_target_user_id', lambda *args: 12) + monkeypatch.setattr(router, 'execute_query_single', lambda *args: None) + monkeypatch.setattr(router, '_case_billing_choice', lambda case, choice: { + 'method': choice, 'status': 'kladde', 'card_id': None, 'agreement_id': None}) + def insert(sql, params): + assert params[-4:-1] == ('draft', None, None) + return [{'id': 3}] + monkeypatch.setattr(router, 'execute_query', insert) + asyncio.run(router.start_live_timer_v1({'sag_id': 186, 'billing_mode': 'automatic', + 'settlement_choice': 'invoice'}, {'id': 12, 'username': 'test'})) + + +def test_v5_command_cannot_select_card_in_automatic_mode(monkeypatch): + choose = MagicMock(return_value={'method': 'draft', 'status': 'kladde', 'card_id': None, 'agreement_id': None}) + monkeypatch.setattr(router, '_case_billing_choice', choose) + result = commands.normalize(MagicMock(), {'id': 186}, {'billing_mode': 'automatic', + 'commands': [{'type': 'time', 'minutes': 15, 'description': 'Test', 'settlement_choice': 'card_7'}]}, {'id': 12}) + choose.assert_called_once_with(186, 'draft') + assert result[0]['settlement_choice'] == 'draft' + + +def test_display_uses_workflow_choice_without_mutating_stored_draft(monkeypatch): + monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', True) + from app.workflows import integration + monkeypatch.setattr(integration, 'active_for_customer', lambda _: True) + monkeypatch.setattr(router, 'execute_query_single', lambda sql, params=None: {'customer_id': 24} if 'sag_sager' in sql else {'name': 'bmc_billing_decisions'}) + monkeypatch.setattr(router, 'execute_query', lambda *args: [{'time_id': 3, 'revision': 2, 'status': 'decided', 'method': 'subscription'}]) + result = router._case_time_billing_display([{'id': 3, 'workflow_revision': 2, 'billing_method': 'draft'}], 186, + {'permissions': ['workflows.override', 'workflows.decisions.view']})[0] + assert result['billing_method'] == 'draft' + assert result['selected_billing_method'] == 'subscription' + assert result['workflow_can_override'] is True + + +def test_stale_workflow_choice_displays_draft_until_recalculated(monkeypatch): + monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', True) + from app.workflows import integration + monkeypatch.setattr(integration, 'active_for_customer', lambda _: True) + monkeypatch.setattr(router, 'execute_query_single', lambda sql, params=None: {'customer_id': 24} if 'sag_sager' in sql else {'name': 'bmc_billing_decisions'}) + monkeypatch.setattr(router, 'execute_query', lambda *args: [{'time_id': 3, 'revision': 1, 'status': 'decided', 'method': 'prepaid'}]) + result = router._case_time_billing_display([{'id': 3, 'workflow_revision': 2, 'billing_method': 'draft'}], 186, {})[0] + assert result['selected_billing_method'] == 'draft' and result['workflow_stale'] is True diff --git a/tests/test_case_v5_billing_scope.py b/tests/test_case_v5_billing_scope.py new file mode 100644 index 0000000..b8cc337 --- /dev/null +++ b/tests/test_case_v5_billing_scope.py @@ -0,0 +1,71 @@ +import shutil +import subprocess +from pathlib import Path +import pytest + + +@pytest.mark.skipif(not shutil.which('node'), reason='Node required for V5 behavior') +def test_composer_new_automatic_body_does_not_mutate_frozen_unknown_outcome(): + source = Path('static/js/case-command-composer.js').read_text() + body = source.split('function body()', 1)[1].split(' function label(', 1)[0] + script = r''' +const assert=require('node:assert/strict'); +let frozenBody=null,parsed={comment:'Tid',commands:[{type:'time',minutes:15}]}; +function body() BODY +assert.equal(body().billing_mode,'automatic'); +const legacy={comment:'Older',commands:[{type:'time',settlement_choice:'card_8'}],internal:true};frozenBody=legacy; +assert.equal(body(),legacy);assert.equal(body().commands[0].settlement_choice,'card_8');assert.equal(body().billing_mode,undefined); +'''.replace('BODY', body) + result = subprocess.run(['node', '-e', script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(not shutil.which('node'), reason='Node required for V5 behavior') +def test_active_billing_choice_permission_and_selected_method(): + source = Path('static/js/time-billing-review.js').read_text() + script = r''' +const assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),window={}; +vm.runInNewContext(fs.readFileSync('static/js/time-billing-review.js','utf8'),{window});const review=window.TimeBillingReview; +assert.equal(review.canChoose({workflow_active:true,workflow_can_override:false,status:'pending'}),false); +assert.equal(review.canChoose({workflow_active:true,workflow_can_override:true,status:'pending'}),true); +assert.equal(review.canChoose({workflow_active:true,workflow_can_override:true,status:'billed'}),false); +assert.match(review.displayBadge({billing_method:'draft',selected_billing_method:'invoice'}),/Faktura/); +assert.doesNotMatch(review.displayBadge({billing_method:'draft',selected_billing_method:'invoice'}),/Kladde/); +''' + result = subprocess.run(['node', '-e', script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +def test_shared_v4_payload_markers_are_conditional_and_manual_only_in_time_tab(): + source = Path('app/modules/sag/templates/detail_v4.html').read_text() + manual = source.split('async function createManualTimeV1(event)', 1)[1].split(' async function ', 1)[0] + assert "document.getElementById('caseV5Data') ? {billing_mode:'manual'" in manual + quick = source.split('async function registerQuickTimeFromComment(content)', 1)[1].split(' function resolveCommentAwait', 1)[0] + assert "document.getElementById('caseV5Data') ? {billing_mode: 'automatic'} : {}" in quick + assert 'settlement_choice' not in quick + assert "document.getElementById('caseV5Data')?'&billing_display=true':''" in source + + +@pytest.mark.skipif(not shutil.which('node'), reason='Node required for V5 behavior') +def test_active_manual_selection_only_saves_audited_decision(): + script = r''' +const assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),window={},calls=[]; +let host; +const select={value:'invoice',add(){}},textarea={value:''},submit={disabled:false},status={},form={}; +const document={createElement:()=>host={listeners:{},querySelector:s=>({select,textarea,form,'[type=submit]':submit,'[role=status]':status}[s]),addEventListener:(name,fn)=>host.listeners[name]=fn,remove(){}},body:{append(){}}}; +class Modal{show(){}hide(){host.listeners['hidden.bs.modal']();}dispose(){}} +const fetch=async(url,options={})=>{calls.push({url,options});return{ok:true,json:async()=>url.endsWith('settlement-options')?{prepaid_cards:[{id:8}],agreements:[]}:url.endsWith('workflow-decision')?{current:{method:'invoice',status:'decided',details:{}}}:{id:1}};}; +function Option(label,value){this.value=value;} +vm.runInNewContext(fs.readFileSync('static/js/time-billing-review.js','utf8'),{window,document,fetch,Option,bootstrap:{Modal}}); +(async()=>{ + const promise=window.TimeBillingReview.choose({id:1,workflow_active:true,workflow_can_override:true,status:'pending'}); + await new Promise(resolve=>setImmediate(resolve)); + select.value='card_8';await form.onsubmit({preventDefault(){}});assert.equal(calls.length,2); + textarea.value='Gyldig kundeaftale';await form.onsubmit({preventDefault(){}});assert.equal(await promise,true); + assert.equal(calls.length,3);assert.match(calls[2].url,/workflow-override$/);assert.equal(calls[2].options.method,'POST'); + assert.deepEqual(JSON.parse(calls[2].options.body),{method:'prepaid',reason:'Gyldig kundeaftale',card_id:8}); + assert.equal(calls.some(call=>call.url.endsWith('/approve')),false); +})().catch(error=>{console.error(error);process.exitCode=1;}); +''' + result = subprocess.run(['node', '-e', script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr diff --git a/tests/test_workflow_employee_ui.py b/tests/test_workflow_employee_ui.py index 265103d..25e11a3 100644 --- a/tests/test_workflow_employee_ui.py +++ b/tests/test_workflow_employee_ui.py @@ -7,9 +7,9 @@ import pytest @pytest.mark.skipif(not shutil.which('node'), reason='Node required for UI behavior check') @pytest.mark.parametrize('active', [True, False]) -def test_v5_employee_billing_controls_only_hidden_for_active_workflow(active): +def test_v5_normal_registration_never_exposes_billing_choice(active): source = Path('static/js/case-v5.js').read_text() - function = source.split('async function loadBillingOptions()', 1)[1].split(" if(document.readyState", 1)[0] + function = source.split('function setAutomaticRegistrationControls', 1)[1].split(" if(document.readyState", 1)[0] script = r''' const assert=require('node:assert/strict'); const controls={}; @@ -21,12 +21,12 @@ const document={createElement:()=>({})}; 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:[]})}); -async function loadBillingOptions() BODY +function setAutomaticRegistrationControls BODY loadBillingOptions().then(()=>{ for(const id of ['v5QuickBilling','time_billing_method','v5CompactBilling']){ const select=controls[id];assert.equal(select.dataset.billingReady,'true');assert.equal(select.value,'draft'); - assert.equal(select.disabled,active);assert.equal(!!select.hidden,active); - if(active)assert.equal(select.options.length,1);else assert.equal(select.options.length,4); + assert.equal(select.disabled,true);assert.equal(!!select.hidden,true); + assert.equal(select.options.length,1); } }).catch(err=>{console.error(err);process.exitCode=1}); '''.replace('ACTIVE', 'true' if active else 'false').replace('BODY', function) diff --git a/tests/test_workflow_manual_edit_scope.py b/tests/test_workflow_manual_edit_scope.py new file mode 100644 index 0000000..b529b45 --- /dev/null +++ b/tests/test_workflow_manual_edit_scope.py @@ -0,0 +1,82 @@ +"""Time-tab financial edits must use the audited workflow override path.""" +import asyncio +from importlib import import_module +import pytest +from fastapi import HTTPException +from app.core.config import settings +from app.workflows import integration,settlement +from app.workflows import decisions,classification + +router=import_module('app.timetracking.backend.router') + +@pytest.fixture +def active(monkeypatch): + monkeypatch.setattr(settings,'WORKFLOWS_ENABLED',True) + monkeypatch.setattr(settings,'WORKFLOWS_MODE','active') + monkeypatch.setattr(integration,'active_for_customer',lambda customer:customer==24) + monkeypatch.setattr(settlement,'workflow_customer',lambda _:24) + +@pytest.mark.parametrize('payload',[{'billing_method':'prepaid'},{'prepaid_card_id':9},{'fixed_price_agreement_id':8}]) +def test_raw_patch_does_not_change_active_workflow_financial_selection(monkeypatch,active,payload): + original={'id':1,'vtiger_id':None,'billing_method':'invoice','status':'pending'} + monkeypatch.setattr(router,'execute_query_single',lambda *_:original) + monkeypatch.setattr(router,'execute_query',lambda *_:pytest.fail('No direct financial selection write permitted')) + monkeypatch.setattr(router,'_assert_prepaid_entry_editable',lambda _:None) + try: + result=asyncio.run(router.patch_time_entry_v1(1,payload,current_user={'id':4,'is_superadmin':True})) + except HTTPException as exc: + assert exc.status_code==409 + else: + assert result['billing_method']=='invoice' + +def test_legacy_wizard_cannot_change_billable_without_audited_override(monkeypatch,active): + monkeypatch.setattr(router,'execute_query_single',lambda *_:{'id':1,'vtiger_id':None,'billing_method':'invoice','status':'pending'}) + monkeypatch.setattr(router,'execute_query',lambda *_:pytest.fail('Active financial edit must not reach UPDATE')) + payload=router.TModuleWizardEditRequest(billable=False) + with pytest.raises(HTTPException) as error: + asyncio.run(router.update_entry_details(1,payload)) + assert error.value.status_code==409 + +def test_legacy_reset_cannot_reopen_active_workflow_time(monkeypatch,active): + monkeypatch.setattr(router,'execute_query_single',lambda *_:{'id':1,'vtiger_id':None,'billing_method':'invoice','status':'approved'}) + monkeypatch.setattr(router.wizard,'reset_to_pending',lambda *args,**kwargs:pytest.fail('Workflow time cannot use legacy reset')) + with pytest.raises(HTTPException) as error: + asyncio.run(router.reset_to_pending(1,reason='Old wizard',user_id=4)) + assert error.value.status_code==409 + +def test_legacy_bulk_approval_cannot_skip_workflow_decision(monkeypatch,active): + monkeypatch.setattr(router.wizard,'get_case_entries',lambda *args,**kwargs:[{'id':1,'vtiger_id':None}]) + monkeypatch.setattr(router.wizard,'approve_case_entries',lambda *args,**kwargs:pytest.fail('No active workflow bulk approval through legacy wizard')) + with pytest.raises(HTTPException) as error: + asyncio.run(router.approve_all_case_entries(9,user_id=4)) + assert error.value.status_code==409 + +@pytest.mark.parametrize('workflows_enabled,vtiger_id',[(False,None),(True,'legacy-vtiger')]) +def test_wizard_edit_preserves_nonworkflow_and_imported_behavior(monkeypatch,active,workflows_enabled,vtiger_id): + monkeypatch.setattr(settings,'WORKFLOWS_ENABLED',workflows_enabled) + monkeypatch.setattr(router,'execute_query_single',lambda *_:{'id':1,'vtiger_id':vtiger_id,'billing_method':'invoice','status':'pending'}) + writes=[] + monkeypatch.setattr(router,'execute_query',lambda sql,params:writes.append((sql,params))) + result=asyncio.run(router.update_entry_details(1,router.TModuleWizardEditRequest(billable=False))) + assert result['id']==1 + assert len(writes)==1 and 'billable = %s' in writes[0][0] + +def test_manual_override_records_actor_reason_and_previous_decision(monkeypatch): + from contextlib import contextmanager + captured={} + @contextmanager + def transaction(): yield object() + monkeypatch.setattr(decisions.service,'transaction',transaction) + monkeypatch.setattr(decisions,'_locked_time',lambda *args:({'workflow_revision':5},{'id':2})) + monkeypatch.setattr(decisions.service,'load_time_context',lambda *args:{'time_id':1,'customer_id':'24','workflow_revision':5}) + monkeypatch.setattr(decisions,'active_for_customer',lambda _:True) + monkeypatch.setattr(classification,'enrich_time_context',lambda cursor,context:context) + def save(cursor,time_id,revision,result,manual=False): + captured.update(result=result,manual=manual,time_id=time_id,revision=revision) + return result + monkeypatch.setattr(decisions,'_save',save) + result=decisions.override(1,decisions.OverrideRequest(method='invoice',reason=' Aftale med kunden '),user={'id':4}) + assert captured['manual'] is True and captured['revision']==5 + assert result['actor_id']==4 and result['previous_decision_id']==2 + assert result['reason']=='manual_override' and result['override_reason']=='Aftale med kunden' + assert result['method']=='invoice'