test: 同步报销审批流与预算分析测试
- 新增预算审批合并、风险标记去重与占位条目校验用例 - 补充预算分析对当前审核人与财务的可见性断言 - 调整单据删除权限测试以匹配 admin 限制
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
47
server/tests/test_expense_claim_risk_flags.py
Normal file
47
server/tests/test_expense_claim_risk_flags.py
Normal 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"]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;/)
|
||||||
|
|||||||
@@ -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;/)
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user