feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
176
server/tests/test_digital_employee_reminder_task.py
Normal file
176
server/tests/test_digital_employee_reminder_task.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
99
server/tests/test_finance_report_task.py
Normal file
99
server/tests/test_finance_report_task.py
Normal 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()
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user