feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -182,7 +182,21 @@ def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume()
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
)
earliest_claim_day = db.scalar(
select(func.min(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")
)
)
latest_claim_day = db.scalar(
select(func.max(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")
)
)
assert admin_claim_count == 0
assert visible_claim_count is not None
assert 400 <= visible_claim_count <= 500
assert earliest_claim_day is not None
assert latest_claim_day is not None
assert earliest_claim_day.date() >= date(2026, 1, 1)
assert latest_claim_day.date() <= date(2026, 6, 2)

View File

@@ -157,3 +157,92 @@ def test_digital_employee_dashboard_keeps_empty_payload_without_fake_data() -> N
assert dashboard.totals["totalRuns"] == 0
assert dashboard.daily_work
assert dashboard.task_distribution == []
def test_digital_employee_dashboard_counts_finance_dashboard_snapshots() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add(
AgentRun(
run_id="run-finance-snapshot-001",
agent="hermes",
source="schedule",
user_id="digital_employee",
status="succeeded",
route_json={"task_type": "finance_dashboard_snapshot"},
result_summary="finance dashboard snapshot generated",
started_at=now - timedelta(minutes=3),
finished_at=now - timedelta(minutes=2),
tool_calls=[
AgentToolCall(
run_id="run-finance-snapshot-001",
tool_type="database",
tool_name="digital_employee.finance_dashboard.snapshot",
request_json={"task_type": "finance_dashboard_snapshot"},
response_json={
"summary": {
"finance_snapshot_count": 1,
"reimbursement_count": 534,
}
},
status="succeeded",
duration_ms=1200,
created_at=now - timedelta(minutes=3),
)
],
)
)
db.commit()
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["financeDashboardSnapshots"] == 1
assert dashboard.totals["businessOutputs"] == 1
assert dashboard.daily_work[-1]["financeDashboardSnapshots"] == 1
assert dashboard.task_distribution[0]["taskType"] == "finance_dashboard_snapshot"
def test_digital_employee_dashboard_counts_reminder_outputs() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add(
AgentRun(
run_id="run-reminder-scan-001",
agent="hermes",
source="schedule",
user_id="digital_employee",
status="succeeded",
route_json={"task_type": "digital_employee_reminder_scan"},
result_summary="reminder scan generated",
started_at=now - timedelta(minutes=3),
finished_at=now - timedelta(minutes=2),
tool_calls=[
AgentToolCall(
run_id="run-reminder-scan-001",
tool_type="database",
tool_name="digital_employee.reminder.scan",
request_json={"task_type": "digital_employee_reminder_scan"},
response_json={
"summary": {
"recipient_count": 3,
"reminder_count": 8,
"approval_pending_count": 2,
}
},
status="succeeded",
duration_ms=900,
created_at=now - timedelta(minutes=3),
)
],
)
)
db.commit()
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["reminders"] == 8
assert dashboard.totals["businessOutputs"] == 8
assert dashboard.daily_work[-1]["reminders"] == 8
assert dashboard.task_distribution[0]["taskType"] == "digital_employee_reminder_scan"

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.digital_employee_reminder_task import DigitalEmployeeReminderTaskService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_digital_employee_reminder_task_generates_actionable_report() -> None:
now = datetime(2026, 6, 2, 2, 0, tzinfo=UTC)
with build_session() as db:
_seed_reminder_data(db, now)
result = DigitalEmployeeReminderTaskService(db).refresh_reminders(now=now)
summary = result["summary"]
report = result["report"]
assert result["task_type"] == "digital_employee_reminder_scan"
assert summary["recipient_count"] >= 3
assert summary["reminder_count"] >= 4
assert summary["approval_pending_count"] == 1
assert summary["budget_reminder_count"] == 1
assert summary["travel_application_reminder_count"] == 1
assert summary["reimbursement_overdue_count"] == 1
reminder_types = {
reminder["type"]
for recipient in report["recipients"]
for reminder in recipient["reminders"]
}
assert {
"approval_pending",
"budget_compilation",
"travel_application_expiry",
"reimbursement_overdue",
}.issubset(reminder_types)
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["reminders"] >= 4
assert dashboard.totals["businessOutputs"] >= 4
assert dashboard.task_distribution[0]["taskType"] == "digital_employee_reminder_scan"
def _seed_reminder_data(db: Session, now: datetime) -> None:
budget_role = Role(
id="role-budget",
role_code="budget_monitor",
name="预算管理员",
description="预算编制提醒接收人",
)
manager = Employee(
id="emp-manager",
employee_no="M001",
name="审批领导",
email="manager@example.com",
position="部门负责人",
grade="M2",
)
employee = Employee(
id="emp-user",
employee_no="E001",
name="出差员工",
email="employee@example.com",
position="客户经理",
grade="P5",
manager=manager,
finance_owner_name="财务BP",
)
budget_admin = Employee(
id="emp-budget",
employee_no="B001",
name="预算管理员甲",
email="budget@example.com",
position="预算管理员",
grade="P6",
roles=[budget_role],
)
db.add_all([budget_role, manager, employee, budget_admin])
db.add_all(
[
_claim(
"claim-approval",
"EXP-APPROVAL-001",
employee,
"travel",
"12000.00",
now - timedelta(days=3),
"submitted",
"直属领导审批",
),
_claim(
"claim-travel-app",
"APP-TRAVEL-001",
employee,
"travel_application",
"8000.00",
now - timedelta(days=1),
"approved",
"已审批",
risk_flags=[
{
"source": "application_detail",
"application_detail": {
"application_type": "差旅申请",
"time": "2026-06-01",
},
}
],
),
_claim(
"claim-supplement",
"EXP-SUPPLEMENT-001",
employee,
"meal",
"600.00",
now - timedelta(days=2),
"returned",
"材料待补",
),
]
)
db.commit()
def _claim(
claim_id: str,
claim_no: str,
employee: Employee,
expense_type: str,
amount: str,
happened_at: datetime,
status: str,
approval_stage: str,
*,
risk_flags: list[dict] | None = None,
) -> ExpenseClaim:
return ExpenseClaim(
id=claim_id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_name="市场部",
expense_type=expense_type,
reason="客户拜访",
location="上海",
amount=Decimal(amount),
invoice_count=1,
occurred_at=happened_at,
submitted_at=happened_at,
status=status,
approval_stage=approval_stage,
risk_flags_json=risk_flags or [],
created_at=happened_at,
updated_at=happened_at,
)

View File

@@ -5,14 +5,17 @@ from typing import Any
from app.core.agent_enums import AgentName
from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
)
from app.services.agent_foundation_digital_employee_tasks import (
AgentFoundationDigitalEmployeeTaskMixin,
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
AgentFoundationDigitalEmployeeTaskMixin,
)
@@ -56,11 +59,17 @@ def test_digital_employee_skill_catalog_has_complete_categories_and_packages() -
categories = [str(spec["skill_category"]) for spec in specs]
skill_names = [str(dict(spec["config"])["skill_name"]) for spec in specs]
assert len(specs) == 16
assert len(specs) == 19
assert len(set(codes)) == len(codes)
assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE] == "积累"
assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 17
assert (
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE]
== "整理"
)
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE] == "升级"
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE] == "整理"
assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 20
for skill_name in ["finance-policy-knowledge-organizer", *skill_names]:
skill_file = _skill_root() / skill_name / "SKILL.md"
@@ -114,6 +123,9 @@ def test_digital_employee_skills_do_not_cross_rule_governance_boundary() -> None
)
assert "risk-clue-collector" in skill_names
assert "finance-dashboard-snapshot-analyst" in skill_names
assert "digital-employee-reminder-scanner" in skill_names
assert "finance-report-orchestrator" in skill_names
assert "rule-execution-case-organizer" in skill_names
assert "policy-reference-gap-hinter" in skill_names
assert "risk-rule-discovery" not in skill_names

View File

@@ -3541,12 +3541,63 @@ def test_direct_manager_cannot_delete_application_claim() -> None:
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"):
with pytest.raises(ValueError, match="只有草稿、待补充或退回待提交状态的单据"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_applicant_can_delete_returned_application_claim() -> None:
current_user = CurrentUserContext(
username="zhangsan-application-return-delete@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E-APP-DEL-RETURN",
name="张三",
email="zhangsan-application-return-delete@example.com",
)
db.add(employee)
db.flush()
claim = ExpenseClaim(
claim_no="APP-DEL-RETURN-101",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="差旅申请",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="returned",
approval_stage="待提交",
risk_flags_json=[
{
"source": "manual_return",
"event_type": "expense_application_return",
"message": "请补充出差事由",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "APP-DEL-RETURN-101"
assert db.get(ExpenseClaim, claim_id) is None
def test_admin_can_delete_application_claim() -> None:
current_user = CurrentUserContext(
username="superadmin",

View File

@@ -8,10 +8,12 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.agent_run import AgentRun
from app.models.budget import BudgetAllocation, BudgetTransaction
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.finance_dashboard import FinanceDashboardService
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
def build_session() -> Session:
@@ -165,12 +167,16 @@ def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() ->
assert dashboard.totals["reimbursementCount"] == 2
assert dashboard.totals["reimbursementAmount"] == 2000.0
assert dashboard.totals["pendingPaymentAmount"] == 0.0
assert dashboard.trend["applications"][-1] >= 1
assert sum(dashboard.trend["applications"]) >= 1
assert "AP-DASH-ADMIN-001" not in str(dashboard.trend)
assert dashboard.spend_by_category[0]["value"] == 1200.0
assert dashboard.department_ranking[0]["name"] == "财务部"
assert dashboard.department_ranking[0]["amount"] == 1200.0
assert dashboard.department_ranking[0]["employeeCount"] == 1
assert dashboard.department_employee_mix[0]["name"] == "财务部 · 陈雨晴"
assert dashboard.department_employee_mix[0]["amount"] == 1200.0
assert dashboard.employee_ranking[0]["name"] == "陈雨晴"
assert dashboard.employee_ranking[0]["count"] == 1
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
assert dashboard.budget_summary["ratio"] == 40.0
@@ -226,7 +232,7 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
ExpenseClaim(
claim_no="CLM-DASH-LABEL-003",
employee_name="reimbursement-user",
department_name="甯傚満閮?,
department_name="Market",
expense_type="travel",
reason="real travel reimbursement",
location="Shanghai",
@@ -327,10 +333,150 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
assert dashboard.trend["claimCount"][-1] == 1
assert dashboard.trend["claimAmount"][-1] == 700.0
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
assert dashboard.department_ranking[0]["name"] == "市场部"
assert dashboard.department_ranking[0]["name"] == "Market"
assert dashboard.department_ranking[0]["amount"] == 700.0
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
assert "风险金额" not in focus_names
assert "材料待补" not in focus_names
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
assert len(dashboard.budget_metrics) == 6
def test_finance_dashboard_ranking_range_supports_year_and_all_scope() -> None:
now = datetime.now(UTC)
previous_year_time = now - timedelta(days=420)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="CLM-RANGE-CURRENT-001",
employee_name="王明",
department_name="销售部",
expense_type="travel",
reason="本年差旅",
location="北京",
amount=Decimal("1000.00"),
invoice_count=1,
occurred_at=now - timedelta(days=5),
submitted_at=now - timedelta(days=5),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=5),
updated_at=now - timedelta(days=4),
),
ExpenseClaim(
claim_no="CLM-RANGE-CURRENT-002",
employee_name="赵琳",
department_name="销售部",
expense_type="meal",
reason="本年招待",
location="上海",
amount=Decimal("500.00"),
invoice_count=1,
occurred_at=now - timedelta(days=8),
submitted_at=now - timedelta(days=8),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=8),
updated_at=now - timedelta(days=7),
),
ExpenseClaim(
claim_no="CLM-RANGE-OLD-001",
employee_name="钱远",
department_name="销售部",
expense_type="office",
reason="历史办公",
location="广州",
amount=Decimal("9000.00"),
invoice_count=1,
occurred_at=previous_year_time,
submitted_at=previous_year_time,
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=previous_year_time,
updated_at=previous_year_time,
),
]
)
db.commit()
year_dashboard = FinanceDashboardService(db).build_dashboard(
range_key="近10日",
trend_range="近7天",
department_range="本年",
)
all_dashboard = FinanceDashboardService(db).build_dashboard(
range_key="近10日",
trend_range="近7天",
department_range="全部",
)
assert year_dashboard.department_ranking[0]["amount"] == 1500.0
assert year_dashboard.department_ranking[0]["employeeCount"] == 2
assert "CLM-RANGE-OLD-001" not in str(year_dashboard.top_claims)
assert {item["employee"] for item in year_dashboard.department_employee_mix} == {
"王明",
"赵琳",
}
assert all_dashboard.department_ranking[0]["amount"] == 10500.0
assert all_dashboard.department_ranking[0]["employeeCount"] == 3
assert all_dashboard.top_claims[0]["claimNo"] == "CLM-RANGE-OLD-001"
assert all_dashboard.department_employee_mix[0]["employee"] == "钱远"
def test_finance_dashboard_snapshot_service_persists_digital_employee_snapshot() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="CLM-SNAPSHOT-001",
employee_name="snapshot-user",
department_name="Finance",
expense_type="travel",
reason="snapshot test",
location="Shanghai",
amount=Decimal("880.00"),
invoice_count=1,
occurred_at=now - timedelta(hours=1),
submitted_at=now - timedelta(minutes=50),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(hours=1),
updated_at=now - timedelta(minutes=40),
)
)
db.commit()
service = FinanceDashboardSnapshotService(db)
first = service.build_dashboard(
range_key="近30日",
trend_range="近12天",
department_range="本月",
)
second = service.build_dashboard(
range_key="近30日",
trend_range="近12天",
department_range="本月",
)
runs = [
run
for run in db.query(AgentRun).filter(AgentRun.agent == "hermes").all()
if (run.route_json or {}).get("task_type") == "finance_dashboard_snapshot"
]
assert first.totals["reimbursementCount"] == 1
assert second.generated_at == first.generated_at
assert len(runs) == 1
assert runs[0].status == "succeeded"
assert runs[0].route_json["task_type"] == "finance_dashboard_snapshot"
assert runs[0].route_json["snapshot_payload"]["totals"]["reimbursementAmount"] == 880.0

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings
from app.db.base import Base
from app.models.agent_run import AgentRun
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.digital_employee_finance_report_task import (
FINANCE_REPORT_TASK_TYPE,
DigitalEmployeeFinanceReportTaskService,
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_finance_report_task_generates_pdf_and_agent_record(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path))
get_settings.cache_clear()
now = datetime.now(UTC)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="RE-REPORT-001",
employee_name="林嘉宁",
department_name="市场部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("3600.00"),
invoice_count=2,
occurred_at=now - timedelta(days=2),
submitted_at=now - timedelta(days=2),
status="paid",
approval_stage="已付款",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=2),
updated_at=now - timedelta(days=1),
)
)
db.add(
RiskObservation(
observation_key="risk-report-001",
subject_type="expense_claim",
subject_key="RE-REPORT-001",
subject_label="RE-REPORT-001",
claim_no="RE-REPORT-001",
risk_type="policy",
risk_signal="amount_outlier",
title="金额异常",
risk_level="high",
status="pending_review",
created_at=now - timedelta(days=1),
updated_at=now - timedelta(days=1),
)
)
db.commit()
result = DigitalEmployeeFinanceReportTaskService(db).generate_report(
report_type="weekly",
send_email=True,
dry_run_email=True,
)
pdf_path = tmp_path / result["pdf"]["storage_key"]
html_path = pdf_path.with_name("report.html")
runs = [
run
for run in db.query(AgentRun).filter(AgentRun.agent == "hermes").all()
if (run.route_json or {}).get("task_type") == FINANCE_REPORT_TASK_TYPE
]
assert pdf_path.exists()
assert pdf_path.read_bytes().startswith(b"%PDF")
assert html_path.exists()
assert result["delivery"]["status"] in {"dry_run", "pending_configuration"}
assert result["summary"]["reimbursement_count"] >= 1
assert runs
assert runs[0].status == "succeeded"
assert runs[0].route_json["report_delivery"]["pdf"]["storage_key"].endswith("report.pdf")
get_settings.cache_clear()

View File

@@ -12,6 +12,8 @@ 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.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.employee_profile_scan_task import EmployeeProfileScanTaskService
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
@@ -35,6 +37,24 @@ def test_hermes_employee_profile_scan_returns_profile_baseline_summary() -> None
)
def test_employee_profile_scan_task_records_digital_employee_run() -> None:
session_factory = _build_session_factory()
with session_factory() as db:
_seed_scan_data(db)
result = EmployeeProfileScanTaskService(db).refresh_profiles()
summary = result["summary"]
assert result["task_type"] == "employee_behavior_profile_scan"
assert summary["target_employee_count"] == 3
assert summary["snapshot_count"] >= 12
assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 12
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["profileSnapshots"] >= 12
assert dashboard.task_distribution[0]["taskType"] == "employee_behavior_profile_scan"
def _build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",

View File

@@ -693,6 +693,92 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
assert second_response.draft_payload is None
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
session_factory = build_session_factory()
with session_factory() as db:
claim = ExpenseClaim(
id="application-edit-1",
claim_no="AP-20260220-EDIT",
employee_name="pytest",
department_name="技术部",
expense_type="travel_application",
reason="旧事由",
location="上海",
amount=Decimal("1000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="returned",
approval_stage="待提交",
risk_flags_json=[
{
"source": "manual_return",
"event_type": "expense_application_return",
"message": "请修改事由",
},
{
"source": "application_detail",
"application_detail": {
"reason": "旧事由",
"time": "2026-02-20 至 2026-02-23",
},
},
],
)
db.add(claim)
db.commit()
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={
"manager_name": "向万红",
"application_edit_mode": True,
"application_edit_claim_id": claim.id,
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "支撑国网仿生产环境建设",
"days": "4天",
"transportMode": "火车",
"amount": "4660元",
"grade": "P5",
"department": "技术部",
"position": "财务智能化产品经理",
"managerName": "向万红",
}
},
},
)
db.refresh(claim)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "申请单据已修改并重新提交" in response.answer
assert response.draft_payload is not None
assert response.draft_payload.claim_id == claim.id
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.reason == "支撑国网仿生产环境建设"
assert claim.location == "上海市"
assert claim.amount == Decimal("4660.00")
assert claim.occurred_at.date().isoformat() == "2026-02-20"
flags = list(claim.risk_flags_json or [])
assert any(flag.get("event_type") == "expense_application_return" for flag in flags)
assert any(flag.get("event_type") == "expense_application_submission" for flag in flags)
detail_flags = [
flag.get("application_detail")
for flag in flags
if isinstance(flag, dict) and flag.get("source") == "application_detail"
]
assert len(detail_flags) == 1
assert detail_flags[0]["reason"] == "支撑国网仿生产环境建设"
assert detail_flags[0]["transport_mode"] == "火车"
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory()
with session_factory() as db: