Add reviewed approval and billing lifecycle to time queue

This commit is contained in:
Christian 2026-10-09 01:21:12 +02:00
parent 239d75ff06
commit ba463ced4e
8 changed files with 537 additions and 34 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@ -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('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
const out=ctx.caseTags({case_tags:[{name:'ABA <script>',color:'red;position:fixed'}]});assert.match(out,/ABA &lt;script&gt;/);assert.ok(!out.includes('position:fixed'));assert.match(out,/bi-tag/);
});
test('history is read-only even when approved entries can otherwise settle',()=>{
const {ctx,state}=fixture([{id:1,can_settle:true,can_approve:false}]);assert.equal(ctx.actionable(state.items[0]),true);state.scope='history';assert.equal(ctx.actionable(state.items[0]),false);
});
test('approval submits reviewed snapshots without settlement',async()=>{
const row={id:1,can_approve:true,updated_at:'2026-10-09',entry_fingerprint:'canonical-reviewed-row',billing_method:'draft',workflow_revision:4};const {ctx,state,controls}=fixture([row]);let calls=[];ctx.api=async(url,options)=>{calls.push({url,body:JSON.parse(options.body)});return {}};ctx.message=()=>{};ctx.load=async()=>{};
vm.runInContext(html.slice(html.indexOf(' async function approveSelected(){'),html.indexOf(' async function changeBilling(')),ctx);await ctx.approveSelected();assert.equal(calls.length,1);assert.match(calls[0].url,/approve-reviewed$/);assert.equal(calls[0].body.snapshots[0].workflow_revision,4);assert.equal(calls[0].body.snapshots[0].billing_method,'draft');assert.equal(calls[0].body.snapshots[0].entry_fingerprint,'canonical-reviewed-row');assert.equal(state.selected.size,0);
});
test('new send flow previews saved choices and ignores obsolete bulk inputs',()=>{
const {ctx,controls}=fixture([{id:1}]);controls.settlementMethod.value='invoice';controls.prepaidCard.value='9';controls.agreementId.value='3';assert.deepEqual(JSON.parse(JSON.stringify(ctx.payload())),{ids:[1],line_mode:'separate'});
});
test('permission booleans disable send and approved rows cannot change',()=>{
const {ctx,controls}=fixture([{id:1,can_approve:false,can_settle:false,can_change_billing:false,lifecycle_status:'approved'}]);ctx.syncWorkflowControls();assert.equal(controls.previewBtn.disabled,true);assert.equal(controls.stickyPreviewBtn.disabled,true);assert.equal(controls.approveBtn.disabled,true);assert.equal(controls.setMethodBtn.disabled,true);
});
test('review snapshots bind decision identity and terminal history shows saved method',()=>{
const {ctx}=fixture();assert.equal(ctx.approvalSnapshot({id:1,workflow_decision:{id:15,revision:2}}).workflow_decision_id,15);assert.equal(ctx.effectiveMethod({lifecycle_status:'settled',billing_method:'fixed_price',workflow_active:true,workflow_decision:{method:'invoice',status:'overridden'}}),'subscription');
});

View File

@ -266,3 +266,77 @@ def test_unknown_invoice_product_rolls_back_without_draft(db, entry):
assert c.fetchone()[0] == before
c.execute('SELECT status,economy_order_draft_id FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert c.fetchone() == ('pending', None)
def _reviewed_queue_request(db, entry, monkeypatch, method='internal'):
from app.core.config import settings
from app.economy.backend import router as economy
monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', False)
async def actor(_request):
return {'id': entry['actor'], 'username': 'reviewed-db-test', 'is_superadmin': True}
monkeypatch.setattr(economy, '_queue_actor', actor)
with db.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('UPDATE tmodule_times SET billing_method=%s,prepaid_card_id=%s,billable=%s WHERE id=%s',
(method, entry['card_id'] if method == 'prepaid' else None,
method == 'prepaid', entry['time_id']))
cursor.execute('SELECT id,updated_at,billing_method,prepaid_card_id,fixed_price_agreement_id,workflow_revision FROM tmodule_times WHERE id=%s', (entry['time_id'],))
snapshot = dict(cursor.fetchone())
snapshot['updated_at'] = snapshot['updated_at'].isoformat()
snapshot['entry_fingerprint'] = economy._queue_review_fingerprint(
economy._selected_time_entries([entry['time_id']])[0])
return economy, economy.ReviewedTimeRequest(ids=[entry['time_id']], snapshots=[snapshot])
def test_reviewed_queue_approval_has_no_financial_effects(db, entry, monkeypatch):
import asyncio
from starlette.requests import Request
economy, payload = _reviewed_queue_request(db, entry, monkeypatch)
with db.cursor() as cursor:
cursor.execute('SELECT count(*) FROM ordre_drafts')
drafts_before = cursor.fetchone()[0]
result = asyncio.run(economy.approve_reviewed_time_queue(payload, Request({'type': 'http', 'headers': []})))
assert result['approved'] == 1
with db.cursor() as cursor:
cursor.execute('SELECT status,approved_by,economy_settled_at,economy_order_draft_id FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert cursor.fetchone() == ('approved', entry['actor'], None, None)
cursor.execute('SELECT used_hours FROM tticket_prepaid_cards WHERE id=%s', (entry['card_id'],))
assert float(cursor.fetchone()[0]) == 0
cursor.execute('SELECT count(*) FROM ordre_drafts')
assert cursor.fetchone()[0] == drafts_before
cursor.execute('SELECT count(*) FROM bmc_workflow_settlements WHERE time_id=%s', (entry['time_id'],))
assert cursor.fetchone()[0] == 0
def test_reviewed_queue_stale_snapshot_does_not_approve(db, entry, monkeypatch):
import asyncio
from fastapi import HTTPException
from starlette.requests import Request
economy, payload = _reviewed_queue_request(db, entry, monkeypatch)
with db.cursor() as cursor:
cursor.execute("UPDATE tmodule_times SET description='Changed after review',updated_at=updated_at+interval '1 second' WHERE id=%s", (entry['time_id'],))
with pytest.raises(HTTPException) as error:
asyncio.run(economy.approve_reviewed_time_queue(payload, Request({'type': 'http', 'headers': []})))
assert error.value.status_code == 409
with db.cursor() as cursor:
cursor.execute('SELECT status,approved_at,economy_settled_at FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert cursor.fetchone() == ('pending', None, None)
def test_reviewed_queue_send_rolls_back_card_debit_on_failure(db, entry, monkeypatch):
import asyncio
from starlette.requests import Request
economy, payload = _reviewed_queue_request(db, entry, monkeypatch, method='prepaid')
def fail_after_debit(*_args, **_kwargs):
raise RuntimeError('Injected reviewed queue failure after card debit')
monkeypatch.setattr(economy, 'execute_insert', fail_after_debit)
request = Request({'type': 'http', 'headers': []})
request.state.user_id = entry['actor']
with pytest.raises(RuntimeError, match='after card debit'):
asyncio.run(economy.send_reviewed_time_queue(payload, request))
with db.cursor() as cursor:
cursor.execute('SELECT used_hours FROM tticket_prepaid_cards WHERE id=%s', (entry['card_id'],))
assert float(cursor.fetchone()[0]) == 0
cursor.execute('SELECT status,economy_settled_at,economy_order_draft_id FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert cursor.fetchone() == ('pending', None, None)
cursor.execute('SELECT count(*) FROM tticket_prepaid_transactions WHERE card_id=%s', (entry['card_id'],))
assert cursor.fetchone()[0] == 0

View File

@ -42,11 +42,11 @@ def test_queue_active_selection_uses_decisions_and_line_mode(active):
script = r'''
const assert=require('node:assert/strict');
const active=ACTIVE;
const controls={settlementMethod:{value:'prepaid'},prepaidCard:{value:'8'},agreementId:{value:''},setMethodBtn:{},workflowLineMode:{value:'combined'},workflowLineModeHost:{classList:{toggle(){}}},previewBtn:{},stickyPreviewBtn:{},selectionText:{}};
const $=id=>controls[id],ids=()=>[1],selectedRows=()=>[{id:1,workflow_active:active}];
const controls={settlementMethod:{value:'prepaid'},prepaidCard:{value:'8'},agreementId:{value:''},setMethodBtn:{},workflowLineMode:{value:'combined'},workflowLineModeHost:{classList:{toggle(){}}},previewBtn:{},stickyPreviewBtn:{},selectionText:{},approveBtn:{},stickyApproveBtn:{}};
const $=id=>controls[id],ids=()=>[1],selectedRows=()=>[{id:1,workflow_active:active,can_approve:true,can_settle:true}];
function selectionMode(rows) BODY
const data=payload();
assert.deepEqual(data,active?{ids:[1],line_mode:'combined'}:{ids:[1],billing_method:'prepaid',prepaid_card_id:8});
assert.deepEqual(data,{ids:[1],line_mode:'combined'});
syncWorkflowControls();assert.equal(controls.settlementMethod.disabled,active);assert.equal(controls.setMethodBtn.disabled,active);
'''.replace('ACTIVE', 'true' if active else 'false').replace('BODY', functions)
result = subprocess.run(['node', '-e', script], capture_output=True, text=True)