feat: 新增员工行为画像算法与费用风险标签体系
后端新增员工行为画像算法模块,支持标签规则引擎和评分计算, 完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流 和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费 用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台 样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
177
server/tests/test_employee_behavior_profile_algorithm.py
Normal file
177
server/tests/test_employee_behavior_profile_algorithm.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.algorithem.employee_behavior_profile import (
|
||||
ProfileComponent,
|
||||
build_review_suggestions,
|
||||
calculate_review_priority_score,
|
||||
evaluate_weighted_profile,
|
||||
level_from_score,
|
||||
normalize_by_peer_percentiles,
|
||||
percentile,
|
||||
)
|
||||
from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags
|
||||
|
||||
|
||||
def test_peer_percentile_normalization_and_level_mapping() -> None:
|
||||
assert percentile([10, 20, 30, 40, 50], 90) == Decimal("46.0")
|
||||
assert normalize_by_peer_percentiles(35, 20, 50) == 50
|
||||
assert normalize_by_peer_percentiles(10, 20, 50) == 0
|
||||
assert level_from_score(82) == "escalation"
|
||||
|
||||
|
||||
def test_weighted_profile_uses_component_weights() -> None:
|
||||
result = evaluate_weighted_profile(
|
||||
"expense",
|
||||
[
|
||||
ProfileComponent("frequency_score", "申请频次", 80, weight=Decimal("0.20")),
|
||||
ProfileComponent("amount_occupancy_score", "预算占用", 60, weight=Decimal("0.30")),
|
||||
ProfileComponent("peer_deviation_score", "同组偏离", 40, weight=Decimal("0.50")),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.profile_score == 54
|
||||
assert result.profile_level == "watch"
|
||||
assert result.top_contributors(1)[0]["code"] == "peer_deviation_score"
|
||||
|
||||
|
||||
def test_review_priority_excludes_ai_usage_score() -> None:
|
||||
assert (
|
||||
calculate_review_priority_score(
|
||||
expense_profile_score=80,
|
||||
process_quality_score=20,
|
||||
)
|
||||
== 62
|
||||
)
|
||||
assert calculate_review_priority_score(
|
||||
expense_profile_score=80,
|
||||
process_quality_score=20,
|
||||
) == calculate_review_priority_score(
|
||||
expense_profile_score=80,
|
||||
process_quality_score=20,
|
||||
)
|
||||
|
||||
|
||||
def test_review_suggestions_generate_caps_without_auto_penalty() -> None:
|
||||
suggestions = build_review_suggestions(
|
||||
expense_profile_score=72,
|
||||
process_quality_score=65,
|
||||
requested_days=Decimal("5"),
|
||||
peer_days_p75=Decimal("3"),
|
||||
policy_limit=Decimal("800"),
|
||||
peer_unit_amount_p75=Decimal("600"),
|
||||
)
|
||||
|
||||
types = {item["type"] for item in suggestions}
|
||||
assert "review_travel_days" in types
|
||||
assert "review_entertainment_unit_amount" in types
|
||||
assert any(item["recommended_upper"] == "3" for item in suggestions)
|
||||
|
||||
|
||||
def test_profile_tags_and_approval_radar_use_quantified_evidence() -> None:
|
||||
profiles = [
|
||||
{
|
||||
"profile_type": "expense",
|
||||
"score": 82,
|
||||
"level": "escalation",
|
||||
"metrics": {
|
||||
"window_days": 90,
|
||||
"expense_type_scope": "travel",
|
||||
"peer_sample_size": 20,
|
||||
"amount_total": "128000",
|
||||
"amount_share": "0.34",
|
||||
"claim_count": 6,
|
||||
"current_claim_amount": "56000",
|
||||
"requested_days": "5",
|
||||
"peer_days_p75": "3",
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "amount_occupancy_score", "score": 90},
|
||||
{"code": "peer_deviation_score", "score": 88},
|
||||
{"code": "current_claim_deviation_score", "score": 86},
|
||||
{"code": "frequency_score", "score": 84},
|
||||
],
|
||||
},
|
||||
{
|
||||
"profile_type": "process_quality",
|
||||
"score": 68,
|
||||
"level": "review",
|
||||
"metrics": {
|
||||
"peer_sample_size": 20,
|
||||
"return_count": 2,
|
||||
"missing_attachment_count": 3,
|
||||
"invoice_mismatch_count": 1,
|
||||
"missing_business_context_count": 2,
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "return_count_score", "score": 70},
|
||||
{"code": "missing_attachment_score", "score": 75},
|
||||
{"code": "invoice_mismatch_score", "score": 60},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
tags = build_profile_tags(profiles, scene="approval")
|
||||
tag_codes = {item["code"] for item in tags}
|
||||
assert {"expense_king", "large_amount_deviation", "return_frequent"} <= tag_codes
|
||||
assert all(item["evidence"] for item in tags)
|
||||
|
||||
radar = build_profile_radar(profiles, tags, scene="approval")
|
||||
dimensions = {item["code"]: item for item in radar["dimensions"]}
|
||||
assert set(dimensions) == {
|
||||
"expense_intensity",
|
||||
"application_rhythm",
|
||||
"travel_entertainment",
|
||||
"material_completeness",
|
||||
"process_pressure",
|
||||
}
|
||||
assert dimensions["expense_intensity"]["score"] >= 70
|
||||
assert "expense_king" in dimensions["expense_intensity"]["top_tags"]
|
||||
|
||||
|
||||
def test_profile_tags_include_ai_and_approval_traits_outside_approval_scene() -> None:
|
||||
profiles = [
|
||||
{
|
||||
"profile_type": "ai_usage",
|
||||
"score": 72,
|
||||
"level": "review",
|
||||
"metrics": {
|
||||
"peer_sample_size": 15,
|
||||
"ai_run_count": 14,
|
||||
"tool_call_count": 10,
|
||||
"failed_tool_call_count": 3,
|
||||
"estimated_token_count": 22000,
|
||||
"token_count_mode": "estimated_token_count",
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "ai_call_count_score", "score": 75},
|
||||
{"code": "token_cost_score", "score": 70},
|
||||
{"code": "failed_ai_call_score", "score": 80},
|
||||
],
|
||||
},
|
||||
{
|
||||
"profile_type": "approval",
|
||||
"score": 64,
|
||||
"level": "review",
|
||||
"metrics": {
|
||||
"peer_sample_size": 12,
|
||||
"approval_record_count": 6,
|
||||
"direct_approve_ratio": "0.5",
|
||||
"return_count": 3,
|
||||
"sla_overdue_rate": "0.4",
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "system_advice_override_score", "score": 70},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
tags = build_profile_tags(profiles, scene="operations")
|
||||
tag_codes = {item["code"] for item in tags}
|
||||
assert {"ai_heavy", "token_high", "ai_failure_cluster", "cautious_reviewer"} <= tag_codes
|
||||
|
||||
radar = build_profile_radar(profiles, tags, scene="operations")
|
||||
assert len(radar["dimensions"]) == 8
|
||||
assert any(
|
||||
item["code"] == "ai_collaboration" and item["score"] > 0
|
||||
for item in radar["dimensions"]
|
||||
)
|
||||
242
server/tests/test_employee_behavior_profile_service.py
Normal file
242
server/tests/test_employee_behavior_profile_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_scheduler import HermesScheduler
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def seed_profile_data(db: Session) -> None:
|
||||
org = OrganizationUnit(
|
||||
id="dept-sales",
|
||||
unit_code="SALES",
|
||||
name="市场部",
|
||||
unit_type="department",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-main",
|
||||
employee_no="E1001",
|
||||
name="张三",
|
||||
email="zhangsan@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
peer_a = Employee(
|
||||
id="emp-peer-a",
|
||||
employee_no="E1002",
|
||||
name="李四",
|
||||
email="lisi@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
peer_b = Employee(
|
||||
id="emp-peer-b",
|
||||
employee_no="E1003",
|
||||
name="王五",
|
||||
email="wangwu@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
db.add_all([org, employee, peer_a, peer_b])
|
||||
now = datetime.now(UTC)
|
||||
|
||||
claims = [
|
||||
_build_claim(
|
||||
"claim-main-1",
|
||||
employee.id,
|
||||
employee.name,
|
||||
Decimal("5000"),
|
||||
now - timedelta(days=5),
|
||||
missing_attachment=True,
|
||||
),
|
||||
_build_claim(
|
||||
"claim-main-2",
|
||||
employee.id,
|
||||
employee.name,
|
||||
Decimal("4200"),
|
||||
now - timedelta(days=15),
|
||||
returned=True,
|
||||
),
|
||||
_build_claim(
|
||||
"claim-main-3", employee.id, employee.name, Decimal("3800"), now - timedelta(days=30)
|
||||
),
|
||||
_build_claim(
|
||||
"claim-peer-a", peer_a.id, peer_a.name, Decimal("1200"), now - timedelta(days=12)
|
||||
),
|
||||
_build_claim(
|
||||
"claim-peer-b", peer_b.id, peer_b.name, Decimal("1500"), now - timedelta(days=18)
|
||||
),
|
||||
]
|
||||
db.add_all(claims)
|
||||
db.add(
|
||||
AgentRun(
|
||||
run_id="run-main-1",
|
||||
agent="hermes",
|
||||
source="user_message",
|
||||
user_id=employee.email,
|
||||
status="success",
|
||||
result_summary="AI 已辅助生成报销说明。",
|
||||
started_at=now - timedelta(days=2),
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-main-1",
|
||||
tool_type="expense",
|
||||
tool_name="claim_draft",
|
||||
request_json={"message": "出差报销"},
|
||||
response_json={"ok": True},
|
||||
status="success",
|
||||
duration_ms=120,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _build_claim(
|
||||
claim_id: str,
|
||||
employee_id: str,
|
||||
employee_name: str,
|
||||
amount: Decimal,
|
||||
occurred_at: datetime,
|
||||
*,
|
||||
missing_attachment: bool = False,
|
||||
returned: bool = False,
|
||||
) -> ExpenseClaim:
|
||||
invoice_id = None if missing_attachment else f"invoice-{claim_id}"
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=f"EXP-{claim_id}",
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
department_id="dept-sales",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-001",
|
||||
expense_type="travel",
|
||||
reason="客户拜访出差",
|
||||
location="北京",
|
||||
amount=amount,
|
||||
currency="CNY",
|
||||
invoice_count=0 if missing_attachment else 1,
|
||||
occurred_at=occurred_at,
|
||||
submitted_at=occurred_at,
|
||||
status="returned" if returned else "submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[{"source": "manual_return", "message": "补充票据"}] if returned else [],
|
||||
items=[
|
||||
ExpenseClaimItem(
|
||||
id=f"item-{claim_id}",
|
||||
claim_id=claim_id,
|
||||
item_date=date.today(),
|
||||
item_type="travel",
|
||||
item_reason="客户拜访出差",
|
||||
item_location="北京",
|
||||
item_amount=amount,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_service_scans_snapshots_and_filters_approval_scene() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
summary = HermesEmployeeProfileScannerService(db).scan_employee_profiles(log_id=None)
|
||||
|
||||
assert summary["target_employee_count"] >= 1
|
||||
assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 4
|
||||
|
||||
latest = EmployeeBehaviorProfileService(db).get_latest_profile(
|
||||
employee_id="emp-main",
|
||||
scene="approval",
|
||||
claim_id="claim-main-1",
|
||||
window_days=90,
|
||||
expense_type_scope="travel",
|
||||
)
|
||||
|
||||
assert latest.employee_id == "emp-main"
|
||||
assert {item.profile_type for item in latest.profiles} == {"expense", "process_quality"}
|
||||
assert latest.review_priority_score > 0
|
||||
assert latest.peer_group.sample_size >= 3
|
||||
assert latest.profile_tags
|
||||
assert latest.radar.dimensions
|
||||
|
||||
|
||||
def test_latest_profile_endpoint_returns_approval_payload() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/employee-profiles/emp-main/latest",
|
||||
params={
|
||||
"scene": "approval",
|
||||
"claim_id": "claim-main-1",
|
||||
"window_days": 90,
|
||||
"expense_type_scope": "travel",
|
||||
},
|
||||
headers={"x-auth-username": "auditor", "x-auth-name": "auditor"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["employee_id"] == "emp-main"
|
||||
assert {item["profile_type"] for item in payload["profiles"]} == {"expense", "process_quality"}
|
||||
assert payload["review_priority_score"] >= 0
|
||||
assert payload["profile_tags"]
|
||||
assert {item["code"] for item in payload["radar"]["dimensions"]} == {
|
||||
"expense_intensity",
|
||||
"application_rhythm",
|
||||
"travel_entertainment",
|
||||
"material_completeness",
|
||||
"process_pressure",
|
||||
}
|
||||
|
||||
|
||||
def test_hermes_scheduler_parses_weekly_profile_cron() -> None:
|
||||
scheduler = HermesScheduler()
|
||||
|
||||
assert scheduler._parse_simple_cron("30 8 * * 1") == (30, 8, 0)
|
||||
assert scheduler._parse_simple_cron("0 9 * * *") == (0, 9, None)
|
||||
assert scheduler._parse_simple_cron("bad cron") is None
|
||||
@@ -44,6 +44,7 @@ def test_employee_directory_seeds_rich_employee_data() -> None:
|
||||
assert any("审批负责人" in item.roles for item in employees)
|
||||
assert any(item.permissions for item in employees)
|
||||
assert any(item.history for item in employees)
|
||||
assert all(item.bankName and item.bankAccountNo and item.bankAccountName for item in employees)
|
||||
|
||||
role_count = db.scalar(select(func.count()).select_from(Role))
|
||||
org_count = db.scalar(select(func.count()).select_from(OrganizationUnit))
|
||||
@@ -84,6 +85,9 @@ def test_update_employee_persists_changes_and_hashes_password() -> None:
|
||||
grade="P6",
|
||||
finance_owner_name="共享财务中心",
|
||||
cost_center="CC-TEST-01",
|
||||
bank_account_name="测试员工A",
|
||||
bank_name="招商银行上海分行",
|
||||
bank_account_no="622588000000000001",
|
||||
role_codes=["finance", "user"],
|
||||
password="12345",
|
||||
),
|
||||
@@ -98,6 +102,9 @@ def test_update_employee_persists_changes_and_hashes_password() -> None:
|
||||
assert updated.grade == "P6"
|
||||
assert updated.financeOwner == "共享财务中心"
|
||||
assert updated.costCenter == "CC-TEST-01"
|
||||
assert updated.bankAccountName == "测试员工A"
|
||||
assert updated.bankName == "招商银行上海分行"
|
||||
assert updated.bankAccountNo == "622588000000000001"
|
||||
assert updated.roleCodes == ["finance", "user"]
|
||||
assert persisted is not None
|
||||
assert persisted.password_hash is not None
|
||||
|
||||
@@ -59,6 +59,9 @@ def test_import_employees_rejects_invalid_row_without_writing() -> None:
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
@@ -98,6 +101,9 @@ def test_import_employees_updates_existing_employee() -> None:
|
||||
"",
|
||||
"华东财务组",
|
||||
"CC-TEST",
|
||||
"导入户名",
|
||||
"招商银行上海分行",
|
||||
"622588000000000002",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
@@ -112,6 +118,9 @@ def test_import_employees_updates_existing_employee() -> None:
|
||||
assert updated is not None
|
||||
assert updated.name == new_name
|
||||
assert updated.phone == "13900000001"
|
||||
assert updated.bankAccountName == "导入户名"
|
||||
assert updated.bankName == "招商银行上海分行"
|
||||
assert updated.bankAccountNo == "622588000000000002"
|
||||
|
||||
|
||||
def test_import_employees_creates_new_employee() -> None:
|
||||
@@ -136,6 +145,9 @@ def test_import_employees_creates_new_employee() -> None:
|
||||
"E10234",
|
||||
"华东财务组",
|
||||
"CC-9001",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
@@ -151,3 +163,6 @@ def test_import_employees_creates_new_employee() -> None:
|
||||
).scalar_one()
|
||||
assert imported.name == "导入新员工"
|
||||
assert imported.email == "import.new.user@xfinance.com"
|
||||
assert imported.bank_account_name == "导入新员工"
|
||||
assert imported.bank_name
|
||||
assert imported.bank_account_no
|
||||
|
||||
@@ -2680,6 +2680,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-PAID",
|
||||
employee_name="丙",
|
||||
department_name="C部",
|
||||
project_code="PRJ-C",
|
||||
expense_type="office",
|
||||
reason="C 报销",
|
||||
location="深圳",
|
||||
amount=Decimal("180.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
||||
status="paid",
|
||||
approval_stage="已付款",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525120000-ABCDEFGH",
|
||||
employee_name="丙",
|
||||
@@ -2722,6 +2739,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
|
||||
assert {claim.claim_no for claim in claims} == {
|
||||
"EXP-ARCH-101",
|
||||
"EXP-ARCH-PAID",
|
||||
"AP-20260525120000-ABCDEFGH",
|
||||
}
|
||||
|
||||
@@ -3894,7 +3912,7 @@ def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
|
||||
assert claim.risk_flags_json == []
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
def test_finance_can_approve_claim_to_pending_payment_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
name="财务复核",
|
||||
@@ -3931,19 +3949,65 @@ def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "归档入账"
|
||||
assert approved.status == "pending_payment"
|
||||
assert approved.approval_stage == "待付款"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "finance_approval"
|
||||
and flag.get("event_type") == "expense_claim_finance_approval"
|
||||
and flag.get("opinion") == "票据与明细一致,同意入账。"
|
||||
and flag.get("previous_approval_stage") == "财务审批"
|
||||
and flag.get("next_approval_stage") == "归档入账"
|
||||
and flag.get("next_status") == "pending_payment"
|
||||
and flag.get("next_approval_stage") == "待付款"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-pay@example.com",
|
||||
name="财务付款",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-FIN-PAY-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.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="pending_payment",
|
||||
approval_stage="待付款",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
paid = ExpenseClaimService(db).mark_claim_paid(claim.id, current_user)
|
||||
|
||||
assert paid is not None
|
||||
assert paid.status == "paid"
|
||||
assert paid.approval_stage == "已付款"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "payment"
|
||||
and flag.get("event_type") == "expense_claim_payment_completed"
|
||||
and flag.get("previous_status") == "pending_payment"
|
||||
and flag.get("next_status") == "paid"
|
||||
and flag.get("next_approval_stage") == "已付款"
|
||||
for flag in paid.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
|
||||
@@ -364,7 +364,7 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_approve_application_endpoint_completes_after_direct_manager_review() -> None:
|
||||
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
@@ -415,15 +415,15 @@ def test_approve_application_endpoint_completes_after_direct_manager_review() ->
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "approved"
|
||||
assert payload["approval_stage"] == "审批完成"
|
||||
assert payload["status"] == "submitted"
|
||||
assert payload["approval_stage"] == "预算管理者审批"
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["event_type"] == "expense_application_approval"
|
||||
and item["opinion"] == "业务必要,同意申请。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_status"] == "approved"
|
||||
and item["next_approval_stage"] == "审批完成"
|
||||
and item["next_status"] == "submitted"
|
||||
and item["next_approval_stage"] == "预算管理者审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user