Make V5 quick time capture automatic and keep billing choices in time tab

This commit is contained in:
Christian 2026-10-08 21:36:42 +02:00
parent 16fca2c8f1
commit 4a06ed8e00
14 changed files with 428 additions and 70 deletions

View File

@ -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'))))

View File

@ -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 @@
});
</script>
<script src="/static/js/time-billing-review.js?v=20261006-1"></script>
<script src="/static/js/time-billing-review.js?v=20261008-2"></script>
<script>
const timeCaseId = {{ case.id }};
let timeV1EntriesById = {};
@ -14286,7 +14286,7 @@
<div class="border rounded p-2 mb-2" data-time-summary-entry-id="${entry.id}">
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div class="small fw-semibold">${escapeHtml(tech)}</div>
<span class="badge bg-light text-dark border">${statusLabel(entry.entry_status)}</span> ${TimeBillingReview.badge(entry)}
<span class="badge bg-light text-dark border">${statusLabel(entry.entry_status)}</span> ${document.getElementById('caseV5Data')?TimeBillingReview.displayBadge(entry):TimeBillingReview.badge(entry)} ${document.getElementById('caseV5Data')&&TimeBillingReview.canChoose(entry)?`<button type="button" class="btn btn-sm btn-outline-secondary" onclick="chooseTimeBillingV5(${Number(entry.id)})">Afregning</button>`:''}
</div>
<div class="small text-muted">${escapeHtml(dateValue)} • ${escapeHtml(timeLabel)} • ${minutesToLabel(actual)}</div>
<div class="small text-muted">Type: ${escapeHtml(typeLabel(entry.entry_type))}</div>
@ -14336,6 +14336,13 @@
}
}
async function chooseTimeBillingV5(entryId) {
if(!document.getElementById('caseV5Data'))return;
const entry=timeV1EntriesById[Number(entryId)];
if(!entry)return;
try{if(await TimeBillingReview.choose(entry))await loadTimeTrackingTab();}catch(error){showCaseFeedback(error.message,'danger');}
}
function toIsoLocalFromDateMinutes(dateStr, totalMinutes) {
const mins = Math.max(0, Math.floor(totalMinutes));
const hh = String(Math.floor(mins / 60)).padStart(2, '0');
@ -14623,7 +14630,7 @@
async function loadTimeTrackingTab() {
try {
const [res, ownTimerRes, linkedActivitiesRes] = await Promise.all([
fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}`, { credentials: 'include' }),
fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}${document.getElementById('caseV5Data')?'&billing_display=true':''}`, { credentials: 'include' }),
fetch('/api/v1/timetracking/time/my-switchable', { credentials: 'include' }).catch(() => null),
fetch(`/api/v1/sag/${timeCaseId}/linked-activities`, { credentials: 'include' }).catch(() => null),
]);
@ -14764,7 +14771,7 @@
entry_status: 'kladde',
beskrivelse: descriptionInput?.value?.trim() || null,
kilde: 'manuel',
...(document.getElementById('v5CompactBilling') ? {settlement_choice: document.getElementById('v5CompactBilling').value} : {})
...(document.getElementById('caseV5Data') ? {billing_mode: 'automatic'} : {})
})
});
if (!response.ok) {
@ -14775,7 +14782,7 @@
if (descriptionInput) descriptionInput.value = '';
setModuleContentState('time', true);
await loadTimeTrackingTab();
showCaseFeedback(document.getElementById('v5CompactBilling') ? 'Tid registreret med valgt afregning' : 'Tidskladde oprettet', 'success');
showCaseFeedback('Tid registreret som kladde', 'success');
} catch (error) {
showCaseFeedback(error.message || 'Kunne ikke gemme tidskladden');
} finally {
@ -14799,7 +14806,7 @@
entry_type: 'manuel',
work_type: typeInput?.value || 'support',
beskrivelse: descriptionInput?.value?.trim() || null,
...(document.getElementById('v5CompactBilling') ? {settlement_choice: document.getElementById('v5CompactBilling').value} : {})
...(document.getElementById('caseV5Data') ? {billing_mode: 'automatic'} : {})
})
});
if (!response.ok) {
@ -14822,6 +14829,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: timeCaseId,
...(document.getElementById('caseV5Data')?{billing_mode:'automatic'}:{}),
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
work_type: document.getElementById('timeV1LiveWorkType')?.value || 'support',
beskrivelse: document.getElementById('timeV1Description')?.value || null
@ -15002,6 +15010,7 @@
entry_status: document.getElementById('timeV1Status')?.value || 'afventer',
beskrivelse: document.getElementById('timeV1Description')?.value || null,
kilde: 'manuel',
...(document.getElementById('caseV5Data') ? {billing_mode:'manual',settlement_choice:document.getElementById('v5TabBilling')?.value||'draft'} : {}),
start_tid: startObj,
slut_tid: endObj
};
@ -16609,6 +16618,7 @@
: null;
const timePayload = {
sag_id: data.sag_id,
...(document.getElementById('caseV5Data')?{billing_mode:'automatic'}:{}),
solution_id: solution.id,
description: document.getElementById('sol_time_desc').value || data.title,
original_hours: timeTotal,
@ -16819,7 +16829,7 @@
if (document.getElementById('v5QuickBilling')) {
const billingSelect = document.getElementById('time_billing_method');
if (billingSelect.dataset.billingReady !== 'true') { showCaseFeedback('Afregningsmuligheder er ikke indlæst. Genindlæs siden.'); return; }
payload.settlement_choice = billingSelect.value;
payload.billing_mode = 'automatic';
}
const presetSelect = document.getElementById('time_multiplier_preset');
@ -22863,6 +22873,7 @@
description: formData.get('description'),
work_type: formData.get('work_type') || 'support',
billing_method: billingMethod,
...(document.getElementById('caseV5Data')?{billing_mode:'automatic'}:{}),
is_internal: isInternal
};
@ -23801,7 +23812,7 @@
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select></div>
<div class="mb-2"><label class="form-label small fw-semibold">Fakturering</label>
<div class="mb-2" ${document.getElementById('caseV5Data')?'hidden':''}><label class="form-label small fw-semibold">Fakturering</label>
<select id="rqt_billing" class="form-select form-select-sm">
<option value="invoice">Fakturerbar</option>
<option value="internal">Intern</option>
@ -23839,6 +23850,7 @@
description: description,
work_type: document.getElementById('rqt_work_type')?.value || 'support',
billing_method: billing,
...(document.getElementById('caseV5Data')?{billing_mode:'automatic'}:{}),
is_internal: billing === 'internal',
};
if ((!description || !description.trim()) && selectedPreset && selectedPreset.text) {

View File

@ -35,8 +35,8 @@
'groupId': case.assigned_group_id,
'userId': case.ansvarlig_bruger_id
}|tojson }}</script>
<script src="/static/js/case-v5.js?v=20261007-4"></script>
<script src="/static/js/case-v5.js?v=20261008-3"></script>
<script src="/static/js/case-commands.js?v=20261006-1"></script>
<script src="/static/js/case-command-suggestions.js?v=20261006-1"></script>
<script src="/static/js/case-command-composer.js?v=20261006-4"></script>
<script src="/static/js/case-command-composer.js?v=20261008-3"></script>
{% endblock %}

View File

@ -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

View File

@ -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/<id>/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.

View File

@ -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.

View File

@ -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('');

View File

@ -15,36 +15,34 @@
compactBillingHost.querySelector('.form-text').removeAttribute('id');
byId('caseQuickTimeDraftForm').querySelector('.row').insertBefore(compactBillingHost,byId('caseQuickTimeSaveButton').parentElement);
byId('caseQuickTimeSaveButton').innerHTML='<i class="bi bi-plus-lg me-1"></i>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');

View File

@ -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)?`<span class="badge text-bg-warning">${labels[entry.billing_method || entry._billing_method]} · Chefkontrol</span>`:'';
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 `<span class="badge text-bg-secondary">${text}${entry.workflow_stale?' · skal genberegnes':''}</span>`;
};
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='<div class="modal-dialog"><form class="modal-content"><div class="modal-header"><h5 class="modal-title">Vælg afregning</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><p class="small text-muted">Valget gemmes med begrundelse. Bogholderiet godkender og afregner tiden.</p><label class="form-label">Afregning</label><select class="form-select" required></select><label class="form-label mt-3">Begrundelse</label><textarea class="form-control" minlength="3" maxlength="2000" required></textarea><div class="small mt-2 text-danger" role="status"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary">Gem afregningsvalg</button></div></form></div>';
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};
})();

View File

@ -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);
});

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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'