From ba463ced4e0309def1efe518dde8777a1b370977 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 9 Oct 2026 01:21:12 +0200 Subject: [PATCH] Add reviewed approval and billing lifecycle to time queue --- app/economy/backend/router.py | 251 +++++++++++++++++++++++++- app/economy/frontend/time_queue.html | 43 +++-- app/workflows/settlement.py | 4 +- docs/tidsko-manuel-testplan.md | 11 ++ tests/test_economy_queue_lifecycle.py | 148 +++++++++++++++ tests/test_smart_time_queue.js | 34 +++- tests/test_workflow_db.py | 74 ++++++++ tests/test_workflow_employee_ui.py | 6 +- 8 files changed, 537 insertions(+), 34 deletions(-) create mode 100644 tests/test_economy_queue_lifecycle.py diff --git a/app/economy/backend/router.py b/app/economy/backend/router.py index b4eb33b..dcdfe86 100644 --- a/app/economy/backend/router.py +++ b/app/economy/backend/router.py @@ -24,6 +24,8 @@ class BulkIdsRequest(BaseModel): class BulkUpdateRequest(BaseModel): ids: List[int] = Field(..., min_length=1) description: Optional[str] = None + prepaid_card_id: Optional[int] = Field(None, gt=0) + fixed_price_agreement_id: Optional[int] = Field(None, gt=0) original_hours: Optional[float] = Field(None, gt=0) billable: Optional[bool] = None billing_method: Optional[str] = None @@ -116,6 +118,8 @@ async def list_hub_time_queue( billable: Optional[bool] = Query(None), q: Optional[str] = Query(None), limit: int = Query(500, ge=1, le=2000), + scope: Optional[str] = Query(None, pattern="^(active|history|all)$"), + request: Request = None, ): """List non-billed Hub-created time entries for the economy queue.""" try: @@ -125,6 +129,12 @@ async def list_hub_time_queue( "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", ] + if scope in {'active', 'history', 'all'}: + conditions = ["t.vtiger_id IS NULL", "t.status <> 'rejected'"] + if scope == 'active': + conditions += ["t.billed_via_thehub_id IS NULL", "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", "t.economy_settled_at IS NULL"] + elif scope == 'history': + conditions.append("(t.status IN ('approved','billed') OR t.economy_order_draft_id IS NOT NULL OR t.billed_via_thehub_id IS NOT NULL OR t.economy_settled_at IS NOT NULL)") params: List[Any] = [] # A time entry may retain an old customer_id after a case has been @@ -195,8 +205,13 @@ async def list_hub_time_queue( COALESCE(NULLIF(u.full_name, ''), NULLIF(u.username, ''), NULLIF(t.user_name, ''), 'Ukendt medarbejder') AS employee_name, CONCAT_WS(' ', NULLIF(cont.first_name, ''), NULLIF(cont.last_name, '')) AS contact_name, t.created_at, - t.updated_at + t.updated_at, + t.workflow_revision, + t.economy_order_draft_id, t.economy_settled_at, t.billed_via_thehub_id, + draft.sync_status AS order_sync_status, + draft.economic_invoice_number FROM tmodule_times t + LEFT JOIN ordre_drafts draft ON draft.id = t.economy_order_draft_id LEFT JOIN tmodule_cases c ON c.id = t.case_id LEFT JOIN sag_sager s ON s.id = t.sag_id LEFT JOIN customers case_customer ON case_customer.id = s.customer_id @@ -228,6 +243,21 @@ async def list_hub_time_queue( _attach_case_tags(rows) if settings.WORKFLOWS_ENABLED: _attach_workflow_decisions(rows) + if scope in {"active", "history", "all"}: + from app.core.auth_dependencies import get_current_user + actor = await _queue_actor(request) if request is not None else {} + permissions = actor.get('permissions') or [] + can_override = actor.get('is_superadmin') or all(p in permissions for p in ('workflows.override','workflows.decisions.view')) + for row in rows: + row.update(_queue_lifecycle(row)) + row['entry_fingerprint'] = _queue_review_fingerprint(row) + row['workflow_can_override'] = bool(can_override and row['can_change_billing']) + can_approve = bool(actor.get('is_superadmin') or 'billing.approve' in permissions) + if row.get('workflow_active'): + can_approve = can_approve and bool(actor.get('is_superadmin') or 'workflows.settle' in permissions) + row['can_approve'] = bool(row['can_approve'] and can_approve) + row['can_settle'] = bool(row['can_settle'] and can_approve) + row['can_change_billing'] = bool(row['can_change_billing'] and (can_override if row.get('workflow_active') else actor.get('is_superadmin') or 'billing.edit' in permissions)) return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED} except HTTPException: raise @@ -300,11 +330,32 @@ async def list_prepaid_cards(customer_id: Optional[int] = Query(None, gt=0)): @router.patch("/time-queue/bulk-update") -async def bulk_update_time_queue(payload: BulkUpdateRequest): +async def bulk_update_time_queue(payload: BulkUpdateRequest, request: Request = None): + if {'prepaid_card_id', 'fixed_price_agreement_id'}.intersection(payload.model_fields_set): + from app.core.database import atomic_transaction + from app.core.auth_dependencies import require_permission + actor = await _queue_actor(request) + await require_permission('billing.edit')(actor) + with atomic_transaction(): + execute_query('SELECT id FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (_ensure_ids(payload.ids),)) + return await _bulk_update_time_queue(payload) + return await _bulk_update_time_queue(payload) + + +async def _bulk_update_time_queue(payload: BulkUpdateRequest): ids = _ensure_ids(payload.ids) - if payload.billing_method is not None or payload.billable is not None: + if payload.billing_method is not None or payload.billable is not None or {"prepaid_card_id","fixed_price_agreement_id"}.intersection(payload.model_fields_set): _protect_workflow_selection(ids) + # Financial choices are frozen once the employee's registration is reviewed. + financial_edit = bool({'prepaid_card_id','fixed_price_agreement_id'}.intersection(payload.model_fields_set)) + if financial_edit: + current = execute_query('SELECT id,status,economy_order_draft_id,economy_settled_at,billed_via_thehub_id FROM tmodule_times WHERE id=ANY(%s)', (ids,)) or [] + if len(current) != len(ids) or any(row.get('status') in {'approved','billed','rejected'} or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('billed_via_thehub_id') for row in current): + raise HTTPException(409, 'Afregning kan kun ændres før godkendelse') + if payload.billing_method is not None and _normalise_billing_method(payload.billing_method) not in VALID_SETTLEMENT_METHODS | {'draft','operations','development'}: + raise HTTPException(422, 'Ugyldig afregningsmetode') + updates: List[str] = [] values: List[Any] = [] @@ -326,6 +377,13 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest): updates.append("billing_method = %s") values.append(payload.billing_method) + if "prepaid_card_id" in payload.model_fields_set: + updates.append("prepaid_card_id = %s") + values.append(payload.prepaid_card_id) + if "fixed_price_agreement_id" in payload.model_fields_set: + updates.append("fixed_price_agreement_id = %s") + values.append(payload.fixed_price_agreement_id) + if not updates: raise HTTPException(status_code=400, detail="No update fields provided") @@ -339,9 +397,18 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest): AND billed_via_thehub_id IS NULL AND economy_order_draft_id IS NULL AND status <> 'billed' + {"AND status NOT IN ('approved','rejected') AND economy_settled_at IS NULL" if financial_edit else ""} + RETURNING id """ - execute_update(query, tuple(values + ids)) + if financial_edit: + changed = execute_query(query, tuple(values + ids)) or [] + if len(changed) != len(ids): + raise HTTPException(409, "En tid blev ændret under behandlingen. Genindlæs køen.") + else: + execute_update(query, tuple(values + ids)) return {"success": True, "updated": len(ids)} + except HTTPException: + raise except Exception as e: logger.error("Failed bulk update: %s", e) raise HTTPException(status_code=500, detail="Failed bulk update") @@ -1268,9 +1335,183 @@ def _attach_workflow_decisions(rows): customer_activation[customer_id] = active_for_customer(customer_id) row['workflow_active']=customer_activation[customer_id] row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision']) - if row['workflow_active'] and decision and decision.get('method'): + if row['workflow_active'] and decision and decision.get('method') and not (row.get('economy_settled_at') or row.get('economy_order_draft_id') or row.get('billed_via_thehub_id')): row['billing_method']=decision['method'] row['billable']=decision['method'] in ('invoice','prepaid') config=(decision.get('details') or {}).get('billing') or (decision.get('details') or {}).get('decision') or {} row['prepaid_card_id']=config.get('card_id') row['fixed_price_agreement_id']=config.get('agreement_id') + + +class ReviewedTimeSnapshot(BaseModel): + id: int = Field(..., gt=0) + updated_at: str + entry_fingerprint: str + billing_method: str + prepaid_card_id: Optional[int] = None + fixed_price_agreement_id: Optional[int] = None + workflow_revision: Optional[int] = None + workflow_decision_id: Optional[int] = None + + +class ReviewedTimeRequest(BaseModel): + model_config = {'extra': 'forbid'} + ids: List[int] = Field(..., min_length=1) + snapshots: List[ReviewedTimeSnapshot] = Field(..., min_length=1) + line_mode: str = Field(default='separate', pattern='^(separate|combined)$') + + +def _queue_lifecycle(row): + method = _normalise_billing_method(row.get('billing_method')) + sent = bool(row.get('economy_order_draft_id') or row.get('billed_via_thehub_id')) + invoice = bool(row.get('economic_invoice_number')) or row.get('order_sync_status') in {'posted', 'paid'} + settled = bool(row.get('economy_settled_at') or row.get('status') == 'billed') + if method != 'invoice' and settled: + stage, label = 'settled', 'Afregnet' + elif invoice: + stage, label = 'invoiced', 'Faktureret' + elif sent: + stage, label = 'sent', 'Sendt til fakturering' + elif row.get('status') == 'approved': + stage, label = 'approved', 'Godkendt' + elif row.get('status') == 'billed': + # Legacy "billed" alone does not establish a verified invoice. + stage, label = 'settled', 'Afregnet' + else: + stage, label = 'pending', 'Afventer godkendelse' + return dict(lifecycle_status=stage, lifecycle_label=label, + can_approve=stage == 'pending' and row.get('status') != 'rejected', + can_change_billing=stage == 'pending' and row.get('status') != 'rejected', + can_settle=stage in {'pending', 'approved'} and row.get('status') != 'rejected', + order_url=f"/ordre/{int(row['economy_order_draft_id'])}" if row.get('economy_order_draft_id') else None) + + +def _validate_reviewed_rows(payload, raw): + ids = _ensure_ids(payload.ids) + snapshots = {item.id: item for item in payload.snapshots} + if len(snapshots) != len(payload.snapshots) or set(snapshots) != set(ids): + raise HTTPException(422, 'Der skal være præcis én gennemgået registrering for hver valgt tid') + if {int(row['id']) for row in raw} != set(ids): + raise HTTPException(404, 'En tidsregistrering findes ikke længere') + if any(row.get('vtiger_id') or row.get('billed_via_thehub_id') or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('status') in {'billed', 'rejected'} for row in raw): + raise HTTPException(409, 'En valgt tid er allerede behandlet eller afvist. Genindlæs køen.') + selected = _selected_time_entries(ids) + if settings.WORKFLOWS_ENABLED: + _attach_workflow_decisions(selected) + by_id = {int(row['id']): row for row in selected} + for row in raw: + snapshot = snapshots[int(row['id'])] + current = by_id.get(int(row['id'])) + if not current: + raise HTTPException(409, 'Tiden findes ikke længere i køen') + if snapshot.entry_fingerprint != _queue_review_fingerprint(current): + raise HTTPException(409, 'Tid, kunde eller beskrivelse er ændret. Genindlæs køen.') + # Compare the database timestamp, not a rounded display value. + timestamp = row.get('updated_at') + from datetime import datetime + try: + reviewed_stamp = datetime.fromisoformat(snapshot.updated_at.replace('Z', '+00:00')) + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + except (ValueError, TypeError): + raise HTTPException(409, 'Genindlæs tiden før godkendelse') + if timestamp != reviewed_stamp: + raise HTTPException(409, 'Tiden er ændret siden gennemgangen. Genindlæs køen.') + for key in ('billing_method', 'prepaid_card_id', 'fixed_price_agreement_id'): + if getattr(snapshot, key) != current.get(key): + raise HTTPException(409, 'Afregningsvalget er ændret. Genindlæs køen.') + if current.get('workflow_active') and snapshot.workflow_revision != int(row.get('workflow_revision') or 0): + raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.') + if current.get('workflow_active') and snapshot.workflow_decision_id != (current.get('workflow_decision') or {}).get('id'): + raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.') + if current.get('workflow_active') and current.get('workflow_stale'): + raise HTTPException(409, 'Workflowbeslutningen er forældet') + for current in selected: + if current.get('workflow_active'): + from app.workflows.settlement import current_decision + from app.workflows.service import load_time_context + from app.workflows.classification import enrich_time_context + from app.core.database import _transaction_connection + from psycopg2.extras import RealDictCursor + current_decision(current['id'], by_id[current['id']].get('workflow_decision', {}).get('entry_revision')) + with _transaction_connection.get().cursor(cursor_factory=RealDictCursor) as cursor: + context = enrich_time_context(cursor, load_time_context(current['id'], cursor)) + if context.get('pending_billing_tag_suggestions'): + raise HTTPException(409, 'Afvent godkendelse eller afvisning af relevante tags') + preview = _build_settlement_preview(SettlementRequest(ids=ids, line_mode=payload.line_mode)) + if not preview['valid']: + raise HTTPException(409, {'message': 'Ret fejlene før godkendelse', 'preview': preview}) + return selected + + +@router.post('/time-queue/approve-reviewed') +async def approve_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request): + from app.core.auth_dependencies import get_current_user, require_permission + from app.core.database import atomic_transaction + actor = await _queue_actor(request) + await require_permission('billing.approve')(actor) + ids = _ensure_ids(payload.ids) + with atomic_transaction(): + raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or [] + rows = _validate_reviewed_rows(payload, raw) + if any(row.get('workflow_active') for row in rows): + await require_permission('workflows.settle')(actor) + changed = [int(row['id']) for row in raw if row.get('status') != 'approved'] + if changed: + execute_update("""UPDATE tmodule_times SET status='approved',entry_status='godkendt', + approved_hours=COALESCE(approved_hours,original_hours), approved_at=now(),approved_by=%s, + updated_at=now(),aktiv_timer=false WHERE id=ANY(%s)""", (actor['id'], changed)) + return {'success': True, 'approved': len(changed), 'already_approved': len(ids)-len(changed), + 'message': 'Tiderne er godkendt. Der er ikke trukket timer eller oprettet ordrekladder.'} + + +@router.post('/time-queue/send-reviewed') +async def send_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request): + """Revalidate the reviewed selection under locks before any local financial effects.""" + from app.core.auth_dependencies import get_current_user, require_permission + from app.core.database import atomic_transaction + from app.workflows.integration import active_for_customer + from app.workflows.settlement import workflow_customer, settle_workflow_entries + actor = await _queue_actor(request) + await require_permission('billing.approve')(actor) + ids = _ensure_ids(payload.ids) + scoped = [active_for_customer(workflow_customer(tid)) for tid in ids] if settings.WORKFLOWS_ENABLED else [False]*len(ids) + if any(scoped): + if not all(scoped): + raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig') + await require_permission('workflows.settle')(actor) + return settle_workflow_entries(ids, actor['id'], payload.line_mode, + review_validator=lambda raw: _validate_reviewed_rows(payload, raw)) + with atomic_transaction(): + raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or [] + rows = _validate_reviewed_rows(payload, raw) + if any(row.get('workflow_active') for row in rows): + raise HTTPException(409, 'Workflowaktivering er ændret. Genindlæs køen.') + return await settle_time_queue(SettlementRequest(ids=ids, line_mode=payload.line_mode), request) + + +async def _queue_actor(request): + from app.core.auth_dependencies import get_current_user + from fastapi.security import HTTPAuthorizationCredentials + header = request.headers.get('authorization', '') + credentials = None + if header.lower().startswith('bearer '): + credentials = HTTPAuthorizationCredentials(scheme='Bearer', credentials=header[7:].strip()) + return await get_current_user(request, credentials) + + +def _queue_review_fingerprint(row): + import hashlib + fields = ('billing_method','prepaid_card_id','fixed_price_agreement_id','original_hours', + 'approved_hours','faktisk_tid_min','fakturerbar_tid_min','round_block_min', + 'worked_date','description','billing_customer_id','sag_id','case_id','billable') + values = {} + for field in fields: + value = row.get(field) + if field in {'original_hours','approved_hours'} and value is not None: + value = str(Decimal(str(value)).normalize()) + elif field == 'worked_date' and value is not None: + value = str(value) + values[field] = value + encoded = json.dumps(values, sort_keys=True, default=str, ensure_ascii=False, separators=(',', ':')) + return hashlib.sha256(encoded.encode('utf-8')).hexdigest() diff --git a/app/economy/frontend/time_queue.html b/app/economy/frontend/time_queue.html index 847cf2b..778ebfd 100644 --- a/app/economy/frontend/time_queue.html +++ b/app/economy/frontend/time_queue.html @@ -27,35 +27,40 @@ @media(max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.5rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 32px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}.case-detail-meta{grid-template-columns:1fr}.triage-tabs{display:flex;flex-wrap:wrap;gap:.35rem}.triage-tabs>.btn{border-radius:.4rem!important}}
-
Økonomi

Tidskø til afregning

Gennemgå forslag og afregn efter forhåndsvisning.

+
Økonomi

Tidskø til afregning

Kontrollér afregningen, godkend tiden og send den videre.

+
Registreringer
–
Timer
–
Valgt
0
Ingen afregning før din godkendelse.
Flere filtre
-

Valgte registreringer

Behold forslagene, eller justér afregningen.
0 valgt
Justér afregning manuelt
Vælg tidsregistreringer for at se kundens klippekort.
-

Workflowvalg ændres under Hvorfor?

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

Valgte registreringer

Behold forslagene, eller justér afregningen.
0 valgt
Justér afregning manuelt
Vælg tidsregistreringer for at se kundens klippekort.
+

Afregning ændres direkte på den enkelte registrering.

Forhåndsvisning af afregning
Dette er en kontrol uden ændringer. Kontrollér resultatet, før du godkender og sender eller afregner.
Foreløbig vurdering. Forhåndsvisning kontrollerer endeligt.
-
0 tidsregistreringer valgtVælg forhåndsvisning før afregning.
+
0 tidsregistreringer valgtGodkend uden afregning, eller forhåndsvis næste trin.
+ {% endblock %} diff --git a/app/workflows/settlement.py b/app/workflows/settlement.py index adc3f4b..827acff 100644 --- a/app/workflows/settlement.py +++ b/app/workflows/settlement.py @@ -26,7 +26,7 @@ def current_decision(time_id, revision=None): return row -def settle_workflow_entries(ids, actor_id, line_mode='separate'): +def settle_workflow_entries(ids, actor_id, line_mode='separate', review_validator=None): """Lock and settle a complete selection once, rolling everything back on failure.""" from app.economy.backend.router import _selected_time_entries, _hours_for_prepaid_card, _create_ordre_draft_from_selected ids = sorted(set(int(value) for value in ids)) @@ -36,6 +36,8 @@ def settle_workflow_entries(ids, actor_id, line_mode='separate'): raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) if len(raw) != len(ids): raise HTTPException(404, 'En tidsregistrering findes ikke') + if review_validator is not None: + review_validator(raw) old = execute_query('SELECT * FROM bmc_workflow_settlements WHERE time_id=ANY(%s)', (ids,)) old_ids = {row['time_id'] for row in old} remaining = [entry for entry in raw if entry['id'] not in old_ids] diff --git a/docs/tidsko-manuel-testplan.md b/docs/tidsko-manuel-testplan.md index 68e4fc9..e041198 100644 --- a/docs/tidsko-manuel-testplan.md +++ b/docs/tidsko-manuel-testplan.md @@ -11,3 +11,14 @@ Test lokalt på `/economy/time-queue` med en workflowkunde og en kunde uden work 7. Brug kun særskilte testregistreringer ved faktisk afregning. Kontrollér ordrekladde/kortsaldo før og efter; en forhåndsvisning alene må ikke ændre dem. Klar til gennemgang er en indledende sortering. Forhåndsvisningen kontrollerer fortsat aftaler, saldo, beløb og aktuelle beslutninger inden gennemførsel. + +## Godkendelse og historik + +1. Vælg en ny testtid, og ændr afregning direkte på rækken. Aktiv workflowtid skal kræve overstyringsrettighed og begrundelse. En godkendt eller behandlet tid må ikke kunne ændres gennem rækken. +2. Tryk Godkend. Kontrollér status Godkendt og godkendelsesdato. Kortsaldo, aftaleforbrug og ordrekladder skal være uændrede. +3. Vælg fakturatid og forhåndsvis afregningen. Kontrollér metode, kunde og beløb før gennemførsel. +4. Gennemfør kun på særskilte testregistreringer: status skal være Sendt til fakturering og linke til den lokale ordrekladde. En lokal kladde må aldrig alene give Faktureret. +5. Åbn Historik. Kontrollér Godkendt, Sendt til fakturering, Faktureret og Afregnet ud fra faktisk behandling. Fastpris/kort/intern behandling hedder Afregnet. +6. Ret eller genberegn en tid fra en anden fane efter forhåndsvisningen: gennemførsel skal afvises og kræve genindlæsning. +7. Gentag godkendelse/send på samme udvalg: godkendelse må ikke oprette betaling, og allerede sendte tider må ikke skabe nye kladder eller korttræk. +8. En bruger uden billing.approve skal ikke kunne godkende eller sende. Aktiv workflowafregning kræver også workflows.settle. diff --git a/tests/test_economy_queue_lifecycle.py b/tests/test_economy_queue_lifecycle.py new file mode 100644 index 0000000..e0cdcb2 --- /dev/null +++ b/tests/test_economy_queue_lifecycle.py @@ -0,0 +1,148 @@ +import asyncio +from contextlib import contextmanager +from datetime import datetime +from unittest.mock import Mock +import pytest +from fastapi import HTTPException +from pydantic import ValidationError +from app.economy.backend import router as queue + + +@pytest.mark.parametrize('fields,stage,label', [ + ({}, 'pending', 'Afventer godkendelse'), + ({'status':'approved'}, 'approved','Godkendt'), + ({'status':'approved','economy_order_draft_id':4,'order_sync_status':'exported'},'sent','Sendt til fakturering'), + ({'economy_order_draft_id':4,'economic_invoice_number':'1001'},'invoiced','Faktureret'), + ({'billing_method':'prepaid','status':'billed','economy_settled_at':'today'},'settled','Afregnet'), + ({'status':'billed'},'settled','Afregnet'), +]) +def test_lifecycle_requires_actual_invoice(fields,stage,label): + result = queue._queue_lifecycle(dict(billing_method='invoice',**fields)) if 'billing_method' not in fields else queue._queue_lifecycle(fields) + assert (result['lifecycle_status'],result['lifecycle_label']) == (stage,label) + if stage in {'sent','invoiced','settled'}: + assert not result['can_settle'] and not result['can_change_billing'] + + +def setup_review(monkeypatch): + stamp=datetime(2026,10,9,12,0) + raw={'id':1,'status':'pending','updated_at':stamp,'billing_method':'internal','original_hours':1} + monkeypatch.setattr(queue.settings,'WORKFLOWS_ENABLED',False) + monkeypatch.setattr(queue,'_selected_time_entries',lambda ids:[dict(raw)]) + monkeypatch.setattr(queue,'_build_settlement_preview',lambda payload:{'valid':True}) + payload=queue.ReviewedTimeRequest(ids=[1],snapshots=[{'id':1,'updated_at':stamp.isoformat(),'entry_fingerprint':queue._queue_review_fingerprint(raw),'billing_method':'internal'}]) + return raw,payload + + +@pytest.mark.parametrize('change', [{'status':'rejected'},{'economy_order_draft_id':9},{'economy_settled_at':'done'},{'updated_at':datetime(2026,10,9,12,1)}]) +def test_reviewed_selection_rejects_changed_or_processed(monkeypatch,change): + raw,payload=setup_review(monkeypatch) + raw.update(change) + with pytest.raises(HTTPException) as error: + queue._validate_reviewed_rows(payload,[raw]) + assert error.value.status_code == 409 + + +def test_snapshot_must_cover_every_id(monkeypatch): + raw,payload=setup_review(monkeypatch) + payload.ids=[1,2] + with pytest.raises(HTTPException) as error: + queue._validate_reviewed_rows(payload,[raw]) + assert error.value.status_code == 422 + + +def test_review_cannot_silently_accept_unsaved_override(): + with pytest.raises(ValidationError): + queue.ReviewedTimeRequest(ids=[1],snapshots=[{'id':1,'updated_at':'today','entry_fingerprint':'test','billing_method':'internal'}],billing_method='invoice') + + +def test_pure_approval_no_financial_operation(monkeypatch): + raw,payload=setup_review(monkeypatch) + async def actor(request): return {'id':7,'username':'test','is_superadmin':True} + monkeypatch.setattr(queue,'_queue_actor',actor) + @contextmanager + def transaction(): yield None + import app.core.database as database + monkeypatch.setattr(database,'atomic_transaction',transaction) + monkeypatch.setattr(queue,'execute_query',lambda sql,params:[raw]) + update=Mock();monkeypatch.setattr(queue,'execute_update',update) + insert=Mock(side_effect=AssertionError('Approval must not create financial records')) + monkeypatch.setattr(queue,'execute_insert',insert) + result=asyncio.run(queue.approve_reviewed_time_queue(payload,None)) + assert result['approved']==1 + assert update.call_count==1 + sql=update.call_args.args[0] + assert "status='approved'" in sql and 'economy_settled_at' not in sql + insert.assert_not_called() + + +def test_approval_validation_failure_writes_nothing(monkeypatch): + raw,payload=setup_review(monkeypatch);raw['status']='rejected' + async def actor(request): return {'id':7,'username':'test','is_superadmin':True} + monkeypatch.setattr(queue,'_queue_actor',actor) + @contextmanager + def transaction(): yield None + import app.core.database as database + monkeypatch.setattr(database,'atomic_transaction',transaction) + monkeypatch.setattr(queue,'execute_query',lambda sql,params:[raw]) + update=Mock();monkeypatch.setattr(queue,'execute_update',update) + with pytest.raises(HTTPException):asyncio.run(queue.approve_reviewed_time_queue(payload,None)) + update.assert_not_called() + + +def test_same_revision_new_decision_is_rejected(monkeypatch): + raw,payload=setup_review(monkeypatch) + monkeypatch.setattr(queue.settings,'WORKFLOWS_ENABLED',True) + monkeypatch.setattr(queue,'_attach_workflow_decisions',lambda rows: rows[0].update(workflow_active=True,workflow_stale=False,workflow_decision={'id':11,'entry_revision':0})) + payload.snapshots[0].workflow_revision=0 + payload.snapshots[0].workflow_decision_id=10 + with pytest.raises(HTTPException) as error: + queue._validate_reviewed_rows(payload,[raw]) + assert error.value.status_code==409 + + +def test_approval_permission_denied_before_transaction(monkeypatch): + raw,payload=setup_review(monkeypatch) + async def actor(request):return {'id':7,'username':'limited','is_superadmin':False} + monkeypatch.setattr(queue,'_queue_actor',actor) + from app.core.auth_dependencies import AuthService + monkeypatch.setattr(AuthService,'user_has_permission',lambda *args:False) + write=Mock();monkeypatch.setattr(queue,'execute_update',write) + with pytest.raises(HTTPException) as error:asyncio.run(queue.approve_reviewed_time_queue(payload,None)) + assert error.value.status_code==403 + write.assert_not_called() + + +def test_reviewed_send_rolls_back_all_effects_on_failure(monkeypatch): + raw,payload=setup_review(monkeypatch) + async def actor(request):return {'id':7,'username':'test','is_superadmin':True} + monkeypatch.setattr(queue,'_queue_actor',actor) + effects=[] + @contextmanager + def transaction(): + try:yield None + except BaseException: + effects.clear() + raise + import app.core.database as database + monkeypatch.setattr(database,'atomic_transaction',transaction) + monkeypatch.setattr(queue,'execute_query',lambda sql,params:[raw]) + async def settle(payload,request): + effects.append('draft') + raise HTTPException(409,'A later selected entry failed') + monkeypatch.setattr(queue,'settle_time_queue',settle) + with pytest.raises(HTTPException):asyncio.run(queue.send_reviewed_time_queue(payload,None)) + assert effects==[] + + +@pytest.mark.parametrize('change',[{'original_hours':2},{'description':'Changed'},{'billing_customer_id':9},{'worked_date':'2026-10-10'}]) +def test_fingerprint_binds_financial_input_even_without_timestamp_change(monkeypatch,change): + raw,payload=setup_review(monkeypatch) + raw.update(change) + with pytest.raises(HTTPException) as error: + queue._validate_reviewed_rows(payload,[raw]) + assert error.value.status_code==409 + + +def test_fingerprint_decimal_representation_is_stable(): + from decimal import Decimal + assert queue._queue_review_fingerprint({'original_hours':Decimal('1.000')}) == queue._queue_review_fingerprint({'original_hours':1.0}) diff --git a/tests/test_smart_time_queue.js b/tests/test_smart_time_queue.js index ddc05c8..86da815 100644 --- a/tests/test_smart_time_queue.js +++ b/tests/test_smart_time_queue.js @@ -4,11 +4,11 @@ const vm=require('node:vm'); const fs=require('node:fs'); const html=fs.readFileSync('app/economy/frontend/time_queue.html','utf8'); function fixture(rows=[]){ - const controls={};for(const id of ['settlementMethod','prepaidCard','agreementId','workflowLineMode','workflowLineModeHost','setMethodBtn','previewBtn','stickyPreviewBtn','selectionText','previewPanel','settleBtn','filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'])controls[id]={value:'',disabled:false,classList:{add(){},toggle(){}}}; + const controls={};for(const id of ['approveBtn','stickyApproveBtn','settlementMethod','prepaidCard','agreementId','workflowLineMode','workflowLineModeHost','setMethodBtn','previewBtn','stickyPreviewBtn','selectionText','previewPanel','settleBtn','filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'])controls[id]={value:'',disabled:false,classList:{add(){},toggle(){}}}; controls.workflowLineMode.value='separate'; - const state={items:rows,selected:new Set(rows.map(x=>x.id)),triage:'all',preview:{valid:true},previewPayload:{ids:[1]},previewFingerprint:'old'}; - const ctx={state,$:id=>controls[id],norm:v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[v]||v||'draft',ids:()=>[...state.selected],selectedRows:()=>rows,Number,JSON}; - vm.createContext(ctx);vm.runInContext(html.slice(html.indexOf(' function effectiveMethod('),html.indexOf(' function status(')),ctx); + const state={scope:'active',items:rows,selected:new Set(rows.map(x=>x.id)),triage:'all',preview:{valid:true},previewPayload:{ids:[1]},previewFingerprint:'old'}; + const ctx={state,$:id=>controls[id],norm:v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[v]||v||'draft',ids:()=>[...state.selected],selectedRows:()=>rows,confirm:()=>true,Number,JSON}; + vm.createContext(ctx);vm.runInContext(html.slice(html.indexOf(' function actionable('),html.indexOf(' function status(')),ctx); return {ctx,controls,state}; } test('workflow stale, absent or manual review never inherits invoice fallback',()=>{ @@ -39,15 +39,15 @@ test('preview fingerprint tracks billing inputs, filters, selection and line gro ctx.invalidatePreview();assert.equal(state.preview,null);assert.equal(state.previewPayload,null);assert.equal(controls.settleBtn.disabled,true); }); test('settlement sends frozen preview request, never a mutated billing configuration',async()=>{ - const {ctx,controls,state}=fixture([{id:1}]);let sent=null;ctx.message=()=>{};ctx.api=async(url,options)=>{sent=JSON.parse(options.body);return {}};ctx.loadCustomers=async()=>{};ctx.load=async()=>{}; - state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint(); + const {ctx,controls,state}=fixture([{id:1}]);let sent=null,sentUrl=null;ctx.message=()=>{};ctx.api=async(url,options)=>{sentUrl=url;sent=JSON.parse(options.body);return {}};ctx.loadCustomers=async()=>{};ctx.load=async()=>{}; + state.preview={valid:true};state.previewPayload={ids:[1],line_mode:'separate',snapshots:[{id:1,updated_at:'reviewed'}]};state.previewFingerprint=ctx.fingerprint(); vm.runInContext(html.slice(html.indexOf(' async function settle(){'),html.indexOf(' function selectionChanged()')),ctx); - await ctx.settle();assert.equal(sent.billing_method,'invoice');assert.deepEqual(sent.ids,[1]); + await ctx.settle();assert.match(sentUrl,/send-reviewed$/);assert.equal(sent.snapshots[0].updated_at,'reviewed');assert.equal(sent.line_mode,'separate');assert.deepEqual(sent.ids,[1]); state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint();controls.agreementId.value='99';sent=null; await ctx.settle();assert.equal(sent,null);assert.equal(state.preview,null); }); test('triage and select-ready respect the visible billing filter',()=>{ - const rows=[{id:1,billing_method:'invoice',original_hours:1,customer_id:9},{id:2,billing_method:'draft',original_hours:1},{id:3,billing_method:'prepaid',original_hours:1,prepaid_card_id:8}]; + const rows=[{id:1,can_approve:true,billing_method:'invoice',original_hours:1,customer_id:9},{id:2,can_approve:true,billing_method:'draft',original_hours:1},{id:3,can_approve:true,billing_method:'prepaid',original_hours:1,prepaid_card_id:8}]; const {ctx,controls,state}=fixture(rows);vm.runInContext(html.slice(html.indexOf(' function methodFilteredItems('),html.indexOf(' function render(){')),ctx); state.triage='action';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[2]);state.triage='ready';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[1,3]); controls.filterMethod.value='prepaid';ctx.selectionChanged=()=>{};vm.runInContext(html.slice(html.indexOf("$('selectReadyBtn').onclick="),html.indexOf(";['workflowLineMode'",html.indexOf("$('selectReadyBtn').onclick="))),Object.assign(ctx,{ $:id=>id==='selectReadyBtn'?(controls.selectReadyBtn??={}):controls[id]}));controls.selectReadyBtn.onclick();assert.deepEqual([...state.selected],[3]); @@ -56,3 +56,21 @@ test('case tags escape names without inserting customer-controlled styles',()=>{ const {ctx}=fixture();ctx.esc=v=>String(v).replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); const out=ctx.caseTags({case_tags:[{name:'ABA