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') 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 isinstance(minutes,bool) or not isinstance(minutes,int) or not (0 if kind=='estimate' else 1)<=minutes<=525600: fail('Ugyldigt minuttal')
if kind=='time': 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 from app.timetracking.backend.router import _case_billing_choice
c['billing']=_case_billing_choice(case['id'],choice) c['billing']=_case_billing_choice(case['id'],choice)
c['settlement_choice']=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: 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() 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') 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 rid=None
if execute: if execute:
try: rid=str(UUID(str(payload.get('request_id')))) try: rid=str(UUID(str(payload.get('request_id'))))

View File

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

View File

@ -35,8 +35,8 @@
'groupId': case.assigned_group_id, 'groupId': case.assigned_group_id,
'userId': case.ansvarlig_bruger_id 'userId': case.ansvarlig_bruger_id
}|tojson }}</script> }|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-commands.js?v=20261006-1"></script>
<script src="/static/js/case-command-suggestions.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 %} {% 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,)) t = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not t: if not t:
raise HTTPException(status_code=404, detail="Time entry not found") 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 = [] updates = []
params = [] params = []
@ -1070,6 +1076,8 @@ async def update_entry_details(
""" """
return execute_query_single(query, (time_id,)) return execute_query_single(query, (time_id,))
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to update entry {time_id}: {e}") logger.error(f"❌ Failed to update entry {time_id}: {e}")
raise HTTPException(status_code=500, detail=str(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) - user_id: ID på brugeren der nulstiller (optional)
""" """
try: 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) return wizard.reset_to_pending(time_id, reason=reason, user_id=user_id)
except HTTPException: except HTTPException:
raise raise
@ -1141,11 +1157,22 @@ async def approve_all_case_entries(
Afr under automatisk efter configured settings. Afr under automatisk efter configured settings.
""" """
try: 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( return wizard.approve_case_entries(
case_id=case_id, case_id=case_id,
user_id=user_id, user_id=user_id,
exclude_time_card=exclude_time_card exclude_time_card=exclude_time_card
) )
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -2422,6 +2449,7 @@ async def list_time_entries_v1(
day: Optional[date] = Query(None), day: Optional[date] = Query(None),
medarbejder_id: Optional[int] = Query(None, gt=0), medarbejder_id: Optional[int] = Query(None, gt=0),
current_user: Optional[dict] = Depends(get_optional_user), current_user: Optional[dict] = Depends(get_optional_user),
billing_display: bool = False,
): ):
"""List tidsregistreringer for en sag med filtre til timeline UI.""" """List tidsregistreringer for en sag med filtre til timeline UI."""
try: try:
@ -2462,7 +2490,8 @@ async def list_time_entries_v1(
WHERE {where_sql} WHERE {where_sql}
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC 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: except Exception as e:
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, 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") 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") raise HTTPException(status_code=400, detail="sag_id is required")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id")) 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")) bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
if not bruger_id: if not bruger_id:
@ -2940,6 +2969,43 @@ async def list_team_timer_status_v1(
return {"scope": scope, "rows": rows} 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]: def _case_billing_choice(sag_id: int, choice: str) -> Dict[str, Any]:
from app.core.config import settings from app.core.config import settings
if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active': 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") raise HTTPException(status_code=400, detail="sag_id is required")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id")) 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") requested_user_ids = payload.get("medarbejder_ids")
is_bulk_request = requested_user_ids is not None 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. 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. 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 ### 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. 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. 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 ### 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) | | | | T19 e-conomic (valgfri) | | |
| T20 Deaktivering | | | | T20 Deaktivering | | |
| T21 Kundekortaktivering | | | | 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). 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 ## 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. - 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. - 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. - 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); helpList.append(intro);
const groups=[ const groups=[
['Tid og planlægning','clock-history',[ ['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']]], ['forventet','Sæt forventet tidsforbrug'],['deadline','Angiv seneste dato'],['udsat','Udsæt sagen inden deadline']]],
['Ansvar og opgaver','person-check',[ ['Ansvar og opgaver','person-check',[
['tildel','Vælg medarbejder med begrundelse'],['mig','Tildel sagen til dig selv'], ['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; const saved = key ? JSON.parse(sessionStorage.getItem(key) || 'null') : null;
if (saved && !input.value && typeof saved.text === 'string') { if (saved && !input.value && typeof saved.text === 'string') {
input.value = saved.text; requestId = saved.requestId || null; frozenText = saved.frozenText || null; frozenBody=saved.frozenBody || null; 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);}); if(Array.isArray(saved.settlements)) saved.settlements.forEach(([id,value])=>{if(typeof id==='string' && typeof value==='string') settlements.set(id,value);});
} }
} catch (_) { storageAvailable = false; } } catch (_) { storageAvailable = false; }
@ -134,7 +135,7 @@
function message(text,error=false) { function message(text,error=false) {
status.textContent = text; status.classList.toggle('is-error',error); 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) { 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 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}`; const target=command.scope==='main' ? 'Hovedsag' : `Undersag ${Number(command.scope)+1}`;
@ -203,19 +204,8 @@
chip.title = 'Klik for at rette kommandolinjen'; chip.title = 'Klik for at rette kommandolinjen';
chip.addEventListener('click', () => editLine(command.line)); chips.append(chip); chip.addEventListener('click', () => editLine(command.line)); chips.append(chip);
if(command.type==='time') { if(command.type==='time') {
if(workflowActive){
delete command.settlement_choice; delete command.settlement_choice;
const note=document.createElement('span');note.className='small text-muted';note.textContent='Afregning vælges automatisk';chips.append(note); 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);
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);
} }
if (command.type === 'file') { if (command.type === 'file') {
const file = document.createElement('button'); file.type='button'; file.textContent='Vælg filer…'; const file = document.createElement('button'); file.type='button'; file.textContent='Vælg filer…';
@ -225,7 +215,7 @@
}); });
renderSuggestions(); store(); updateSubmitLabel(); renderSuggestions(); store(); updateSubmitLabel();
if(parsed.commands.some(command=>command.type==='help')) help.open=true; 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); 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 if (parsed.commands.length) { message('Kontrollerer handlinger…'); const version=generation; timer=setTimeout(()=>preview(version),350); }
else message(''); else message('');

View File

@ -15,36 +15,34 @@
compactBillingHost.querySelector('.form-text').removeAttribute('id'); compactBillingHost.querySelector('.form-text').removeAttribute('id');
byId('caseQuickTimeDraftForm').querySelector('.row').insertBefore(compactBillingHost,byId('caseQuickTimeSaveButton').parentElement); byId('caseQuickTimeDraftForm').querySelector('.row').insertBefore(compactBillingHost,byId('caseQuickTimeSaveButton').parentElement);
byId('caseQuickTimeSaveButton').innerHTML='<i class="bi bi-plus-lg me-1"></i>Gem tid'; 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() { async function loadBillingOptions() {
const quick=byId('v5QuickBilling'), modal=byId('time_billing_method'), compact=byId('v5CompactBilling');
try { try {
const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-options`,{credentials:'include'}); const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-options`,{credentials:'include'});
const data=await response.json(); const data=await response.json();
if(!response.ok)throw new Error(data.detail || 'Afregningsmuligheder kunne ikke hentes'); if(!response.ok)throw new Error(data.detail || 'Afregningsmuligheder kunne ikke hentes');
if(data.workflow_active === true) { setAutomaticRegistrationControls(data.workflow_active===true);
for(const select of [quick,modal,compact].filter(Boolean)) { const anchor=byId('timeV1Date');
select.replaceChildren(new Option('Afregning vælges automatisk','draft')); if(anchor&&!byId('v5TabBilling')) {
select.value='draft'; select.disabled=true; select.hidden=true; const host=document.createElement('div');host.className='col-12';
select.dataset.billingReady='true'; select.dataset.workflowActive='true'; const label=document.createElement('label');label.htmlFor='v5TabBilling';label.className='form-label small';label.textContent='Afregning (valgfrit)';
const label=select.parentElement.querySelector(`label[for="${select.id}"]`); const select=document.createElement('select');select.id='v5TabBilling';select.className='form-select form-select-sm';
if(label)label.hidden=true; 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);
} }
for(const host of [billingHost,compactBillingHost])host.querySelector('.form-text').textContent='Afregning vælges automatisk'; } catch(error){byId('v5BillingHint').textContent='Tid kan stadig registreres som kladde. Afregningsmuligheder kunne ikke hentes.';}
if(modal){const hint=document.createElement('div');hint.className='form-text';hint.textContent='Afregning vælges automatisk';modal.after(hint);}
return;
}
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';} }
} }
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadBillingOptions,{once:true});else loadBillingOptions(); if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadBillingOptions,{once:true});else loadBillingOptions();
const deferredInput = byId('topbarDeferredInput'); const deferredInput = byId('topbarDeferredInput');

View File

@ -2,6 +2,47 @@ window.TimeBillingReview = (() => {
const labels={draft:'Kladde',operations:'Drift',development:'Udvikling'}; const labels={draft:'Kladde',operations:'Drift',development:'Udvikling'};
const needsReview=entry=>Object.hasOwn(labels,entry.billing_method || entry._billing_method || ''); 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 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) { async function approve(entry,hours) {
const response=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/settlement-options`,{credentials:'include'}); 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'); if(!response.ok)throw new Error('Afregningsmuligheder kunne ikke hentes');
@ -30,5 +71,5 @@ window.TimeBillingReview = (() => {
};modal.show(); };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/); await assert.rejects(f.controller.submit(),/Timer allerede/);
assert.equal(f.input.readOnly,false);assert.equal(f.input.value,'/timer start "Support"'); 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; let release;
const f=await fixture((url,body)=>url.endsWith('preview') ? new Promise(resolve=>{release=()=>resolve(response(200,{commands:body.commands}));}) : response(200,{results:[]})); 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"'); f.type('/tid 30m remote "Support"\n/tid 30m remote "Support"');
const choices=f.nodes.filter(node=>node.tag==='select').slice(-2); assert.equal(f.nodes.filter(node=>node.tag==='select').length,0);
choices[0].value='operations';choices[0].listeners.change();
const actual=f.controller.getParsed().commands; 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); const promise=f.controller.submit();assert.equal(f.input.readOnly,true);
release();await promise; release();await promise;
const payload=f.calls.find(call=>call.url.endsWith('execute')).body; 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.skipif(not shutil.which('node'), reason='Node required for UI behavior check')
@pytest.mark.parametrize('active', [True, False]) @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() 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''' script = r'''
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const controls={}; const controls={};
@ -21,12 +21,12 @@ const document={createElement:()=>({})};
function Option(label,value){this.label=label;this.value=value;} function Option(label,value){this.label=label;this.value=value;}
const active=ACTIVE; const active=ACTIVE;
const fetch=async()=>({ok:true,json:async()=>({workflow_active:active,has_customer:true,hourly_rate:100,prepaid_cards:[],agreements:[]})}); 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(()=>{ loadBillingOptions().then(()=>{
for(const id of ['v5QuickBilling','time_billing_method','v5CompactBilling']){ for(const id of ['v5QuickBilling','time_billing_method','v5CompactBilling']){
const select=controls[id];assert.equal(select.dataset.billingReady,'true');assert.equal(select.value,'draft'); 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); assert.equal(select.disabled,true);assert.equal(!!select.hidden,true);
if(active)assert.equal(select.options.length,1);else assert.equal(select.options.length,4); assert.equal(select.options.length,1);
} }
}).catch(err=>{console.error(err);process.exitCode=1}); }).catch(err=>{console.error(err);process.exitCode=1});
'''.replace('ACTIVE', 'true' if active else 'false').replace('BODY', function) '''.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'