Add reviewed approval and billing lifecycle to time queue
This commit is contained in:
parent
239d75ff06
commit
ba463ced4e
@ -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
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
148
tests/test_economy_queue_lifecycle.py
Normal file
148
tests/test_economy_queue_lifecycle.py
Normal 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})
|
||||
@ -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 <script>',color:'red;position:fixed'}]});assert.match(out,/ABA <script>/);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');
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user