feat: 新增员工行为画像算法与费用风险标签体系

后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-28 12:09:49 +08:00
parent 04cd6d0f81
commit 8a4a777be7
96 changed files with 9835 additions and 704 deletions

View 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"]
)

View 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

View File

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

View File

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

View File

@@ -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",

View File

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