test: 同步报销审批流与预算分析测试

- 新增预算审批合并、风险标记去重与占位条目校验用例
- 补充预算分析对当前审核人与财务的可见性断言
- 调整单据删除权限测试以匹配 admin 限制
This commit is contained in:
caoxiaozhu
2026-06-17 14:39:26 +08:00
parent 0fac8b615f
commit 4199feb681
10 changed files with 907 additions and 42 deletions

View File

@@ -313,15 +313,34 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
with session_factory() as db: with session_factory() as db:
seed_budget_allocations(db) seed_budget_allocations(db)
budget_role, market_department = seed_market_budget_monitor(db) budget_role, market_department = seed_market_budget_monitor(db)
manager_role = Role(role_code="manager", name="直属领导")
finance_role = Role(role_code="finance", name="财务")
manager = Employee(
employee_no="E-BUDGET-MANAGER",
name="预算领导",
email="budget-manager-review@example.com",
grade="P7",
organization_unit=market_department,
roles=[manager_role],
)
finance_user = Employee(
employee_no="E-BUDGET-FINANCE",
name="预算财务",
email="budget-finance-review@example.com",
grade="P6",
organization_unit=market_department,
roles=[finance_role],
)
p6_budget_monitor = Employee( p6_budget_monitor = Employee(
employee_no="E-BUDGET-MARKET-P6", employee_no="E-BUDGET-MARKET-P6",
name="低级预算", name="低级预算",
email="p6-budget-monitor@example.com", email="p6-budget-monitor@example.com",
grade="P6", grade="P6",
organization_unit=market_department, organization_unit=market_department,
manager=manager,
roles=[budget_role], roles=[budget_role],
) )
db.add(p6_budget_monitor) db.add_all([manager, finance_user, p6_budget_monitor])
db.flush() db.flush()
claim = ExpenseClaim( claim = ExpenseClaim(
claim_no="APP-BUDGET-ANALYSIS-001", claim_no="APP-BUDGET-ANALYSIS-001",
@@ -342,9 +361,49 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
approval_stage="预算管理者审批", approval_stage="预算管理者审批",
risk_flags_json=[], risk_flags_json=[],
) )
db.add(claim) leader_claim = ExpenseClaim(
claim_no="RE-BUDGET-ANALYSIS-LEADER",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel",
reason="客户现场交付报销",
location="上海",
amount=Decimal("6000.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
finance_claim = ExpenseClaim(
claim_no="RE-BUDGET-ANALYSIS-FINANCE",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel",
reason="客户现场交付报销",
location="上海",
amount=Decimal("8000.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add_all([claim, leader_claim, finance_claim])
db.commit() db.commit()
claim_id = claim.id claim_id = claim.id
leader_claim_id = leader_claim.id
finance_claim_id = finance_claim.id
ordinary_response = client.get( ordinary_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis", f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
@@ -367,8 +426,26 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
"x-auth-role-codes": "budget_monitor", "x-auth-role-codes": "budget_monitor",
}, },
) )
leader_response = client.get(
f"/api/v1/reimbursements/claims/{leader_claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-manager-review@example.com",
"x-auth-role-codes": "manager",
},
)
finance_response = client.get(
f"/api/v1/reimbursements/claims/{finance_claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-finance-review@example.com",
"x-auth-role-codes": "finance",
},
)
assert ordinary_response.status_code == 403 assert ordinary_response.status_code == 403
assert p6_monitor_response.status_code == 403 assert p6_monitor_response.status_code == 403
assert monitor_response.status_code == 200 assert monitor_response.status_code == 200
assert leader_response.status_code == 200
assert finance_response.status_code == 200
assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00") assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00")
assert Decimal(leader_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("12.00")
assert Decimal(finance_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("16.00")

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -236,7 +237,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None: def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None:
with build_session() as db: with build_session() as db:
department, manager, _budget_manager, employee = _seed_people(db, suffix="OVER-90-APP") department, manager, budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
_seed_budget_allocation( _seed_budget_allocation(
db, db,
department_id=department.id, department_id=department.id,
@@ -288,6 +289,18 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
for flag in routed.risk_flags_json for flag in routed.risk_flags_json
) )
with pytest.raises(ValueError, match="预算已超过警戒值"):
ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=budget_manager.email,
name=budget_manager.name,
role_codes=["budget_monitor"],
is_admin=False,
),
opinion=" ",
)
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None: def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
with build_session() as db: with build_session() as db:
@@ -496,3 +509,57 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in budget_approved.risk_flags_json for flag in budget_approved.risk_flags_json
) )
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
with build_session() as db:
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
_seed_budget_allocation(
db,
department_id=department.id,
department_name=department.name,
amount=Decimal("10000.00"),
)
claim = ExpenseClaim(
claim_no="RE-20260530-BUDGET-NORMAL",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel",
reason="客户现场沟通",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
risk_flags_json=[],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=budget_manager.email,
name=budget_manager.name,
role_codes=["budget_monitor"],
is_admin=False,
),
opinion=" ",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_claim_budget_approval"
and flag.get("opinion") == "同意"
for flag in approved.risk_flags_json
)

View File

@@ -0,0 +1,47 @@
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
def test_dedupe_claim_risk_flags_keeps_highest_severity_for_same_route_issue() -> None:
flags = [
{
"severity": "high",
"label": "多城市行程待说明",
"message": "检测到本次差旅涉及深圳多个目的地,但当前报销事由未说明中转原因。",
"item_ids": ["route-1", "route-2"],
"business_stage": "reimbursement",
},
{
"severity": "medium",
"label": "多城市行程缺少说明中风险",
"message": "本次报销识别到多城市行程,但事由中未说明中转、多地拜访或改签原因。",
"item_ids": ["route-2"],
"business_stage": "reimbursement",
},
]
deduped = dedupe_claim_risk_flags(flags)
assert [flag["label"] for flag in deduped] == ["多城市行程待说明"]
def test_dedupe_claim_risk_flags_keeps_distinct_item_risks() -> None:
flags = [
{
"severity": "high",
"label": "住宿金额超出报销标准",
"message": "第一张住宿票超出住宿标准。",
"item_id": "hotel-1",
"business_stage": "reimbursement",
},
{
"severity": "medium",
"label": "住宿金额超出报销标准",
"message": "第二张住宿票超出住宿标准。",
"item_id": "hotel-2",
"business_stage": "reimbursement",
},
]
deduped = dedupe_claim_risk_flags(flags)
assert [flag["item_id"] for flag in deduped] == ["hotel-1", "hotel-2"]

View File

@@ -37,6 +37,7 @@ from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE, APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
) )
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService from app.services.ocr import OcrService
@@ -267,6 +268,81 @@ def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
assert any("缺少票据标识" in item for item in issues) assert any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_does_not_block_uploaded_receipt_ocr_gaps() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="hotel", location="北京")
claim.invoice_count = 1
claim.amount = Decimal("1086.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_date = None
claim.items[0].item_reason = ""
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
issues = service._validate_claim_for_submission(claim)
assert not any("缺少日期" in item for item in issues)
assert not any("缺少说明" in item for item in issues)
assert not any("缺少金额" in item for item in issues)
assert not any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_ignores_trailing_placeholder_item() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="上海")
claim.amount = Decimal("1086.00")
claim.invoice_count = 1
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "上海喜来登酒店"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("1086.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
claim.items.append(
ExpenseClaimItem(
id="item-2",
claim_id="claim-1",
item_date=date(2026, 2, 23),
item_type="hotel_ticket",
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
invoice_id="",
)
)
issues = service._validate_claim_for_submission(claim)
assert not any(item.startswith("费用明细第 2 条") for item in issues)
def test_validate_claim_for_submission_skips_generated_allowance_item() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="上海")
claim.amount = Decimal("1486.00")
claim.invoice_count = 1
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "上海喜来登酒店"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("1086.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
claim.items.append(
ExpenseClaimItem(
id="allowance-1",
claim_id="claim-1",
item_date=date(2026, 2, 23),
item_type="travel_allowance",
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
invoice_id="",
)
)
issues = service._validate_claim_for_submission(claim)
assert not any(item.startswith("费用明细第 2 条") for item in issues)
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None: def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
user_id = "preview-only@example.com" user_id = "preview-only@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报" message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
@@ -2979,10 +3055,10 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None: def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", username="admin",
name="张三", name="张三",
role_codes=[], role_codes=["admin"],
is_admin=False, is_admin=True,
) )
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
@@ -3018,6 +3094,27 @@ def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path)
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None: def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", username="emp-1",
@@ -3624,6 +3721,29 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
current_user=current_user, current_user=current_user,
) )
def fake_platform_route_review(self, claim, *, rule_codes=None, business_stage=None):
return {
"flags": [
{
"source": "submission_review",
"hit_source": "rule_center",
"rule_code": "risk.travel.medium.multi_city_no_reason",
"severity": "medium",
"label": "多城市行程缺少说明中风险",
"message": "本次报销识别到多城市行程(上海、武汉、成都),但事由中未说明中转、多地拜访或改签原因。",
"item_ids": ["travel-item-2"],
"business_stage": "reimbursement",
}
],
"blocking_reasons": [],
}
monkeypatch.setattr(
ExpenseClaimService,
"evaluate_platform_risk_rules",
fake_platform_route_review,
)
submitted = service.submit_claim(claim.id, current_user) submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None assert submitted is not None
@@ -3648,6 +3768,11 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
assert route_flags assert route_flags
assert all(flag.get("item_ids") for flag in route_flags) assert all(flag.get("item_ids") for flag in route_flags)
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags) assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
assert not any(
isinstance(flag, dict)
and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route( def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
@@ -5049,6 +5174,175 @@ def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
assert claim.risk_flags_json == [] assert claim.risk_flags_json == []
def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance() -> None:
current_user = CurrentUserContext(
username="manager-budget-monitor-reimbursement@example.com",
name="李预算经理",
role_codes=["manager", "budget_monitor", "executive"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-REIMBURSEMENT-MERGED",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E-RB-MERGED-MGR",
name="李预算经理",
email="manager-budget-monitor-reimbursement@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-RB-MERGED-EMP",
name="张三",
email="zhangsan-budget-monitor-reimbursement@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-MERGED",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel",
reason="上海出差报销",
location="上海",
amount=Decimal("3020.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
current_user,
opinion="业务必要且预算可承接,同意报销。",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_claim_approval"
and flag.get("label") == "领导及预算审核通过"
and flag.get("opinion") == "业务必要且预算可承接,同意报销。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "财务审批"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
admin_user = CurrentUserContext(
username="admin",
name="admin",
role_codes=["admin"],
is_admin=True,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-LEGACY-REPAIR",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E-LEGACY-MGR",
name="李预算经理",
email="manager-legacy-repair@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-LEGACY-EMP",
name="张三",
email="zhangsan-legacy-repair@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-LEGACY",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel",
reason="上海出差报销",
location="上海",
amount=Decimal("3020.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "manual_approval",
"event_type": "expense_claim_approval",
"approval_event_id": "legacy-approval-event",
"operator": manager.name,
"operator_username": manager.email,
"previous_approval_stage": DIRECT_MANAGER_APPROVAL_STAGE,
"next_approval_stage": BUDGET_MANAGER_APPROVAL_STAGE,
"next_approver_name": manager.name,
"next_approver_employee_id": manager.id,
}
],
)
db.add(claim)
db.commit()
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
assert repaired is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "approval_flow_repair"
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in repaired.risk_flags_json
)
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None: def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="application-owner@example.com", username="application-owner@example.com",

View File

@@ -50,7 +50,7 @@ test('direct approvers can return claims without receiving delete permissions',
assert.equal(canManageExpenseClaims(approverUser), false) assert.equal(canManageExpenseClaims(approverUser), false)
}) })
test('finance can return and final approve, but only executives can manage delete permissions', () => { test('finance can return and final approve, executives can manage claim visibility only', () => {
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true) assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false) assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false) assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false)

View File

@@ -68,6 +68,18 @@ test('budget ontology context maps dialog fields to ontology payload', () => {
assert.equal(context.budget_details[0].warning_threshold, '80%') assert.equal(context.budget_details[0].warning_threshold, '80%')
}) })
test('budget ontology includes approval review execution metrics', () => {
const fieldKeys = BUDGET_ONTOLOGY_FIELDS.map((field) => field.key)
assert.ok(fieldKeys.includes('claim_amount'))
assert.ok(fieldKeys.includes('claim_amount_ratio'))
assert.ok(fieldKeys.includes('usage_rate'))
assert.ok(fieldKeys.includes('after_usage_rate'))
assert.ok(fieldKeys.includes('remaining_budget_ratio'))
assert.ok(fieldKeys.includes('available_before_amount'))
assert.ok(fieldKeys.includes('over_budget_amount'))
})
test('budget expense type options expose real expense type codes', () => { test('budget expense type options expose real expense type codes', () => {
const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value) const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value)

View File

@@ -25,7 +25,7 @@ const requestsComposable = readFileSync(
'utf8' 'utf8'
) )
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => { test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/) assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
assert.doesNotMatch(documentsCenterView, /<nav class="status-tabs document-state-tabs"/) assert.doesNotMatch(documentsCenterView, /<nav class="status-tabs document-state-tabs"/)
assert.match(documentsCenterView, /class="document-status-filter"[\s\S]*class="document-filter status-dropdown-filter"/) assert.match(documentsCenterView, /class="document-status-filter"[\s\S]*class="document-filter status-dropdown-filter"/)
@@ -35,6 +35,7 @@ test('documents center keeps only the top scope tabs and renders status as a dro
) )
assert.match(documentsCenterView, /v-for="option in statusFilterOptions"/) assert.match(documentsCenterView, /v-for="option in statusFilterOptions"/)
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/) assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
assert.match(documentsCenterView, /aria-label="风险等级"/)
}) })
test('documents center top tabs start from all and show document category labels', () => { test('documents center top tabs start from all and show document category labels', () => {
@@ -104,7 +105,7 @@ test('documents center category tabs map to the intended row sources', () => {
test('documents center sorts every filtered scope by latest document time before pagination', () => { test('documents center sorts every filtered scope by latest document time before pagination', () => {
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange[\s\S]*\}\)\)/ /return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange[\s\S]*\}\)\)/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -296,23 +297,23 @@ test('documents center switches filter conditions by category tab', () => {
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/) assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '单据状态'[\s\S]*showDocumentType: true/ /\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: true/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/ /\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: false/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: \{[\s\S]*statusTitle: '报销状态'[\s\S]*showDocumentType: false/ /\[DOCUMENT_SCOPE_REIMBURSEMENT\]: \{[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: false/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_REVIEW\]: \{[\s\S]*sceneFallbackLabel: '审核场景'[\s\S]*statusTitle: '审核状态'[\s\S]*statusTabs: \['全部', '审批中', '待补充', '已完成'\]/ /\[DOCUMENT_SCOPE_REVIEW\]: \{[\s\S]*sceneFallbackLabel: '审核场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '归档状态'[\s\S]*statusTabs: \['全部', '已付款', '已完成'\]/ /\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs/
) )
assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/) assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/)
assert.match(documentsCenterView, /:placeholder="activeFilterConfig\.searchPlaceholder"/) assert.match(documentsCenterView, /:placeholder="activeFilterConfig\.searchPlaceholder"/)
@@ -326,10 +327,11 @@ test('documents center switches filter conditions by category tab', () => {
assert.doesNotMatch(documentsCenterView, /pageSizeOpen/) assert.doesNotMatch(documentsCenterView, /pageSizeOpen/)
}) })
test('documents center status dropdown derives labels and closes after selection', () => { test('documents center risk dropdown derives labels and closes after selection', () => {
assert.match(documentsCenterView, /const riskLevelTabs = \['全部', '高风险', '中风险', '低风险', '无风险'\]/)
assert.match(documentsCenterView, /const statusFilterOptions = computed\(\(\) =>/) assert.match(documentsCenterView, /const statusFilterOptions = computed\(\(\) =>/)
assert.match(documentsCenterView, /activeFilterConfig\.value\.statusTabs\.map/) assert.match(documentsCenterView, /activeFilterConfig\.value\.statusTabs\.map/)
assert.match(documentsCenterView, /label: tab === '全部' \? '全部状态' : tab/) assert.match(documentsCenterView, /label: tab === '全部' \? '全部风险' : tab/)
assert.match(documentsCenterView, /const statusFilterLabel = computed\(\(\) =>/) assert.match(documentsCenterView, /const statusFilterLabel = computed\(\(\) =>/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -337,6 +339,18 @@ test('documents center status dropdown derives labels and closes after selection
) )
}) })
test('documents center list renders risk level tags instead of status tags', () => {
assert.match(documentsCenterView, /<th>风险等级<\/th>/)
assert.match(documentsCenterView, /<td data-label="风险等级">[\s\S]*class="risk-level-tags"[\s\S]*v-for="tag in row\.riskTags"/)
assert.match(documentsCenterView, /import \{ countClaimRisks, resolveArchiveRiskTone \} from '..\/utils\/archiveCenterListFilters\.js'/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary\)/)
assert.match(documentsCenterView, /riskTone: riskMeta\.tone,[\s\S]*riskLabel: riskMeta\.label,[\s\S]*riskCount: riskMeta\.count,[\s\S]*riskTags: riskMeta\.tags/)
assert.match(documentsCenterView, /function matchesRiskLevelTab\(row, tab\) \{[\s\S]*tab === '高风险'[\s\S]*row\.riskTone === 'high'/)
assert.match(documentListSharedStyles, /\.risk-level-tags\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentListSharedStyles, /\.risk-level-tag\.high\s*\{[\s\S]*background:\s*#fef2f2;/)
assert.doesNotMatch(documentsCenterView, /<td data-label="状态"><span class="status-tag"/)
})
test('documents center status dropdown uses compact filter styling', () => { test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/) assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
assert.match(documentListSharedStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentListSharedStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)

View File

@@ -19,6 +19,10 @@ const approvalDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
'utf8' 'utf8'
) )
const confirmDialog = readFileSync(
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
'utf8'
)
const budgetAnalysisComponent = readFileSync( const budgetAnalysisComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)),
'utf8' 'utf8'
@@ -27,6 +31,10 @@ const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8' 'utf8'
) )
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
function extractFunction(source, name) { function extractFunction(source, name) {
const signatureIndex = source.indexOf(`function ${name}(`) const signatureIndex = source.indexOf(`function ${name}(`)
@@ -55,6 +63,9 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /approvalMode:/) assert.match(detailScript, /approvalMode:/)
assert.match(detailScript, /const leaderOpinion = ref\(''\)/) assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/) assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const approvalRiskConfirmed = ref\(false\)/)
assert.match(detailScript, /const approvalRiskConfirmItems = computed/)
assert.match(detailScript, /const approvalRiskConfirmRequired = computed/)
assert.match(detailScript, /const canApproveRequest = computed/) assert.match(detailScript, /const canApproveRequest = computed/)
assert.match(detailScript, /canApproveLeaderExpenseClaims/) assert.match(detailScript, /canApproveLeaderExpenseClaims/)
assert.match(detailScript, /canApproveBudgetExpenseApplications/) assert.match(detailScript, /canApproveBudgetExpenseApplications/)
@@ -73,8 +84,10 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.doesNotMatch(detailScript, /approvalNextStage/) assert.doesNotMatch(detailScript, /approvalNextStage/)
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/) assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/) assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => false\)/) assert.match(detailScript, /const budgetApprovalOpinionRequired = computed/)
assert.match(detailScript, /approvalOpinionTitle = computed\(\(\) => \(isFinanceApprovalStage\.value \? '财务意见' : '附加意见'\)\)/) assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => budgetApprovalOpinionRequired\.value\)/)
assert.match(detailScript, /hasBudgetApprovalWarning\(request\.value\)/)
assert.match(detailScript, /return '预算审批意见'/)
assert.match(detailScript, /buildLeaderApprovalEvents/) assert.match(detailScript, /buildLeaderApprovalEvents/)
assert.match(detailScript, /buildLeaderApprovalInfo/) assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /const leaderApprovalEvents = computed/) assert.match(detailScript, /const leaderApprovalEvents = computed/)
@@ -97,6 +110,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /resolveGeneratedDraftClaimNo/) assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /resolveApproveErrorMessage/) assert.match(detailScript, /resolveApproveErrorMessage/)
assert.match(detailScript, /当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。/) assert.match(detailScript, /当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。/)
assert.match(detailScript, /预算已超过警戒值,请填写预算审批意见后再通过。/)
assert.match(detailScript, /approveActionLabel/) assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/) assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/) assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
@@ -128,6 +142,9 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailTemplate, /:description="approvalConfirmDescription"/) assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/) assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/) assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /:risk-confirm-required="approvalRiskConfirmRequired"/)
assert.match(detailTemplate, /v-model:risk-confirmed="approvalRiskConfirmed"/)
assert.match(detailTemplate, /:risk-confirm-items="approvalRiskConfirmItems"/)
assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/) assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/)
assert.doesNotMatch(approvalDialog, /submit-confirm-summary/) assert.doesNotMatch(approvalDialog, /submit-confirm-summary/)
assert.doesNotMatch(approvalDialog, /单据编号/) assert.doesNotMatch(approvalDialog, /单据编号/)
@@ -144,10 +161,17 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest') const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/) assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/) assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
assert.match(handleApproveRequest, /approvalRiskConfirmed\.value = !approvalRiskConfirmRequired\.value/)
assert.match(confirmApproveRequest, /approveExpenseClaim/) assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)[\s\S]*emit\('backToRequests'\)/) assert.match(confirmApproveRequest, /approvalRiskConfirmRequired\.value && !approvalRiskConfirmed\.value/)
assert.doesNotMatch(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/) assert.match(confirmApproveRequest, /请先确认已核对风险说明和佐证材料,再继续审批。/)
assert.match(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(confirmApproveRequest, /预算已超过警戒值,请填写预算审批意见后再通过。/)
assert.match(confirmApproveRequest, /emit\('request-updated', \{[\s\S]*claimId: request\.value\.claimId,[\s\S]*claim: responsePayload[\s\S]*\}\)[\s\S]*emit\('backToRequests'\)/)
assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/) assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
assert.match(appShellScript, /async function handleRequestUpdated\(payload = \{\}\)/)
assert.match(appShellScript, /const mappedRequest = mapExpenseClaimToRequest\(payload\.claim\)/)
assert.match(appShellScript, /upsertRequestSnapshot\(mappedRequest\)/)
assert.match(approvalDialog, /<textarea/) assert.match(approvalDialog, /<textarea/)
assert.match(approvalDialog, /update:opinion/) assert.match(approvalDialog, /update:opinion/)
@@ -155,6 +179,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(approvalDialog, /opinionHint/) assert.match(approvalDialog, /opinionHint/)
assert.match(approvalDialog, /opinionRequired/) assert.match(approvalDialog, /opinionRequired/)
assert.match(approvalDialog, /\{\{ currentOpinion\.length \}\}\/500/) assert.match(approvalDialog, /\{\{ currentOpinion\.length \}\}\/500/)
assert.match(approvalDialog, /风险说明确认/)
assert.match(approvalDialog, /riskConfirmRequired/)
assert.match(approvalDialog, /update:risk-confirmed/)
assert.match(approvalDialog, /:confirm-disabled="confirmDisabled"/)
assert.match(approvalDialog, /props\.opinionRequired && !currentOpinion\.value\.trim\(\)/)
assert.match(confirmDialog, /confirmDisabled:\s*\{\s*type:\s*Boolean,\s*default:\s*false\s*\}/)
assert.match(confirmDialog, /:disabled="busy \|\| confirmDisabled"/)
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/) assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/) assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)

View File

@@ -15,6 +15,7 @@ import {
resolveRiskTagTone resolveRiskTagTone
} from '../src/views/scripts/travelRequestDetailInsights.js' } from '../src/views/scripts/travelRequestDetailInsights.js'
import { import {
buildExpenseDraftIssues,
buildExpenseItemViewModel, buildExpenseItemViewModel,
buildDraftBlockingIssues, buildDraftBlockingIssues,
rebuildExpenseItems, rebuildExpenseItems,
@@ -27,7 +28,8 @@ import {
} from '../src/views/scripts/travelRequestDetailAdviceModel.js' } from '../src/views/scripts/travelRequestDetailAdviceModel.js'
import { import {
buildStandardAdjustmentPayload, buildStandardAdjustmentPayload,
filterSubmitterResolvedRiskCards filterSubmitterResolvedRiskCards,
isRiskCardMissingExpenseNote
} from '../src/views/scripts/travelRequestDetailStandardAdjustment.js' } from '../src/views/scripts/travelRequestDetailStandardAdjustment.js'
const detailViewTemplate = readFileSync( const detailViewTemplate = readFileSync(
@@ -70,6 +72,10 @@ const stageRiskAdviceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)),
'utf8' 'utf8'
) )
const stageRiskAdviceStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/stage-risk-advice-card.css', import.meta.url)),
'utf8'
)
const attachmentMeta = { const attachmentMeta = {
file_name: 'taxi-invoice.pdf', file_name: 'taxi-invoice.pdf',
@@ -291,20 +297,56 @@ test('risk cards carry structured business stage for approval advice filtering',
assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), []) assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
}) })
test('stage risk advice card exposes direct reviewer action suggestion', () => { test('stage risk advice card focuses on document risks without profile or budget boards', () => {
assert.match(stageRiskAdviceCard, /class="employee-risk-action"/) assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
assert.match(stageRiskAdviceCard, /综合审核结论/)
assert.match(stageRiskAdviceCard, /建议结论/)
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/) assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
assert.match(stageRiskAdviceCard, /compactEvidenceItems/) assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
assert.match(stageRiskAdviceCard, /compactAdviceItems/) assert.match(stageRiskAdviceCard, /stageBasisTitle/)
assert.ok( assert.match(stageRiskAdviceCard, /stageBasisHint/)
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"') assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
< stageRiskAdviceCard.indexOf('class="employee-risk-action"') assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
) assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/) assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/) assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(220px, 32%\);/)
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/) assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
assert.match(stageRiskAdviceCard, /riskExplanationItems/)
assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
assert.match(stageRiskAdviceCard, /已补充异常说明/)
assert.match(stageRiskAdviceCard, /可按权限继续审批/) assert.match(stageRiskAdviceCard, /可按权限继续审批/)
assert.match(stageRiskAdviceCard, /申请单风险依据/)
assert.match(stageRiskAdviceCard, /报销单风险依据/)
assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
assert.doesNotMatch(stageRiskAdviceCard, /fetchExpenseClaimBudgetAnalysis/)
assert.doesNotMatch(stageRiskAdviceCard, /reviewDimensionCards/)
assert.doesNotMatch(stageRiskAdviceCard, /documentRiskMetrics/)
assert.doesNotMatch(stageRiskAdviceCard, /profileAdviceItems/)
assert.doesNotMatch(stageRiskAdviceCard, /profileContextItems/)
assert.doesNotMatch(stageRiskAdviceCard, /画像风险/)
assert.doesNotMatch(stageRiskAdviceCard, /退单\/补正/)
assert.doesNotMatch(stageRiskAdviceCard, /材料质量/)
assert.doesNotMatch(stageRiskAdviceCard, /申请人:/)
assert.doesNotMatch(stageRiskAdviceCard, /部门\/岗位:/)
assert.doesNotMatch(stageRiskAdviceCard, /budgetContextMetrics/)
assert.doesNotMatch(stageRiskAdviceCard, /BUDGET_FIELD_KEYS/)
assert.doesNotMatch(stageRiskAdviceCard, /预算池/)
assert.doesNotMatch(stageRiskAdviceCard, /未匹配预算池/)
assert.doesNotMatch(stageRiskAdviceCard, /科目未管控/)
assert.doesNotMatch(stageRiskAdviceCard, /占用比例/)
assert.doesNotMatch(stageRiskAdviceCard, /剩余比例/)
assert.doesNotMatch(stageRiskAdviceCard, /超预算风险/)
assert.doesNotMatch(stageRiskAdviceCard, /explanationContextMetrics/)
assert.doesNotMatch(stageRiskAdviceCard, /employee-risk-context-grid/)
assert.doesNotMatch(stageRiskAdviceCard, /class="employee-risk-action"/)
assert.doesNotMatch(stageRiskAdviceStyles, /employee-risk-ai-note/)
}) })
test('AI advice ignores approval opinions and flow logs as risks', () => { test('AI advice ignores approval opinions and flow logs as risks', () => {
@@ -489,6 +531,42 @@ test('AI advice view model sorts and displays every risk card', () => {
assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3']) assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
}) })
test('AI advice hides lower severity duplicate route explanation risks', () => {
const advice = buildAiAdviceViewModel({
riskCards: [
{
id: 'route-high',
tone: 'high',
label: '高风险',
title: '多城市行程待说明',
risk: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
itemIds: ['train-transfer', 'train-transfer-return']
},
{
id: 'route-medium',
tone: 'medium',
label: '中风险',
title: '多城市行程缺少说明中风险',
risk: '本次报销识别到多城市行程(上海、武汉、深圳),但事由中未说明中转、多地拜访或改签原因。',
itemIds: ['train-transfer', 'train-transfer-return']
},
{
id: 'hotel-high',
tone: 'high',
label: '高风险',
title: '住宿金额超出报销标准',
risk: '住宿金额超出当前职级报销标准。',
itemIds: ['hotel-item']
}
]
})
const riskSection = advice.sections.find((section) => section.kind === 'risk')
assert.deepEqual(advice.riskCards.map((item) => item.id), ['route-high', 'hotel-high'])
assert.deepEqual(riskSection.items.map((item) => item.id), ['route-high', 'hotel-high'])
assert.equal(riskSection.totalCount, 2)
})
test('AI advice view model omits empty sections', () => { test('AI advice view model omits empty sections', () => {
const readyAdvice = buildAiAdviceViewModel({ const readyAdvice = buildAiAdviceViewModel({
completionItems: [], completionItems: [],
@@ -640,6 +718,84 @@ test('route-level risk cards keep related item ids for every affected expense ro
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/) assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
}) })
test('claim risk cards expose related expense explanations to reviewers', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'hotel-row',
name: '住宿票',
desc: '上海喜来登酒店',
itemNote: '时间紧,没有合适的酒店,只能住宿超过金额的酒店。'
},
{
id: 'route-extra-out',
name: '火车票',
desc: '上海-深圳',
itemNote: '中间去深圳,公司要求。'
},
{
id: 'route-extra-back',
name: '火车票',
desc: '深圳-上海',
itemNote: '中间去深圳,公司要求。'
}
],
claimRiskFlags: [
{
source: 'submission_review',
severity: 'high',
label: '多城市行程待说明',
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
item_ids: ['route-extra-out', 'route-extra-back']
}
]
})
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['route-extra-out', 'route-extra-back'])
assert.match(riskCards[0].risk, /用户已在相关费用明细补充异常说明/)
assert.doesNotMatch(riskCards[0].risk, /未说明/)
assert.match(riskCards[0].suggestion, /用户已在费用明细补充异常说明/)
assert.match(riskCards[0].suggestion, /上海-深圳:中间去深圳,公司要求/)
assert.match(riskCards[0].relatedExplanationSummary, /深圳-上海:中间去深圳,公司要求/)
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
})
test('claim risk cards infer hotel explanations when risk flag has no item ids', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'hotel-row',
name: '住宿票',
desc: '上海喜来登酒店',
itemType: 'hotel_ticket',
itemNote: '时间紧,没有合适的酒店,只能住宿超过金额的酒店。'
},
{
id: 'route-row',
name: '火车票',
desc: '上海-深圳',
itemType: 'train_ticket',
itemNote: '中间去深圳,公司要求。'
}
],
claimRiskFlags: [
{
source: 'submission_review',
severity: 'high',
label: '住宿金额超出报销标准',
message: '住宿标准P5在上海的住宿标准为 250.00 元/晚,票据识别金额 1086.00 元 / 3 晚,约 362.00 元/晚,超出 112.00 元/晚。'
}
]
})
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['hotel-row'])
assert.match(riskCards[0].relatedExplanationSummary, /上海喜来登酒店:时间紧,没有合适的酒店/)
assert.doesNotMatch(riskCards[0].relatedExplanationSummary, /上海-深圳/)
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
})
test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => { test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => {
const riskCards = buildAttachmentRiskCards({ const riskCards = buildAttachmentRiskCards({
expenseItems: [ expenseItems: [
@@ -853,6 +1009,45 @@ test('ticket item types and system allowance row are visible but read only', ()
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/) assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
}) })
test('expense item rebuild hides empty placeholders but keeps generated allowance row', () => {
const items = rebuildExpenseItems(
[
{
id: 'hotel-uploaded',
itemType: 'hotel_ticket',
itemDate: '2026-02-20',
itemReason: '上海喜来登酒店',
itemLocation: '上海',
itemAmount: 1086,
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
},
{
id: 'empty-travel-placeholder',
itemType: 'travel',
itemDate: '2026-02-23',
itemReason: '',
itemLocation: '',
itemAmount: 0,
invoiceId: ''
},
{
id: 'allowance',
itemType: 'travel_allowance',
itemDate: '2026-02-23',
itemReason: '系统自动计算出差补贴上海4天100.00元/天',
itemLocation: '直辖市/特区',
itemAmount: 400,
invoiceId: ''
}
],
{ typeCode: 'travel', detailVariant: 'travel' }
)
assert.deepEqual(items.map((item) => item.id), ['hotel-uploaded', 'allowance'])
assert.equal(items[1].isSystemGenerated, true)
assert.equal(items[1].attachmentStatus, '无需附件')
})
test('travel item date caption distinguishes departure return and trip events', () => { test('travel item date caption distinguishes departure return and trip events', () => {
assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/) assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/)
assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/) assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/)
@@ -1114,6 +1309,26 @@ test('standard adjustment resolves submitter risk prompt only after accepted whi
) )
}) })
test('multi item risk is not missing explanation when every related row has note', () => {
const card = {
id: 'risk-multi-city',
itemIds: ['route-extra-out', 'route-extra-back'],
tone: 'high',
risk: '多城市行程待说明。'
}
const explainedItems = [
{ id: 'route-extra-out', itemNote: '中间去深圳,项目现场要求。' },
{ id: 'route-extra-back', itemNote: '从深圳返回上海继续支撑部署。' }
]
const partlyMissingItems = [
{ id: 'route-extra-out', itemNote: '中间去深圳,项目现场要求。' },
{ id: 'route-extra-back', itemNote: '' }
]
assert.equal(isRiskCardMissingExpenseNote(card, explainedItems), false)
assert.equal(isRiskCardMissingExpenseNote(card, partlyMissingItems), true)
})
test('expense item upload remains limited to one receipt per detail row', () => { test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/) assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch( assert.doesNotMatch(
@@ -1376,6 +1591,100 @@ test('draft submit validation uses expense detail date and amount when claim sum
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善'))) assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
}) })
test('draft submit validation does not hard block uploaded receipt rows with OCR gaps', () => {
const issues = buildDraftBlockingIssues(
{
profileName: '张三',
typeLabel: '住宿费',
typeCode: 'hotel',
reason: '上海出差住宿',
location: '上海',
occurredDisplay: '2026-06-01',
amountValue: 1086
},
[
buildExpenseItemViewModel(
{
id: 'hotel-uploaded',
itemType: 'hotel_ticket',
itemReason: '',
itemDate: '',
itemAmount: 0,
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
},
0,
{ typeCode: 'hotel', detailVariant: 'travel' }
)
]
)
assert.ok(!issues.some((issue) => issue.includes('缺少日期')))
assert.ok(!issues.some((issue) => issue.includes('缺少说明')))
assert.ok(!issues.some((issue) => issue.includes('缺少金额')))
assert.ok(!issues.some((issue) => issue.includes('缺少票据标识')))
})
test('draft submit validation ignores trailing placeholder detail rows', () => {
const issues = buildDraftBlockingIssues(
{
profileName: '张三',
typeLabel: '差旅费',
typeCode: 'travel',
reason: '上海出差',
location: '上海',
occurredDisplay: '2026-02-20 至 2026-02-23',
amountValue: 1086
},
[
buildExpenseItemViewModel(
{
id: 'hotel-uploaded',
itemType: 'hotel_ticket',
itemReason: '上海喜来登酒店',
itemLocation: '上海',
itemDate: '2026-02-20',
itemAmount: 1086,
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
},
0,
{ typeCode: 'travel', detailVariant: 'travel' }
),
buildExpenseItemViewModel(
{
id: 'placeholder-6',
itemType: 'hotel_ticket',
itemDate: '2026-02-23',
itemReason: '',
itemLocation: '',
itemAmount: 0,
invoiceId: ''
},
1,
{ typeCode: 'travel', detailVariant: 'travel' }
)
]
)
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少说明')))
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少地点')))
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少金额')))
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少票据标识')))
})
test('draft submit validation does not require receipt fields for generated allowance rows', () => {
const issues = buildExpenseDraftIssues({
id: 'allowance',
itemType: 'travel_allowance',
itemDate: '2026-02-23',
itemReason: '',
itemLocation: '',
itemAmount: 0,
invoiceId: ''
})
assert.deepEqual(issues, [])
})
test('returned application submit validation does not require expense detail rows', () => { test('returned application submit validation does not require expense detail rows', () => {
const issues = buildDraftBlockingIssues( const issues = buildDraftBlockingIssues(
{ {

View File

@@ -48,6 +48,8 @@ function extractFunction(source, name) {
test('detail submit opens a confirmation dialog before calling submit API', () => { test('detail submit opens a confirmation dialog before calling submit API', () => {
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/) assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
assert.match(detailViewTemplate, /:secondary-text="submitConfirmSecondaryText"/)
assert.match(detailViewTemplate, /@secondary="confirmStandardAdjustment"/)
assert.match(detailViewTemplate, /:open="submitConfirmDialogOpen"[\s\S]*size="review"/) assert.match(detailViewTemplate, /:open="submitConfirmDialogOpen"[\s\S]*size="review"/)
assert.match(detailViewTemplate, /cancel-text="返回核对"/) assert.match(detailViewTemplate, /cancel-text="返回核对"/)
assert.match(detailViewTemplate, /@click="handleSubmit"/) assert.match(detailViewTemplate, /@click="handleSubmit"/)
@@ -56,6 +58,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/) assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/) assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
assert.match(detailViewScript, /const submitActionLabel = computed/) assert.match(detailViewScript, /const submitActionLabel = computed/)
assert.match(detailViewScript, /const submitConfirmSecondaryText = computed/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/) assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/) assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/)
assert.match(detailViewScript, /submitConfirmDialogOpen,/) assert.match(detailViewScript, /submitConfirmDialogOpen,/)
@@ -78,23 +81,38 @@ test('detail submit warns on missing risk explanation and supports standard adju
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/) assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/) assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)
assert.match(detailViewTemplate, /异常说明/) assert.match(detailViewTemplate, /异常说明/)
assert.match(detailViewTemplate, /按职级标准重算/) assert.match(detailViewTemplate, /:title="riskOverrideDialogTitle"/)
assert.match(detailViewTemplate, /:description="riskOverrideDialogDescription"/)
assert.match(detailViewTemplate, /:confirm-text="riskOverrideConfirmText"/)
assert.match(detailViewTemplate, /@confirm="confirmRiskOverrideDialog"/)
assert.match(detailViewTemplate, /class="risk-override-card-shell"/)
assert.match(detailViewTemplate, /class="risk-override-side-nav risk-override-side-nav--previous"/)
assert.match(detailViewTemplate, /class="risk-override-side-nav risk-override-side-nav--next"/)
assert.match(detailViewTemplate, /class="risk-override-guidance"/) assert.match(detailViewTemplate, /class="risk-override-guidance"/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/) assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.doesNotMatch(detailViewTemplate, /class="risk-override-nav"/)
assert.doesNotMatch(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) assert.doesNotMatch(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.doesNotMatch(detailViewTemplate, /risk-override-save-btn/) assert.doesNotMatch(detailViewTemplate, /risk-override-save-btn/)
assert.doesNotMatch(detailViewTemplate, /confirmRiskOverrideReasons/) assert.doesNotMatch(detailViewTemplate, /confirmRiskOverrideReasons/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/) assert.match(detailViewScript, /const submitRiskWarnings = computed/)
assert.match(detailViewScript, /const submitExplainedRiskWarnings = computed/)
assert.match(detailViewScript, /const submitRiskReviewWarnings = computed/)
assert.match(detailViewScript, /const hasMissingSubmitRiskWarnings = computed/)
assert.match(detailViewScript, /const riskOverrideConfirmText = computed\(\(\) =>[\s\S]*确认说明/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit') const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest') const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/) assert.match(handleSubmit, /submitRiskReviewWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/) assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.doesNotMatch(detailViewScript, /riskOverrideReasons/) assert.doesNotMatch(detailViewScript, /riskOverrideReasons/)
assert.doesNotMatch(detailViewScript, /function confirmRiskOverrideReasons\(\)/) assert.doesNotMatch(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /function confirmRiskOverrideDialog\(\)/)
assert.match(detailViewScript, /function confirmRiskExplanation\(\)/)
assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/) assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/)
assert.match(detailViewTemplate, /v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner"/) assert.match(detailViewTemplate, /v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner"/)
assert.match(detailViewScript, /const standardAdjustmentBusy = ref\(false\)/) assert.match(detailViewScript, /const standardAdjustmentBusy = ref\(false\)/)
const confirmRiskExplanation = extractFunction(detailViewScript, 'confirmRiskExplanation')
assert.match(confirmRiskExplanation, /riskOverrideDialogOpen\.value = false[\s\S]*submitConfirmDialogOpen\.value = true/)
const confirmStandardAdjustment = extractFunction(detailViewScript, 'confirmStandardAdjustment') const confirmStandardAdjustment = extractFunction(detailViewScript, 'confirmStandardAdjustment')
assert.match(confirmStandardAdjustment, /const claimId = String\(request\.value\?\.claimId/) assert.match(confirmStandardAdjustment, /const claimId = String\(request\.value\?\.claimId/)
assert.match(confirmStandardAdjustment, /riskOverrideDialogOpen\.value = false[\s\S]*standardAdjustmentBusy\.value = true[\s\S]*void runStandardAdjustmentRecalculation\(claimId, taskSeq\)/) assert.match(confirmStandardAdjustment, /riskOverrideDialogOpen\.value = false[\s\S]*standardAdjustmentBusy\.value = true[\s\S]*void runStandardAdjustmentRecalculation\(claimId, taskSeq\)/)
@@ -103,6 +121,7 @@ test('detail submit warns on missing risk explanation and supports standard adju
const runStandardAdjustmentRecalculation = extractFunction(detailViewScript, 'runStandardAdjustmentRecalculation') const runStandardAdjustmentRecalculation = extractFunction(detailViewScript, 'runStandardAdjustmentRecalculation')
assert.match(runStandardAdjustmentRecalculation, /acceptExpenseClaimStandardAdjustment\(claimId, payload\)/) assert.match(runStandardAdjustmentRecalculation, /acceptExpenseClaimStandardAdjustment\(claimId, payload\)/)
assert.doesNotMatch(runStandardAdjustmentRecalculation, /submitConfirmDialogOpen\.value = true/) assert.doesNotMatch(runStandardAdjustmentRecalculation, /submitConfirmDialogOpen\.value = true/)
assert.match(detailViewScript, /buildStandardAdjustmentPayloadModel\(\{[\s\S]*warnings:\s*submitRiskCards\.value/)
const actionBusyStart = detailViewScript.indexOf('const actionBusy = computed') const actionBusyStart = detailViewScript.indexOf('const actionBusy = computed')
const actionBusyEnd = detailViewScript.indexOf('const profile = computed', actionBusyStart) const actionBusyEnd = detailViewScript.indexOf('const profile = computed', actionBusyStart)
assert.ok(actionBusyStart > -1 && actionBusyEnd > actionBusyStart) assert.ok(actionBusyStart > -1 && actionBusyEnd > actionBusyStart)
@@ -126,20 +145,15 @@ test('detail header and fallback progress use reimbursement wording', () => {
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/) assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
}) })
test('archived detail delete action is gated by admin-only permission', () => { test('detail delete action is gated by admin-only permission', () => {
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/) assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/)
assert.match(detailViewScript, /isArchivedRequestView/)
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/) assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/) assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
}) })
test('editable detail delete action is limited to applicant or claim manager', () => { test('detail delete action does not allow applicant or claim manager fallback', () => {
assert.match(detailViewScript, /const isCurrentApplicant = computed/) assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/)
assert.match(detailViewScript, /isPlatformAdminUser/) assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\) \|\| \(isEditableRequest\.value && isCurrentApplicant\.value\)\s*}/)
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/) assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/) assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
}) })