Make V5 quick time capture automatic and keep billing choices in time tab
This commit is contained in:
parent
16fca2c8f1
commit
4a06ed8e00
@ -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'))))
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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};
|
||||
})();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
81
tests/test_case_v5_automatic_billing.py
Normal file
81
tests/test_case_v5_automatic_billing.py
Normal 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
|
||||
71
tests/test_case_v5_billing_scope.py
Normal file
71
tests/test_case_v5_billing_scope.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
82
tests/test_workflow_manual_edit_scope.py
Normal file
82
tests/test_workflow_manual_edit_scope.py
Normal 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'
|
||||
Loading…
Reference in New Issue
Block a user