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

@@ -90,6 +90,12 @@ DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE = "task.hermes.finance_dashboard_snapshot"
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE = "task.hermes.digital_employee_reminder_scan"
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE = "task.hermes.finance_report_orchestration"
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE = "task.hermes.global_risk_scan"
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile_scan"
@@ -102,7 +108,9 @@ DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignm
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize"
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate"
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = (
"task.hermes.department_expense_baseline_accumulate"
)
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate"
@@ -132,6 +140,9 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE: "升级",
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",

View File

@@ -16,11 +16,14 @@ from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE,
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
@@ -30,7 +33,6 @@ from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
)
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
@@ -40,6 +42,65 @@ DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
class AgentFoundationDigitalEmployeeTaskMixin:
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
return (
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE,
name="财务经营快照沉淀",
description="按固定周期统计报销金额、费用结构、预算占用、高额单据和个人费用排行,刷新财务看板缓存。",
scenario_json=["schedule", "finance_dashboard", "expense", "budget"],
owner="财务运营组",
cron="0 2 * * *",
skill_category="整理",
skill_name="finance-dashboard-snapshot-analyst",
output_format="finance_dashboard_snapshot",
input_sources=[
"expense_claims",
"expense_items",
"budget_snapshots",
"employee_profiles",
],
execution_strategy="scheduled_dashboard_cache",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
name="定时提醒与待办扫描",
description="按计划扫描待审批单据、预算编制周期、差旅申请到期和逾期报销,生成可触达的提醒事项。",
scenario_json=["schedule", "reminder", "approval", "budget", "travel"],
owner="财务运营组",
cron="0 2 * * *",
skill_category="升级",
skill_name="digital-employee-reminder-scanner",
output_format="digital_employee_reminder_report",
input_sources=[
"expense_claims",
"approval_tasks",
"budgets",
"travel_applications",
],
execution_strategy="scheduled_reminder_scan",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE,
name="财务报告编排与邮件投递",
description=(
"按周、季、年整合费用、预算、风险、画像和提醒结果,"
"生成图文 PDF 报告并按邮箱设置投递给财务管理人员。"
),
scenario_json=["schedule", "finance_report", "pdf", "email", "management"],
owner="财务运营组",
cron="30 8 * * 1",
skill_category="整理",
skill_name="finance-report-orchestrator",
output_format="finance_report_pdf_delivery",
input_sources=[
"finance_dashboard_snapshots",
"budget_snapshots",
"risk_observations",
"employee_profiles",
"digital_employee_reminders",
"system_mail_settings",
],
execution_strategy="scheduled_pdf_email_report",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
name="制度条款结构化抽取",
@@ -134,7 +195,10 @@ class AgentFoundationDigitalEmployeeTaskMixin:
{
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
"name": "财务风险图谱巡检",
"description": "按计划扫描报销单、票据、审批链、员工画像和规则命中结果,生成风险观察与可复核证据链。",
"description": (
"按计划扫描报销单、票据、审批链、员工画像和规则命中结果,"
"生成风险观察与可复核证据链。"
),
"scenario_json": ["schedule", "expense", "risk_graph", "risk_observation"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
@@ -167,7 +231,10 @@ class AgentFoundationDigitalEmployeeTaskMixin:
{
"code": DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
"name": "员工行为画像巡检",
"description": "按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,为风险图谱提供画像基线。",
"description": (
"按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,"
"为风险图谱提供画像基线。"
),
"scenario_json": ["schedule", "employee_profile", "baseline", "risk_graph"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
@@ -219,7 +286,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="评估",
skill_name="travel-spatiotemporal-consistency-evaluator",
output_format="spatiotemporal_consistency_report",
input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"],
input_sources=[
"expense_claims",
"expense_items",
"invoice_locations",
"travel_routes",
],
execution_strategy="reuse_financial_risk_graph_scan",
),
self._digital_employee_task_spec(
@@ -232,7 +304,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="评估",
skill_name="budget-overrun-precontrol-evaluator",
output_format="budget_precontrol_warning_report",
input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"],
input_sources=[
"expense_claims",
"budget_snapshots",
"policy_refs",
"profile_baselines",
],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
@@ -245,13 +322,21 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="评估",
skill_name="supplier-abnormal-relation-evaluator",
output_format="supplier_abnormal_relation_report",
input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"],
input_sources=[
"risk_graph",
"expense_claims",
"invoice_entities",
"entity_registry",
],
execution_strategy="reuse_financial_risk_graph_scan",
),
{
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
"name": "风险线索归集",
"description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。",
"description": (
"按计划复盘申请、报销、规则命中和人工反馈,"
"归集带事实依据的潜在线索,提交人工复核,不生成规则。"
),
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
@@ -291,7 +376,11 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="升级",
skill_name="risk-algorithm-replay-evaluator",
output_format="algorithm_replay_evaluation_report",
input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"],
input_sources=[
"algorithm_replay_sets",
"risk_observations",
"risk_observation_feedback",
],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
@@ -304,7 +393,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="升级",
skill_name="policy-reference-gap-hinter",
output_format="policy_reference_gap_hint_report",
input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"],
input_sources=[
"policy_refs",
"rule_hits",
"expense_claims",
"risk_feedback_samples",
],
execution_strategy="definition_ready",
),
)

View File

@@ -5,8 +5,15 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any
from app.services.document_numbering import (
DOCUMENT_NUMBER_TOKEN_ALPHABET,
DOCUMENT_NUMBER_TOKEN_LENGTH,
build_document_number,
)
SIM_EMPLOYEE_PREFIX = "SIM2026"
SIM_CLAIM_PREFIX = "SIM-EXP-2026"
# 历史模拟数据已用这个命名空间生成 UUID这里只用于保持幂等不再作为业务单号。
SIM_CLAIM_ID_NAMESPACE = "SIM-EXP-2026"
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
@@ -31,6 +38,7 @@ BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES
class SimulationConfig:
target_employees: int = 100
start_date: date = date(2026, 1, 1)
end_date: date = date(2026, 6, 2)
months: int = 6
seed: int = 20260602
@@ -269,6 +277,25 @@ def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime:
from datetime import timedelta
base = plan.submitted_at or plan.occurred_at
claim_offset = sum(ord(char) for char in str(plan.claim_no or "")[-2:]) % 24
if plan.status in SUCCESS_STATUSES | {"rejected", "returned"}:
return base + timedelta(hours=2 + int(plan.claim_no[-2:]) % 24)
return base + timedelta(hours=2 + claim_offset)
return base + timedelta(hours=1)
def build_simulation_reimbursement_no(occurred_at: datetime, sequence: int) -> str:
return build_document_number(
"reimbursement",
timestamp=occurred_at,
token=simulation_document_token(sequence),
)
def simulation_document_token(sequence: int) -> str:
value = max(0, int(sequence))
base = len(DOCUMENT_NUMBER_TOKEN_ALPHABET)
chars: list[str] = []
for _ in range(DOCUMENT_NUMBER_TOKEN_LENGTH):
chars.append(DOCUMENT_NUMBER_TOKEN_ALPHABET[value % base])
value //= base
return "".join(reversed(chars))

View File

@@ -22,7 +22,7 @@ APPLICATION_EXPENSE_TYPES = {
}
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
RECENT_VISIBLE_CLAIM_START = 501
RECENT_VISIBLE_CLAIM_END = 950
RECENT_VISIBLE_CLAIM_END = 817
def is_admin_identity(*values: Any) -> bool:
@@ -70,10 +70,79 @@ def recent_visible_claim_day(
*,
employee_index: int,
claim_index: int,
period_end: date,
) -> date | None:
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
return None
month = months[-1]
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day)
day = min(2, max_day)
return month.replace(day=1 + ((employee_index + claim_index) % day))
def simulation_claim_day(
rng: Any,
months: list[date],
*,
employee_index: int,
local_index: int,
claim_index: int,
period_end: date,
) -> date:
visible_day = recent_visible_claim_day(
months,
employee_index=employee_index,
claim_index=claim_index,
period_end=period_end,
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day)
day = 1 + ((employee_index * 7 + local_index * 11 + rng.randint(0, 5)) % max_day)
return month.replace(day=day)
def simulation_claim_count(employee: Any, index: int) -> int:
base = 7 + (index % 5)
department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "")
grade = str(getattr(employee, "grade", "") or "")
if department_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
if grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]:
while True:
number = f"{prefix}-{cursor:04d}"
cursor += 1
if number not in used_numbers:
used_numbers.add(number)
return number, cursor
def simulation_month_starts(config: Any) -> list[date]:
current = config.start_date.replace(day=1)
months: list[date] = []
for _ in range(max(1, config.months)):
if current > config.end_date:
break
months.append(current)
year = current.year + (1 if current.month == 12 else 0)
month = 1 if current.month == 12 else current.month + 1
current = date(year, month, 1)
return months or [config.start_date.replace(day=1)]
def simulation_period_end(config: Any) -> date:
last_month = simulation_month_starts(config)[-1]
_, max_day = calendar.monthrange(last_month.year, last_month.month)
return min(last_month.replace(day=max_day), config.end_date)

View File

@@ -1,13 +1,12 @@
from __future__ import annotations
import calendar
import random
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.security import hash_password
@@ -28,7 +27,7 @@ from app.services.demo_company_simulation_catalog import (
MONTH_FACTORS,
PENDING_STATUSES,
SIM_BUDGET_PREFIX,
SIM_CLAIM_PREFIX,
SIM_CLAIM_ID_NAMESPACE,
SIM_EMPLOYEE_PREFIX,
SIM_PROJECT_CODE,
SIM_RESERVATION_PREFIX,
@@ -45,6 +44,7 @@ from app.services.demo_company_simulation_catalog import (
SimulationConfig,
SimulationSummary,
build_employee_name,
build_simulation_reimbursement_no,
claim_location,
claim_reason,
department_from_row,
@@ -57,7 +57,11 @@ from app.services.demo_company_simulation_catalog import (
)
from app.services.demo_company_simulation_filters import (
is_admin_employee_like,
recent_visible_claim_day,
next_simulation_number,
simulation_claim_count,
simulation_claim_day,
simulation_month_starts,
simulation_period_end,
)
@@ -117,7 +121,7 @@ class HalfYearExpenseSimulationSeeder:
budget_reservations_to_create=reservation_count,
risk_observations_to_create=risk_count,
period_start=self.config.start_date.isoformat(),
period_end=self._period_end().isoformat(),
period_end=simulation_period_end(self.config).isoformat(),
)
def _department_refs(self, *, apply: bool) -> list[DepartmentRef]:
@@ -275,16 +279,19 @@ class HalfYearExpenseSimulationSeeder:
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
plans: list[ClaimPlan] = []
months = self._month_starts()
months = simulation_month_starts(self.config)
period_end = simulation_period_end(self.config)
claim_index = 1
for employee_index, employee in enumerate(employees):
count = self._claim_count_for_employee(employee, employee_index)
count = simulation_claim_count(employee, employee_index)
for local_index in range(count):
occurred_day = self._claim_day(
occurred_day = simulation_claim_day(
self.rng,
months,
employee_index,
local_index,
claim_index,
employee_index=employee_index,
local_index=local_index,
claim_index=claim_index,
period_end=period_end,
)
expense_type = self._expense_type_for_employee(employee)
amount = self._claim_amount(employee, expense_type, occurred_day)
@@ -301,10 +308,10 @@ class HalfYearExpenseSimulationSeeder:
id=str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
f"x-financial:{SIM_CLAIM_PREFIX}:{claim_index}",
f"x-financial:{SIM_CLAIM_ID_NAMESPACE}:{claim_index}",
)
),
claim_no=f"{SIM_CLAIM_PREFIX}-{claim_index:04d}",
claim_no=self._simulation_claim_no(occurred_at, claim_index),
employee=employee,
expense_type=expense_type,
reason=claim_reason(
@@ -372,12 +379,25 @@ class HalfYearExpenseSimulationSeeder:
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
created_count = 0
for index, plan in enumerate(plans, start=1):
used_budget_nos = set(
self.db.scalars(
select(BudgetAllocation.budget_no).where(
BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%")
)
).all()
)
budget_no_cursor = 1
for plan in plans:
existing = self._find_sim_allocation(plan)
if existing is not None:
allocation_map[plan.key] = existing.id
continue
created_count += 1
budget_no, budget_no_cursor = next_simulation_number(
SIM_BUDGET_PREFIX,
used_budget_nos,
budget_no_cursor,
)
allocation_id = str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
@@ -390,7 +410,7 @@ class HalfYearExpenseSimulationSeeder:
self.db.add(
BudgetAllocation(
id=allocation_id,
budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}",
budget_no=budget_no,
fiscal_year=plan.key[0],
period_type="quarter",
period_key=plan.period_key,
@@ -415,15 +435,19 @@ class HalfYearExpenseSimulationSeeder:
return allocation_map, created_count
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
existing_claim_nos = set(
self.db.scalars(
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
existing_rows = list(
self.db.execute(
select(ExpenseClaim.id, ExpenseClaim.claim_no).where(
ExpenseClaim.project_code == SIM_PROJECT_CODE
)
).all()
)
existing_claim_ids = {str(row.id) for row in existing_rows}
existing_claim_nos = set(self.db.scalars(select(ExpenseClaim.claim_no)).all())
claim_count = 0
item_count = 0
for plan in plans:
if plan.claim_no in existing_claim_nos:
if plan.id in existing_claim_ids or plan.claim_no in existing_claim_nos:
continue
claim_count += 1
item_count += len(plan.items)
@@ -645,40 +669,6 @@ class HalfYearExpenseSimulationSeeder:
plan.budget_subject_code,
)
def _month_starts(self) -> list[date]:
current = self.config.start_date.replace(day=1)
months: list[date] = []
for _ in range(max(1, self.config.months)):
months.append(current)
year = current.year + (1 if current.month == 12 else 0)
month = 1 if current.month == 12 else current.month + 1
current = date(year, month, 1)
return months
def _period_end(self) -> date:
months = self._month_starts()
last_month = months[-1]
return last_month.replace(day=calendar.monthrange(last_month.year, last_month.month)[1])
def _claim_day(
self,
months: list[date],
employee_index: int,
local_index: int,
claim_index: int,
) -> date:
visible_day = recent_visible_claim_day(
months,
employee_index=employee_index,
claim_index=claim_index,
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
_, max_day = calendar.monthrange(month.year, month.month)
day = 1 + ((employee_index * 7 + local_index * 11 + self.rng.randint(0, 5)) % max_day)
return month.replace(day=day)
def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef:
weighted: list[DepartmentRef] = []
by_code = {item.unit_code: item for item in departments}
@@ -696,16 +686,6 @@ class HalfYearExpenseSimulationSeeder:
subjects = list(weights)
return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0]
def _claim_count_for_employee(self, employee: EmployeeRef, index: int) -> int:
base = 7 + (index % 5)
if employee.department.unit_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
elif employee.department.unit_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
if employee.grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
def _claim_amount(
self,
employee: EmployeeRef,
@@ -726,6 +706,10 @@ class HalfYearExpenseSimulationSeeder:
Decimal("0.01")
)
@staticmethod
def _simulation_claim_no(occurred_at: datetime, claim_index: int) -> str:
return build_simulation_reimbursement_no(occurred_at, claim_index)
def _status_for_claim(self, employee_index: int, local_index: int) -> tuple[str, str | None]:
selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100
if selector < 42:

View File

@@ -28,7 +28,9 @@ TASK_CODE_TO_TYPE = {
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
"task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate",
"task.hermes.travel_spatiotemporal_consistency_evaluate": (
"travel_spatiotemporal_consistency_evaluate"
),
"task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
@@ -136,6 +138,16 @@ TASK_SPECS: dict[str, dict[str, str]] = {
"category": "升级",
"color": "var(--chart-amber)",
},
"finance_dashboard_snapshot": {
"label": "财务看板指标快照",
"category": "积累",
"color": "var(--chart-blue)",
},
"digital_employee_reminder_scan": {
"label": "定时提醒扫描",
"category": "整理",
"color": "var(--success)",
},
}
CATEGORY_SPECS = {
@@ -203,6 +215,8 @@ class DigitalEmployeeDashboardService:
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
+ metrics["finance_snapshots"]
+ metrics["reminders"]
)
return {
@@ -216,6 +230,8 @@ class DigitalEmployeeDashboardService:
"riskClues": metrics["risk_clues"],
"profileSnapshots": metrics["profile_snapshots"],
"knowledgeDocuments": metrics["knowledge_documents"],
"financeDashboardSnapshots": metrics["finance_snapshots"],
"reminders": metrics["reminders"],
"successRate": self._percent(success_runs, total_runs),
"failureRate": self._percent(failed_runs, total_runs),
}
@@ -232,6 +248,8 @@ class DigitalEmployeeDashboardService:
"riskClues": 0,
"profileSnapshots": 0,
"knowledgeDocuments": 0,
"financeDashboardSnapshots": 0,
"reminders": 0,
"businessOutputs": 0,
}
for label in labels
@@ -254,11 +272,15 @@ class DigitalEmployeeDashboardService:
row["riskClues"] += metrics["risk_clues"]
row["profileSnapshots"] += metrics["profile_snapshots"]
row["knowledgeDocuments"] += metrics["knowledge_documents"]
row["financeDashboardSnapshots"] += metrics["finance_snapshots"]
row["reminders"] += metrics["reminders"]
row["businessOutputs"] += (
metrics["risk_observations"]
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
+ metrics["finance_snapshots"]
+ metrics["reminders"]
)
return [rows[label] for label in labels]
@@ -374,6 +396,12 @@ class DigitalEmployeeDashboardService:
summary,
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
)
if self._resolve_task_type(run) == "finance_dashboard_snapshot":
metrics["profile_snapshots"] = 0
metrics["finance_snapshots"] = self._first_int(
summary,
("finance_snapshot_count", "dashboard_snapshot_count"),
)
metrics["knowledge_documents"] = max(
self._first_int(
summary,
@@ -383,9 +411,21 @@ class DigitalEmployeeDashboardService:
self._list_length(route_json, ("document_ids", "requested_document_ids")),
)
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count"))
metrics["target_employees"] = self._first_int(
summary,
("target_employee_count", "employee_count"),
)
metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
metrics["reminders"] = self._first_int(
summary,
(
"reminder_count",
"reminders",
"approval_pending_count",
"budget_reminder_count",
),
)
return metrics
@staticmethod
@@ -394,11 +434,13 @@ class DigitalEmployeeDashboardService:
"risk_observations": 0,
"risk_clues": 0,
"profile_snapshots": 0,
"finance_snapshots": 0,
"knowledge_documents": 0,
"scanned_claims": 0,
"target_employees": 0,
"rule_hits": 0,
"facts": 0,
"reminders": 0,
}
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
@@ -413,7 +455,9 @@ class DigitalEmployeeDashboardService:
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
digital_tools = [
tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.")
tool
for tool in run.tool_calls
if str(tool.tool_name or "").startswith("digital_employee.")
]
for tool in run.tool_calls:
candidates = [
@@ -441,7 +485,10 @@ class DigitalEmployeeDashboardService:
route_json = run.route_json or {}
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
return True
return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls)
return any(
str(tool.tool_name or "").startswith("digital_employee.")
for tool in run.tool_calls
)
def _resolve_task_type(self, run: AgentRun) -> str:
route_json = run.route_json or {}
@@ -491,6 +538,8 @@ class DigitalEmployeeDashboardService:
return "global_risk_scan"
if "employee_behavior_profile" in name:
return "employee_behavior_profile_scan"
if "reminder" in name:
return "digital_employee_reminder_scan"
if "finance_policy_knowledge" in name:
return "finance_policy_knowledge_organize"
if "risk_clue" in name:

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from time import perf_counter
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.services.agent_runs import AgentRunService
from app.services.finance_report_context import FinanceReportContextService, FinanceReportType
from app.services.finance_report_mailer import FinanceReportMailer
from app.services.finance_report_renderer import FinanceReportRenderer
FINANCE_REPORT_TASK_TYPE = "finance_report_orchestration"
FINANCE_REPORT_TOOL_NAME = "digital_employee.finance_report.orchestrate"
class DigitalEmployeeFinanceReportTaskService:
def __init__(self, db: Session) -> None:
self.db = db
def generate_report(
self,
*,
report_type: FinanceReportType = "weekly",
start_date: date | None = None,
end_date: date | None = None,
recipients: list[str] | None = None,
send_email: bool = True,
dry_run_email: bool = False,
source: str = AgentRunSource.SCHEDULE.value,
run_id: str | None = None,
record_tool_call: bool = True,
) -> dict[str, Any]:
run_service = AgentRunService(self.db)
run = None
if run_id is None:
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={"scenario": "finance_report", "intent": report_type},
route_json={
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
)
run_id = run.run_id
timer = perf_counter()
try:
context = FinanceReportContextService(self.db).build_context(
report_type=report_type,
start_date=start_date,
end_date=end_date,
)
rendered = FinanceReportRenderer().render(context)
delivery = (
FinanceReportMailer(self.db).send_report(
context=context,
pdf_path=rendered.pdf_path,
recipients=recipients,
dry_run=dry_run_email,
)
if send_email
else None
)
duration_ms = int((perf_counter() - timer) * 1000)
result = self._result_payload(
context=context,
rendered=rendered,
delivery=delivery.to_dict() if delivery is not None else {"status": "skipped"},
)
if record_tool_call:
run_service.record_tool_call(
run_id=run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=FINANCE_REPORT_TOOL_NAME,
request_json={
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"send_email": send_email,
},
response_json=result,
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run_id,
{
"phase": "succeeded",
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"report_delivery": result,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=self._summary_text(result),
finished_at=datetime.now(UTC),
)
return result
except Exception as exc:
run_service.merge_route_json(
run_id,
{
"phase": "failed",
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
@staticmethod
def _result_payload(
*,
context: dict[str, Any],
rendered: Any,
delivery: dict[str, Any],
) -> dict[str, Any]:
period = context.get("period") if isinstance(context.get("period"), dict) else {}
summary = context.get("summary") if isinstance(context.get("summary"), dict) else {}
return {
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": context.get("report_type"),
"title": period.get("title"),
"period": period,
"summary": summary,
"insights": context.get("insights") or [],
"action_items": context.get("action_items") or [],
"pdf": {
"storage_key": rendered.storage_key,
"path": str(rendered.pdf_path),
"html_path": str(rendered.html_path),
"page_count": rendered.page_count,
},
"delivery": delivery,
}
@staticmethod
def _summary_text(result: dict[str, Any]) -> str:
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
delivery = result.get("delivery") if isinstance(result.get("delivery"), dict) else {}
return (
f"{result.get('title') or '财务经营报告'}已生成:"
f"{summary.get('reimbursement_count', 0)} 单,"
f"金额 {float(summary.get('reimbursement_amount') or 0):,.0f} 元,"
f"邮件状态 {delivery.get('status') or 'skipped'}"
)

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import os
import threading
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.core.agent_enums import AgentRunSource
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.digital_employee_reminder_task import DigitalEmployeeReminderTaskService
logger = get_logger("app.services.digital_employee_reminder_scheduler")
class DigitalEmployeeReminderScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
reminder_time = str(os.environ.get("X_FINANCIAL_REMINDER_SCAN_TIME") or "02:00").strip()
initial_delay = int(os.environ.get("X_FINANCIAL_REMINDER_INITIAL_DELAY_SECONDS") or "24")
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._scan_time = self._parse_scan_time(reminder_time)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="digital-employee-reminder-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Digital employee reminder scheduler started timezone=%s scan_time=%s",
self._timezone.key,
self._scan_time.strftime("%H:%M"),
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Digital employee reminder scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
self._refresh_reminders(reason="startup_warmup")
while not self._stop_event.is_set():
wait_seconds = self._seconds_until_next_scan()
if self._stop_event.wait(wait_seconds):
break
self._refresh_reminders(reason="scheduled_0200")
def _refresh_reminders(self, *, reason: str) -> None:
db = get_session_factory()()
try:
result = DigitalEmployeeReminderTaskService(db).refresh_reminders(
source=AgentRunSource.SCHEDULE.value
)
summary = result.get("summary") or {}
logger.info(
"Digital employee reminder scan generated reason=%s recipients=%s reminders=%s",
reason,
summary.get("recipient_count"),
summary.get("reminder_count"),
)
except Exception:
db.rollback()
logger.exception("Scheduled digital employee reminder scan failed")
finally:
db.close()
def _seconds_until_next_scan(self) -> float:
now = datetime.now(self._timezone)
target = datetime.combine(now.date(), self._scan_time, tzinfo=self._timezone)
if target <= now:
target = target + timedelta(days=1)
return max(1.0, (target - now).total_seconds())
@staticmethod
def _parse_scan_time(raw_value: str) -> time:
value = str(raw_value or "").strip()
try:
hour_text, minute_text = value.split(":", 1)
hour = min(max(int(hour_text), 0), 23)
minute = min(max(int(minute_text), 0), 59)
return time(hour=hour, minute=minute)
except Exception:
return time(hour=2, minute=0)
digital_employee_reminder_scheduler = DigitalEmployeeReminderScheduler()

View File

@@ -0,0 +1,547 @@
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from time import perf_counter
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.budget import BudgetAllocation
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.agent_runs import AgentRunService
DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE = "digital_employee_reminder_scan"
DIGITAL_EMPLOYEE_REMINDER_TOOL_NAME = "digital_employee.reminder.scan"
APPROVAL_PENDING_STATUSES = {"submitted", "review", "in_progress", "pending", "pending_review"}
PAYMENT_PENDING_STATUSES = {"approved", "pending_payment", "payment_pending"}
ARCHIVE_PENDING_STATUSES = {"paid", "payment_completed", "pending_archive"}
SUPPLEMENT_STATUSES = {"returned", "rejected", "supplement", "supplement_required"}
APPLICATION_ACTIVE_STATUSES = {"approved", "submitted", "review", "in_progress", "pending"}
HIGH_AMOUNT_THRESHOLD = Decimal("10000.00")
DEFAULT_WINDOW_DAYS = 14
class DigitalEmployeeReminderTaskService:
def __init__(self, db: Session) -> None:
self.db = db
def refresh_reminders(
self,
*,
source: str = AgentRunSource.SCHEDULE.value,
now: datetime | None = None,
window_days: int = DEFAULT_WINDOW_DAYS,
) -> dict[str, Any]:
run_service = AgentRunService(self.db)
started_at = now or datetime.now(UTC)
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={"scenario": "financial_reminder", "intent": "scan"},
route_json={
"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"job_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"selected_agent": AgentName.HERMES.value,
"phase": "running",
"window_days": int(window_days or DEFAULT_WINDOW_DAYS),
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
started_at=started_at,
)
timer = perf_counter()
try:
report = self.build_reminder_report(now=started_at, window_days=window_days)
summary = self._build_summary(report)
duration_ms = int((perf_counter() - timer) * 1000)
response = {
"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"summary": summary,
"report": report,
}
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=DIGITAL_EMPLOYEE_REMINDER_TOOL_NAME,
request_json={
"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"window_days": int(window_days or DEFAULT_WINDOW_DAYS),
},
response_json=response,
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run.run_id,
{
"phase": "succeeded",
"summary": summary,
"report": report,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=(
"定时提醒扫描完成:"
f"提醒 {summary['recipient_count']} 人,"
f"生成 {summary['reminder_count']} 条事项。"
),
finished_at=datetime.now(UTC),
)
return response
except Exception as exc:
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=DIGITAL_EMPLOYEE_REMINDER_TOOL_NAME,
request_json={"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE},
response_json={},
status=AgentRunStatus.FAILED.value,
duration_ms=int((perf_counter() - timer) * 1000),
error_message=str(exc),
)
run_service.merge_route_json(
run.run_id,
{"phase": "failed", "heartbeat_at": datetime.now(UTC).isoformat()},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
def build_reminder_report(
self,
*,
now: datetime | None = None,
window_days: int = DEFAULT_WINDOW_DAYS,
) -> dict[str, Any]:
scan_time = self._aware(now or datetime.now(UTC))
recipient_map: dict[str, dict[str, Any]] = {}
counters: dict[str, int] = defaultdict(int)
for reminder in [
*self._approval_pending_reminders(scan_time),
*self._budget_compilation_reminders(scan_time),
*self._travel_application_expiry_reminders(scan_time, window_days=window_days),
*self._reimbursement_overdue_reminders(scan_time, window_days=window_days),
]:
self._append_reminder(recipient_map, reminder)
counters[str(reminder["type"])] += 1
recipients = sorted(
recipient_map.values(),
key=lambda item: (-len(item["reminders"]), item["recipientName"]),
)
return {
"title": "数字员工定时提醒扫描报告",
"generatedAt": scan_time.isoformat(),
"windowDays": int(window_days or DEFAULT_WINDOW_DAYS),
"totals": {
"recipientCount": len(recipients),
"reminderCount": sum(counters.values()),
"approvalPendingCount": counters["approval_pending"],
"budgetReminderCount": counters["budget_compilation"],
"travelApplicationReminderCount": counters["travel_application_expiry"],
"reimbursementOverdueCount": counters["reimbursement_overdue"],
},
"recipients": recipients,
}
def _approval_pending_reminders(self, now: datetime) -> list[dict[str, Any]]:
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.employee).selectinload(Employee.manager))
.where(ExpenseClaim.status.in_(APPROVAL_PENDING_STATUSES))
.order_by(ExpenseClaim.submitted_at.asc().nullslast(), ExpenseClaim.updated_at.asc())
.limit(200)
)
reminders: list[dict[str, Any]] = []
for claim in self.db.scalars(stmt).all():
recipient = self._approval_recipient(claim)
wait_started_at = claim.submitted_at or claim.updated_at or claim.created_at
wait_days = self._wait_days(now, wait_started_at)
reminders.append(
self._document_reminder(
reminder_type="approval_pending",
recipient=recipient,
claim=claim,
title=f"{claim.claim_no} 待审批",
action="请在今日处理审批待办,避免影响后续付款和归档。",
wait_days=wait_days,
type_score=0.85,
)
)
return reminders
def _budget_compilation_reminders(self, now: datetime) -> list[dict[str, Any]]:
fiscal_year = now.astimezone(UTC).year
period_key = self._current_quarter_key(now)
active_statuses = {"active", "published"}
year_count = self.db.scalar(
select(func.count(BudgetAllocation.id)).where(
BudgetAllocation.fiscal_year == fiscal_year,
BudgetAllocation.status.in_(active_statuses),
)
) or 0
period_count = self.db.scalar(
select(func.count(BudgetAllocation.id)).where(
BudgetAllocation.fiscal_year == fiscal_year,
BudgetAllocation.period_key == period_key,
BudgetAllocation.status.in_(active_statuses),
)
) or 0
if year_count and period_count:
return []
recipients = self._budget_admin_recipients()
if not recipients:
recipients = [
{
"recipientId": "budget_admin",
"recipientName": "预算管理员",
"recipientRole": "budget_admin",
}
]
title = (
f"{fiscal_year} 年预算池待建立"
if not year_count
else f"{fiscal_year}{period_key} 预算池待补齐"
)
return [
{
"type": "budget_compilation",
"priority": "high" if not year_count else "medium",
"priorityScore": 0.9 if not year_count else 0.65,
"title": title,
"action": "请检查预算编制进度,补齐部门、费用类型和期间预算池。",
"recipient": recipient,
"relatedDocuments": [],
"metrics": {
"fiscalYear": fiscal_year,
"periodKey": period_key,
"activeYearAllocationCount": int(year_count),
"activePeriodAllocationCount": int(period_count),
},
}
for recipient in recipients
]
def _travel_application_expiry_reminders(
self,
now: datetime,
*,
window_days: int,
) -> list[dict[str, Any]]:
cutoff = now - timedelta(days=max(1, int(window_days or DEFAULT_WINDOW_DAYS)))
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.employee))
.where(ExpenseClaim.expense_type.like("%_application"))
.where(ExpenseClaim.status.in_(APPLICATION_ACTIVE_STATUSES))
.where(ExpenseClaim.occurred_at <= now)
.where(ExpenseClaim.occurred_at >= cutoff)
.order_by(ExpenseClaim.occurred_at.asc())
.limit(200)
)
reminders: list[dict[str, Any]] = []
for claim in self.db.scalars(stmt).all():
if not self._is_travel_application(claim):
continue
if self._has_linked_reimbursement_draft(claim):
continue
wait_days = self._wait_days(now, claim.occurred_at)
reminders.append(
self._document_reminder(
reminder_type="travel_application_expiry",
recipient=self._employee_recipient(claim),
claim=claim,
title=f"{claim.claim_no} 出差申请已到期",
action="请发起报销、延长申请或关闭未使用申请。",
wait_days=wait_days,
type_score=0.75,
)
)
return reminders
def _reimbursement_overdue_reminders(
self,
now: datetime,
*,
window_days: int,
) -> list[dict[str, Any]]:
cutoff = now - timedelta(days=max(1, int(window_days or DEFAULT_WINDOW_DAYS)))
statuses = PAYMENT_PENDING_STATUSES | ARCHIVE_PENDING_STATUSES | SUPPLEMENT_STATUSES
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.employee))
.where(ExpenseClaim.status.in_(statuses))
.where(ExpenseClaim.updated_at >= cutoff)
.order_by(ExpenseClaim.updated_at.asc())
.limit(200)
)
reminders: list[dict[str, Any]] = []
for claim in self.db.scalars(stmt).all():
if self._is_application_claim(claim):
continue
status = str(claim.status or "").strip()
recipient = self._finance_recipient(claim)
action = "请检查付款或归档处理进度。"
title = f"{claim.claim_no} 报销流程待处理"
if status in SUPPLEMENT_STATUSES:
recipient = self._employee_recipient(claim)
title = f"{claim.claim_no} 待补充材料"
action = "请补齐材料后重新提交,减少财务反复沟通。"
elif status in PAYMENT_PENDING_STATUSES:
title = f"{claim.claim_no} 待付款"
action = "请确认付款排期,避免已审批单据长期停留。"
elif status in ARCHIVE_PENDING_STATUSES:
title = f"{claim.claim_no} 待归档"
action = "请完成归档,保证单据闭环和后续审计可追踪。"
wait_started_at = claim.updated_at or claim.submitted_at or claim.created_at
wait_days = self._wait_days(now, wait_started_at)
reminders.append(
self._document_reminder(
reminder_type="reimbursement_overdue",
recipient=recipient,
claim=claim,
title=title,
action=action,
wait_days=wait_days,
type_score=0.7,
)
)
return reminders
def _document_reminder(
self,
*,
reminder_type: str,
recipient: dict[str, str],
claim: ExpenseClaim,
title: str,
action: str,
wait_days: int,
type_score: float,
) -> dict[str, Any]:
amount = Decimal(claim.amount or 0)
priority_score = self._priority_score(
wait_days=wait_days,
amount=amount,
type_score=type_score,
)
return {
"type": reminder_type,
"priority": self._priority(priority_score),
"priorityScore": round(priority_score, 4),
"title": title,
"action": action,
"recipient": recipient,
"relatedDocuments": [
{
"documentId": claim.id,
"documentNo": claim.claim_no,
"employeeName": claim.employee_name,
"departmentName": claim.department_name,
"expenseType": claim.expense_type,
"status": claim.status,
"approvalStage": claim.approval_stage,
"amount": float(amount),
"waitDays": wait_days,
}
],
"metrics": {
"amount": float(amount),
"waitDays": wait_days,
},
}
@staticmethod
def _append_reminder(
recipient_map: dict[str, dict[str, Any]],
reminder: dict[str, Any],
) -> None:
recipient = dict(reminder.pop("recipient"))
recipient_id = str(
recipient.get("recipientId") or recipient.get("recipientName") or "unknown"
)
row = recipient_map.setdefault(
recipient_id,
{
"recipientId": recipient_id,
"recipientName": str(recipient.get("recipientName") or recipient_id),
"recipientRole": str(recipient.get("recipientRole") or "unknown"),
"reminders": [],
},
)
row["reminders"].append(reminder)
def _approval_recipient(self, claim: ExpenseClaim) -> dict[str, str]:
employee = claim.employee
if employee is not None and employee.manager is not None:
return {
"recipientId": employee.manager.id,
"recipientName": employee.manager.name,
"recipientRole": "manager",
}
return self._finance_recipient(claim)
@staticmethod
def _employee_recipient(claim: ExpenseClaim) -> dict[str, str]:
if claim.employee is not None:
return {
"recipientId": claim.employee.id,
"recipientName": claim.employee.name,
"recipientRole": "employee",
}
return {
"recipientId": str(claim.employee_id or claim.employee_name or "employee"),
"recipientName": str(claim.employee_name or "员工"),
"recipientRole": "employee",
}
@staticmethod
def _finance_recipient(claim: ExpenseClaim) -> dict[str, str]:
employee = claim.employee
owner = ""
if employee is not None:
owner = str(employee.finance_owner_name or "").strip()
return {
"recipientId": owner or "finance_operator",
"recipientName": owner or "财务经办人",
"recipientRole": "finance",
}
def _budget_admin_recipients(self) -> list[dict[str, str]]:
stmt = (
select(Employee)
.options(selectinload(Employee.roles))
.join(Employee.roles)
.where(Role.role_code.in_(("budget_monitor", "executive")))
.order_by(Employee.name.asc())
.limit(20)
)
recipients = []
seen: set[str] = set()
for employee in self.db.scalars(stmt).all():
if employee.id in seen:
continue
seen.add(employee.id)
recipients.append(
{
"recipientId": employee.id,
"recipientName": employee.name,
"recipientRole": "budget_admin",
}
)
return recipients
@staticmethod
def _is_travel_application(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
if expense_type == "travel_application":
return True
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
detail = flag.get("application_detail") or flag.get("applicationDetail") or {}
if isinstance(detail, dict) and "差旅" in str(detail.get("application_type") or ""):
return True
return False
@staticmethod
def _is_application_claim(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
if expense_type in {"application", "expense_application"} or expense_type.endswith(
"_application"
):
return True
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("business_stage") or "").strip() == "expense_application":
return True
if isinstance(flag.get("application_detail"), dict):
return True
return False
def _has_linked_reimbursement_draft(self, application_claim: ExpenseClaim) -> bool:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if flag.get("generated_draft_claim_id") or flag.get("generated_draft_claim_no"):
return True
stmt = (
select(ExpenseClaim)
.where(ExpenseClaim.expense_type.not_like("%_application"))
.order_by(ExpenseClaim.created_at.desc())
.limit(300)
)
for claim in self.db.scalars(stmt).all():
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("application_claim_id") or "") == application_claim.id:
return True
return False
@staticmethod
def _priority_score(*, wait_days: int, amount: Decimal, type_score: float) -> float:
wait_score = min(max(wait_days, 0) / 3, 1)
amount_score = min(float(max(amount, Decimal("0.00")) / HIGH_AMOUNT_THRESHOLD), 1)
return 0.45 * wait_score + 0.35 * amount_score + 0.20 * type_score
@staticmethod
def _priority(score: float) -> str:
if score >= 0.75:
return "high"
if score >= 0.45:
return "medium"
return "low"
@classmethod
def _wait_days(cls, now: datetime, started_at: datetime | None) -> int:
if started_at is None:
return 0
delta = cls._aware(now) - cls._aware(started_at)
return max(0, int(delta.total_seconds() // 86400))
@staticmethod
def _aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
@staticmethod
def _current_quarter_key(now: datetime) -> str:
month = now.month
quarter = ((month - 1) // 3) + 1
return f"Q{quarter}"
@staticmethod
def _build_summary(report: dict[str, Any]) -> dict[str, Any]:
totals = report.get("totals") if isinstance(report, dict) else {}
totals = totals if isinstance(totals, dict) else {}
return {
"recipient_count": int(totals.get("recipientCount") or 0),
"reminder_count": int(totals.get("reminderCount") or 0),
"approval_pending_count": int(totals.get("approvalPendingCount") or 0),
"budget_reminder_count": int(totals.get("budgetReminderCount") or 0),
"travel_application_reminder_count": int(
totals.get("travelApplicationReminderCount") or 0
),
"reimbursement_overdue_count": int(totals.get("reimbursementOverdueCount") or 0),
}

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import UTC, datetime
from time import perf_counter
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.services.agent_runs import AgentRunService
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
EMPLOYEE_PROFILE_SCAN_TASK_TYPE = "employee_behavior_profile_scan"
EMPLOYEE_PROFILE_SCAN_TOOL_NAME = "digital_employee.employee_behavior_profile.scan"
class EmployeeProfileScanTaskService:
def __init__(self, db: Session) -> None:
self.db = db
def refresh_profiles(self, *, source: str = AgentRunSource.SCHEDULE.value) -> dict[str, Any]:
run_service = AgentRunService(self.db)
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={
"scenario": "employee_behavior_profile",
"intent": "scan",
},
route_json={
"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE,
"job_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE,
"selected_agent": AgentName.HERMES.value,
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
)
timer = perf_counter()
try:
# 画像快照表的 source_task_log_id 外键指向 Hermes 任务日志。
# 这里用 agent_runs 记录数字员工轨迹,因此不写入该外键,避免错误关联。
summary = HermesEmployeeProfileScannerService(self.db).scan_employee_profiles(
log_id=None
)
duration_ms = int((perf_counter() - timer) * 1000)
report = self._build_report(summary)
response = {
"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE,
"summary": summary,
"report": report,
}
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=EMPLOYEE_PROFILE_SCAN_TOOL_NAME,
request_json={"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE},
response_json=response,
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run.run_id,
{
"phase": "succeeded",
"summary": summary,
"report": report,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=(
"员工行为画像已生成:"
f"覆盖 {summary.get('target_employee_count', 0)} 人,"
f"快照 {summary.get('snapshot_count', 0)} 条,"
f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。"
),
finished_at=datetime.now(UTC),
)
return response
except Exception as exc:
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=EMPLOYEE_PROFILE_SCAN_TOOL_NAME,
request_json={"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE},
response_json={},
status=AgentRunStatus.FAILED.value,
duration_ms=int((perf_counter() - timer) * 1000),
error_message=str(exc),
)
run_service.merge_route_json(
run.run_id,
{
"phase": "failed",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
@staticmethod
def _build_report(summary: dict[str, Any]) -> dict[str, Any]:
return {
"title": "员工财务行为画像扫描报告",
"targetEmployeeCount": int(summary.get("target_employee_count") or 0),
"profileSnapshotCount": int(summary.get("snapshot_count") or 0),
"highAttentionEmployeeCount": int(
summary.get("high_attention_employee_count") or 0
),
"windowDays": list(summary.get("window_days") or []),
"algorithmVersion": str(summary.get("algorithm_version") or ""),
"baselineSummary": summary.get("baseline_summary") or {},
}

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
import os
import threading
from datetime import datetime
from zoneinfo import ZoneInfo
from app.core.agent_enums import AgentRunSource
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.employee_profile_scan_task import EmployeeProfileScanTaskService
logger = get_logger("app.services.employee_profile_scheduler")
class EmployeeProfileScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
interval = int(os.environ.get("X_FINANCIAL_EMPLOYEE_PROFILE_INTERVAL_SECONDS") or "1800")
initial_delay = int(
os.environ.get("X_FINANCIAL_EMPLOYEE_PROFILE_INITIAL_DELAY_SECONDS") or "18"
)
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._interval_seconds = max(300, interval)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="employee-profile-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Employee profile scheduler started timezone=%s interval=%ss",
self._timezone.key,
self._interval_seconds,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Employee profile scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._refresh_profiles()
except Exception: # pragma: no cover - scheduler best effort logging
logger.exception("Scheduled employee profile scan failed")
if self._stop_event.wait(self._interval_seconds):
break
def _refresh_profiles(self) -> None:
db = get_session_factory()()
try:
result = EmployeeProfileScanTaskService(db).refresh_profiles(
source=AgentRunSource.SCHEDULE.value
)
summary = result.get("summary") or {}
logger.info(
"Employee profile scan generated at=%s employees=%s snapshots=%s attention=%s",
datetime.now(self._timezone).isoformat(),
summary.get("target_employee_count"),
summary.get("snapshot_count"),
summary.get("high_attention_employee_count"),
)
except Exception:
db.rollback()
raise
finally:
db.close()
employee_profile_scheduler = EmployeeProfileScheduler()

View File

@@ -245,7 +245,7 @@ class ExpenseClaimReadModelMixin:
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
raise ValueError("只有草稿、待补充或退回待提交状态的单才允许执行该操作。")
@staticmethod
def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None:

View File

@@ -562,9 +562,6 @@ class ExpenseClaimService(
if claim is None:
return None
if self._is_expense_application_claim(claim) and not current_user.is_admin:
raise ValueError("申请单只有系统管理员可以删除。")
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
@@ -756,6 +753,5 @@ class ExpenseClaimService(

View File

@@ -9,75 +9,23 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation
from app.models.financial_record import ExpenseClaim
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.budget_support import BudgetSupportMixin
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
SLA_TARGET_HOURS = Decimal("8.0")
PENDING_STATUSES = {
"submitted",
"review",
"pending_review",
"manager_review",
"budget_review",
"finance_review",
"approving",
}
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
"var(--chart-amber)",
"var(--chart-purple)",
"var(--success)",
"var(--danger)",
]
STAGE_LABELS = {
"manager": "直属经理",
"manager_review": "直属经理",
"budget": "预算复核",
"budget_review": "预算复核",
"finance": "财务审核",
"finance_review": "财务审核",
"payment": "付款确认",
"pending_payment": "付款确认",
}
RISK_SIGNAL_LABELS = {
"duplicate_invoice": "重复发票",
"split_billing": "拆分报销",
"frequent_small_claims": "高频小额",
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}
from app.services.finance_dashboard_constants import (
CHART_COLORS,
EMPTY_DONUT,
EXCLUDED_SPEND_STATUSES,
EXPENSE_TYPE_ALIASES,
PENDING_STATUSES,
RISK_SIGNAL_LABELS,
SLA_TARGET_HOURS,
STAGE_LABELS,
SUCCESS_STATUSES,
)
class FinanceDashboardService(BudgetSupportMixin):
@@ -93,7 +41,6 @@ class FinanceDashboardService(BudgetSupportMixin):
trend_range: str = "近12天",
department_range: str = "本月",
) -> FinanceDashboardRead:
self._ensure_storage_ready()
now = datetime.now(UTC)
start, end, resolved_key = self._resolve_scope(
range_key=range_key,
@@ -103,7 +50,7 @@ class FinanceDashboardService(BudgetSupportMixin):
)
previous_start = start - (end - start)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
department_start, department_end = self._resolve_department_scope(department_range, now)
ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now)
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
@@ -111,7 +58,7 @@ class FinanceDashboardService(BudgetSupportMixin):
scope_claims = self._claims_between(claims, start, end)
previous_claims = self._claims_between(claims, previous_start, start)
trend_claims = self._claims_between(claims, trend_start, trend_end)
department_claims = self._claims_between(claims, department_start, department_end)
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
totals = self._totals(scope_claims)
previous_totals = self._totals(previous_claims)
@@ -127,17 +74,15 @@ class FinanceDashboardService(BudgetSupportMixin):
trend=self._trend(trend_labels, trend_claims, now),
spend_by_category=self._spend_by_category(scope_claims),
exception_mix=self._payment_status_mix(scope_claims),
department_ranking=self._department_ranking(department_claims),
employee_ranking=self._employee_ranking(department_claims),
top_claims=self._top_claims(department_claims),
department_ranking=self._department_ranking(ranking_claims),
department_employee_mix=self._department_employee_mix(ranking_claims),
employee_ranking=self._employee_ranking(ranking_claims),
top_claims=self._top_claims(ranking_claims),
bottlenecks=self._bottlenecks(scope_claims),
budget_summary=self._budget_summary(now.year),
budget_metrics=self._budget_metrics(now.year),
)
def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
def _fetch_claims(self) -> list[ExpenseClaim]:
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
return list(self.db.scalars(stmt).all())
@@ -189,18 +134,20 @@ class FinanceDashboardService(BudgetSupportMixin):
labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)]
return self._day_start(start_day), self._day_after(end_day), labels
def _resolve_department_scope(
def _resolve_ranking_scope(
self,
department_range: str,
now: datetime,
) -> tuple[datetime, datetime]:
today = now.date()
key = str(department_range or "").strip()
if key == "本周":
start_day = today - timedelta(days=today.weekday())
elif key == "本季度":
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
quarter_month = ((today.month - 1) // 3) * 3 + 1
start_day = today.replace(month=quarter_month, day=1)
elif key == "本年":
start_day = today.replace(month=1, day=1)
else:
start_day = today.replace(day=1)
return self._day_start(start_day), self._day_after(today)
@@ -347,6 +294,7 @@ class FinanceDashboardService(BudgetSupportMixin):
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
employees: dict[str, set[str]] = defaultdict(set)
for claim in claims:
status = self._status(claim)
if status in EXCLUDED_SPEND_STATUSES:
@@ -357,6 +305,9 @@ class FinanceDashboardService(BudgetSupportMixin):
amount = self._claim_amount(claim)
buckets[department_name] += amount
counts[department_name] += 1
employee_name = str(claim.employee_name or "").strip()
if not self._is_missing_finance_dimension(employee_name):
employees[department_name].add(employee_name)
if status in PENDING_STATUSES:
pending_amounts[department_name] += amount
@@ -366,6 +317,7 @@ class FinanceDashboardService(BudgetSupportMixin):
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"employeeCount": len(employees[name]),
"pendingAmount": self._decimal_number(pending_amounts[name]),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
@@ -375,6 +327,34 @@ class FinanceDashboardService(BudgetSupportMixin):
]
return rows
def _department_employee_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[tuple[str, str], Decimal] = defaultdict(Decimal)
for claim in claims:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
department_name = str(claim.department_name or "").strip()
employee_name = str(claim.employee_name or "").strip()
if self._is_missing_finance_dimension(department_name):
continue
if self._is_missing_finance_dimension(employee_name):
continue
buckets[(department_name, employee_name)] += self._claim_amount(claim)
rows = [
{
"name": f"{department_name} · {employee_name}",
"department": department_name,
"employee": employee_name,
"value": self._decimal_number(amount),
"amount": self._decimal_number(amount),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, ((department_name, employee_name), amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from decimal import Decimal
SLA_TARGET_HOURS = Decimal("8.0")
PENDING_STATUSES = {
"submitted",
"review",
"pending_review",
"manager_review",
"budget_review",
"finance_review",
"approving",
}
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
"var(--chart-amber)",
"var(--chart-purple)",
"var(--success)",
"var(--danger)",
]
STAGE_LABELS = {
"manager": "直属经理",
"manager_review": "直属经理",
"budget": "预算复核",
"budget_review": "预算复核",
"finance": "财务审核",
"finance_review": "财务审核",
"payment": "付款确认",
"pending_payment": "付款确认",
}
RISK_SIGNAL_LABELS = {
"duplicate_invoice": "重复发票",
"split_billing": "拆分报销",
"frequent_small_claims": "高频小额",
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import os
import threading
from datetime import datetime
from zoneinfo import ZoneInfo
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
logger = get_logger("app.services.finance_dashboard_scheduler")
class FinanceDashboardScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
interval = int(os.environ.get("X_FINANCIAL_FINANCE_DASHBOARD_INTERVAL_SECONDS") or "120")
initial_delay = int(
os.environ.get("X_FINANCIAL_FINANCE_DASHBOARD_INITIAL_DELAY_SECONDS") or "6"
)
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._interval_seconds = max(30, interval)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="finance-dashboard-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Finance dashboard scheduler started timezone=%s interval=%ss",
self._timezone.key,
self._interval_seconds,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Finance dashboard scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._refresh_snapshot()
except Exception: # pragma: no cover - scheduler best effort logging
logger.exception("Scheduled finance dashboard snapshot failed")
if self._stop_event.wait(self._interval_seconds):
break
def _refresh_snapshot(self) -> None:
db = get_session_factory()()
try:
dashboard = FinanceDashboardSnapshotService(db).refresh_default_snapshot()
db.commit()
totals = dashboard.totals or {}
logger.info(
"Finance dashboard snapshot generated at=%s count=%s amount=%s",
datetime.now(self._timezone).isoformat(),
totals.get("reimbursementCount"),
totals.get("reimbursementAmount"),
)
except Exception:
db.rollback()
raise
finally:
db.close()
finance_dashboard_scheduler = FinanceDashboardScheduler()

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from time import perf_counter
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_run import AgentRun
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.agent_runs import AgentRunService
from app.services.finance_dashboard import FinanceDashboardService
FINANCE_DASHBOARD_TASK_TYPE = "finance_dashboard_snapshot"
FINANCE_DASHBOARD_TOOL_NAME = "digital_employee.finance_dashboard.snapshot"
SNAPSHOT_TTL_SECONDS = 120
SNAPSHOT_SCHEMA_VERSION = "finance-dashboard-ranking-v2"
class FinanceDashboardSnapshotService:
def __init__(self, db: Session) -> None:
self.db = db
def build_dashboard(
self,
*,
range_key: str = "近30日",
start_date: Any = None,
end_date: Any = None,
trend_range: str = "近12天",
department_range: str = "本月",
) -> FinanceDashboardRead:
key = self._cache_key(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
)
snapshot = self._latest_fresh_snapshot(key)
if snapshot is not None:
return snapshot
return self.refresh_snapshot(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
source=AgentRunSource.SYSTEM_EVENT.value,
)
def refresh_default_snapshot(self) -> FinanceDashboardRead:
return self.refresh_snapshot(
range_key="近30日",
trend_range="近12天",
department_range="本月",
source=AgentRunSource.SCHEDULE.value,
)
def refresh_snapshot(
self,
*,
range_key: str,
start_date: Any = None,
end_date: Any = None,
trend_range: str,
department_range: str,
source: str,
) -> FinanceDashboardRead:
key = self._cache_key(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
)
run_service = AgentRunService(self.db)
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={
"scenario": "finance_dashboard",
"intent": "snapshot",
},
route_json={
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"job_type": FINANCE_DASHBOARD_TASK_TYPE,
"selected_agent": AgentName.HERMES.value,
"snapshot_key": key,
"params": {
"range_key": range_key,
"start_date": self._date_text(start_date),
"end_date": self._date_text(end_date),
"trend_range": trend_range,
"department_range": department_range,
},
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
)
timer = perf_counter()
try:
dashboard = FinanceDashboardService(self.db).build_dashboard(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
)
duration_ms = int((perf_counter() - timer) * 1000)
payload = dashboard.model_dump(mode="json")
summary = self._summary(payload)
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=FINANCE_DASHBOARD_TOOL_NAME,
request_json={
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"snapshot_key": key,
},
response_json={
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"summary": summary,
"payload": payload,
},
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run.run_id,
{
"phase": "succeeded",
"snapshot_key": key,
"snapshot_payload": payload,
"summary": summary,
"expires_at": (
datetime.now(UTC) + timedelta(seconds=SNAPSHOT_TTL_SECONDS)
).isoformat(),
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=(
f"财务看板指标快照已生成:{summary['reimbursement_count']} 单,"
f"金额 {summary['reimbursement_amount']:.2f} 元。"
),
finished_at=datetime.now(UTC),
)
return dashboard
except Exception as exc:
run_service.merge_route_json(
run.run_id,
{
"phase": "failed",
"snapshot_key": key,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
def _latest_fresh_snapshot(self, key: str) -> FinanceDashboardRead | None:
now = datetime.now(UTC)
for run in self._recent_snapshot_runs():
route_json = run.route_json or {}
if str(route_json.get("snapshot_key") or "") != key:
continue
payload = route_json.get("snapshot_payload")
if not isinstance(payload, dict):
payload = self._payload_from_tool_call(run)
if not isinstance(payload, dict):
continue
expires_at = self._parse_datetime(route_json.get("expires_at"))
if expires_at is None or expires_at <= now:
continue
return FinanceDashboardRead.model_validate(payload)
return None
def _recent_snapshot_runs(self) -> list[AgentRun]:
stmt = (
select(AgentRun)
.options(selectinload(AgentRun.tool_calls))
.where(
AgentRun.agent == AgentName.HERMES.value,
AgentRun.status == AgentRunStatus.SUCCEEDED.value,
)
.order_by(AgentRun.started_at.desc())
.limit(80)
)
runs = list(self.db.scalars(stmt).all())
return [
run
for run in runs
if str((run.route_json or {}).get("task_type") or "") == FINANCE_DASHBOARD_TASK_TYPE
]
@staticmethod
def _payload_from_tool_call(run: AgentRun) -> dict[str, Any] | None:
for tool_call in run.tool_calls:
if tool_call.tool_name != FINANCE_DASHBOARD_TOOL_NAME:
continue
payload = (tool_call.response_json or {}).get("payload")
return payload if isinstance(payload, dict) else None
return None
@staticmethod
def _summary(payload: dict[str, Any]) -> dict[str, Any]:
totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
return {
"finance_snapshot_count": 1,
"reimbursement_count": int(totals.get("reimbursementCount") or 0),
"reimbursement_amount": float(totals.get("reimbursementAmount") or 0),
"pending_payment_amount": float(totals.get("pendingPaymentAmount") or 0),
"budget_metric_count": len(payload.get("budget_metrics") or []),
"department_count": len(payload.get("department_ranking") or []),
}
@classmethod
def _cache_key(
cls,
*,
range_key: str,
start_date: Any,
end_date: Any,
trend_range: str,
department_range: str,
) -> str:
return "|".join(
[
SNAPSHOT_SCHEMA_VERSION,
str(range_key or ""),
cls._date_text(start_date),
cls._date_text(end_date),
str(trend_range or ""),
str(department_range or ""),
]
)
@staticmethod
def _date_text(value: Any) -> str:
if value is None:
return ""
if hasattr(value, "isoformat"):
return str(value.isoformat())
return str(value or "")
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
normalized = str(value or "").strip()
if not normalized:
return None
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)

View File

@@ -0,0 +1,319 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any, Literal
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.agent_run import AgentRun
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.risk_observation import RiskObservation
from app.services.finance_dashboard import FinanceDashboardService
FinanceReportType = Literal["weekly", "quarterly", "annual"]
@dataclass(frozen=True, slots=True)
class FinanceReportPeriod:
report_type: FinanceReportType
start_date: date
end_date: date
label: str
title: str
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
payload["start_date"] = self.start_date.isoformat()
payload["end_date"] = self.end_date.isoformat()
return payload
class FinanceReportContextService:
def __init__(self, db: Session) -> None:
self.db = db
def build_context(
self,
*,
report_type: FinanceReportType = "weekly",
start_date: date | None = None,
end_date: date | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
generated_at = now or datetime.now(UTC)
period = self.resolve_period(
report_type=report_type,
start_date=start_date,
end_date=end_date,
now=generated_at,
)
dashboard = FinanceDashboardService(self.db).build_dashboard(
range_key="自定义",
start_date=period.start_date,
end_date=period.end_date,
trend_range="近12天" if report_type == "weekly" else "近30天",
department_range="本季度" if report_type != "weekly" else "本月",
)
dashboard_payload = dashboard.model_dump(mode="json")
risk_summary = self._risk_summary(period)
profile_summary = self._profile_summary(period)
digital_employee_summary = self._digital_employee_summary(period)
actions = self._action_items(dashboard_payload, risk_summary)
insights = self._insights(dashboard_payload, risk_summary, profile_summary, actions)
return {
"report_type": report_type,
"period": period.to_dict(),
"generated_at": generated_at.isoformat(),
"dashboard": dashboard_payload,
"risk_summary": risk_summary,
"profile_summary": profile_summary,
"digital_employee_summary": digital_employee_summary,
"insights": insights,
"action_items": actions,
"summary": self._summary(dashboard_payload, risk_summary, actions),
}
@staticmethod
def resolve_period(
*,
report_type: FinanceReportType,
start_date: date | None,
end_date: date | None,
now: datetime,
) -> FinanceReportPeriod:
if start_date and end_date:
begin = min(start_date, end_date)
finish = max(start_date, end_date)
return FinanceReportPeriod(
report_type=report_type,
start_date=begin,
end_date=finish,
label=f"{begin.isoformat()}{finish.isoformat()}",
title=_report_title(report_type),
)
local_now = now.astimezone(ZoneInfo("Asia/Shanghai")) if now.tzinfo else now
today = local_now.date()
if report_type == "annual":
year = today.year - 1
begin = date(year, 1, 1)
finish = date(year, 12, 31)
label = f"{year}"
elif report_type == "quarterly":
current_quarter = (today.month - 1) // 3 + 1
year = today.year
quarter = current_quarter - 1
if quarter <= 0:
quarter = 4
year -= 1
month = (quarter - 1) * 3 + 1
begin = date(year, month, 1)
finish = _month_end(year, month + 2)
label = f"{year} 年 Q{quarter}"
else:
current_week_start = today - timedelta(days=today.weekday())
begin = current_week_start - timedelta(days=7)
finish = current_week_start - timedelta(days=1)
label = f"{begin.isoformat()}{finish.isoformat()}"
return FinanceReportPeriod(
report_type=report_type,
start_date=begin,
end_date=finish,
label=label,
title=_report_title(report_type),
)
def _risk_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
start_dt = _day_start(period.start_date)
end_dt = _day_after(period.end_date)
rows = list(
self.db.scalars(
select(RiskObservation).where(
RiskObservation.created_at >= start_dt,
RiskObservation.created_at < end_dt,
)
).all()
)
high_rows = [row for row in rows if str(row.risk_level or "").lower() == "high"]
pending_rows = [
row for row in rows if str(row.status or "").lower() in {"pending_review", "open"}
]
top_signals: dict[str, int] = {}
for row in rows:
label = str(row.title or row.risk_signal or "风险观察").strip()
top_signals[label] = top_signals.get(label, 0) + 1
return {
"risk_count": len(rows),
"high_risk_count": len(high_rows),
"pending_review_count": len(pending_rows),
"top_signals": [
{"name": name, "count": count}
for name, count in sorted(
top_signals.items(),
key=lambda item: item[1],
reverse=True,
)[:5]
],
}
def _profile_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
start_dt = _day_start(period.start_date)
end_dt = _day_after(period.end_date)
rows = list(
self.db.scalars(
select(EmployeeBehaviorProfileSnapshot).where(
EmployeeBehaviorProfileSnapshot.calculated_at >= start_dt,
EmployeeBehaviorProfileSnapshot.calculated_at < end_dt,
)
).all()
)
attention_rows = [
row
for row in rows
if str(row.profile_level or "").lower() in {"attention", "high", "warning"}
or int(row.profile_score or 0) >= 80
]
return {
"snapshot_count": len(rows),
"attention_profile_count": len(attention_rows),
"top_profiles": [
{
"name": row.subject_name,
"department": row.department_name or "",
"score": row.profile_score,
"level": row.profile_level,
}
for row in sorted(
rows,
key=lambda item: int(item.profile_score or 0),
reverse=True,
)[:5]
],
}
def _digital_employee_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
start_dt = _day_start(period.start_date)
end_dt = _day_after(period.end_date)
rows = list(
self.db.scalars(
select(AgentRun).where(
AgentRun.agent == "hermes",
AgentRun.started_at >= start_dt,
AgentRun.started_at < end_dt,
)
).all()
)
succeeded = [row for row in rows if row.status == "succeeded"]
reports = [
row
for row in rows
if "finance_report" in str((row.route_json or {}).get("task_type") or "")
]
return {
"run_count": len(rows),
"succeeded_count": len(succeeded),
"report_count": len(reports),
}
def _action_items(
self,
dashboard: dict[str, Any],
risk: dict[str, Any],
) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
for item in list(dashboard.get("bottlenecks") or [])[:4]:
name = str(item.get("name") or "财务关注项").strip()
tone = str(item.get("tone") or "neutral").strip()
if tone in {"warning", "danger"}:
actions.append(
{
"title": name,
"owner": str(item.get("role") or "财务运营组"),
"priority": "high" if tone == "danger" else "medium",
"suggestion": (
f"请跟进{name}"
f"{item.get('duration') or ''} {item.get('status') or ''}"
).strip(),
}
)
if int(risk.get("pending_review_count") or 0) > 0:
actions.append(
{
"title": "风险观察待复核",
"owner": "风控与审计部",
"priority": "high",
"suggestion": f"当前有 {risk['pending_review_count']} 条风险观察待复核。",
}
)
return actions[:6]
def _insights(
self,
dashboard: dict[str, Any],
risk: dict[str, Any],
profile: dict[str, Any],
actions: list[dict[str, Any]],
) -> list[str]:
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
amount = float(totals.get("reimbursementAmount") or 0)
count = int(totals.get("reimbursementCount") or 0)
budget_rate = float(totals.get("budgetUsageRate") or 0)
insights = [
f"本周期报销 {count} 单,费用金额 {_money(amount)}",
f"预算使用率 {budget_rate:.1f}%,需关注预算预警和预占释放。",
(
f"风险观察 {risk.get('risk_count', 0)} 条,"
f"其中高风险 {risk.get('high_risk_count', 0)} 条。"
),
]
if int(profile.get("attention_profile_count") or 0) > 0:
insights.append(f"员工画像中有 {profile['attention_profile_count']} 个高关注样本。")
if actions:
insights.append(f"数字员工整理出 {len(actions)} 项管理动作,建议纳入本周跟进。")
return insights[:5]
@staticmethod
def _summary(
dashboard: dict[str, Any],
risk: dict[str, Any],
actions: list[dict[str, Any]],
) -> dict[str, Any]:
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
return {
"reimbursement_count": int(totals.get("reimbursementCount") or 0),
"reimbursement_amount": float(totals.get("reimbursementAmount") or 0),
"pending_payment_amount": float(totals.get("pendingPaymentAmount") or 0),
"risk_count": int(risk.get("risk_count") or 0),
"action_count": len(actions),
}
def _report_title(report_type: str) -> str:
return {
"weekly": "财务经营周报",
"quarterly": "财务经营季报",
"annual": "财务经营年报",
}.get(report_type, "财务经营报告")
def _month_end(year: int, month: int) -> date:
next_month = date(year + (month // 12), (month % 12) + 1, 1)
return next_month - timedelta(days=1)
def _day_start(value: date) -> datetime:
return datetime.combine(value, datetime.min.time(), tzinfo=UTC)
def _day_after(value: date) -> datetime:
return datetime.combine(value + timedelta(days=1), datetime.min.time(), tzinfo=UTC)
def _money(value: float | Decimal) -> str:
return f"¥{float(value):,.0f}"

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import smtplib
from dataclasses import asdict, dataclass
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.core.secret_box import decrypt_secret
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.services.settings import SettingsService
@dataclass(frozen=True, slots=True)
class FinanceReportDeliveryResult:
status: str
recipients: list[str]
subject: str
message: str
smtp_host: str
attachment_name: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class FinanceReportMailer:
def __init__(self, db: Session) -> None:
self.db = db
def send_report(
self,
*,
context: dict[str, Any],
pdf_path: Path,
recipients: list[str] | None = None,
dry_run: bool = False,
) -> FinanceReportDeliveryResult:
settings_row, secrets_row = SettingsService(self.db).ensure_settings_ready()
resolved_recipients = self._resolve_recipients(settings_row, recipients)
subject = self._subject(context)
missing = self._missing_config(settings_row, secrets_row, resolved_recipients)
if missing:
return FinanceReportDeliveryResult(
status="pending_configuration",
recipients=resolved_recipients,
subject=subject,
message=f"邮件未发送,缺少配置:{', '.join(missing)}",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
if dry_run:
return FinanceReportDeliveryResult(
status="dry_run",
recipients=resolved_recipients,
subject=subject,
message="邮件 dry-run 完成,未连接 SMTP。",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
password = self._decrypt_password(secrets_row)
message = self._message(
settings_row=settings_row,
context=context,
pdf_path=pdf_path,
recipients=resolved_recipients,
subject=subject,
)
try:
self._send(settings_row, message, password)
except Exception as exc:
return FinanceReportDeliveryResult(
status="failed",
recipients=resolved_recipients,
subject=subject,
message=f"邮件发送失败:{exc}",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
return FinanceReportDeliveryResult(
status="sent",
recipients=resolved_recipients,
subject=subject,
message="邮件已发送。",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
def _message(
self,
*,
settings_row: SystemSetting,
context: dict[str, Any],
pdf_path: Path,
recipients: list[str],
subject: str,
) -> EmailMessage:
sender_address = str(
settings_row.sender_address or settings_row.smtp_username or ""
).strip()
sender_name = str(
settings_row.sender_name or settings_row.company_name or "X-Financial"
).strip()
summary = context.get("summary") if isinstance(context.get("summary"), dict) else {}
insights = list(context.get("insights") or [])[:3]
body = "\n".join(
[
"各位好,",
"",
"数字员工已生成本期财务经营报告,摘要如下:",
*[f"- {item}" for item in insights],
"",
f"报销单数:{summary.get('reimbursement_count', 0)}",
f"报销金额:¥{float(summary.get('reimbursement_amount') or 0):,.0f}",
f"行动项:{summary.get('action_count', 0)}",
"",
"详细内容请查看附件 PDF。",
]
)
message = EmailMessage()
message["Subject"] = subject
message["From"] = f"{sender_name} <{sender_address}>"
message["To"] = ", ".join(recipients)
message.set_content(body)
message.add_attachment(
pdf_path.read_bytes(),
maintype="application",
subtype="pdf",
filename=pdf_path.name,
)
return message
@staticmethod
def _resolve_recipients(
settings_row: SystemSetting,
override_recipients: list[str] | None,
) -> list[str]:
raw_values = override_recipients or [
str(settings_row.default_receiver or ""),
str(settings_row.notice_email or ""),
str(settings_row.admin_email or ""),
]
recipients: list[str] = []
for raw in raw_values:
for item in str(raw or "").replace(";", ",").split(","):
email = item.strip()
if email and "@" in email and email not in recipients:
recipients.append(email)
return recipients
@staticmethod
def _missing_config(
settings_row: SystemSetting,
secrets_row: SystemSettingSecret,
recipients: list[str],
) -> list[str]:
missing: list[str] = []
if not str(settings_row.smtp_host or "").strip():
missing.append("smtp_host")
if not int(settings_row.smtp_port or 0):
missing.append("smtp_port")
if not str(settings_row.sender_address or settings_row.smtp_username or "").strip():
missing.append("sender_address")
if not str(settings_row.smtp_username or "").strip():
missing.append("smtp_username")
if not str(secrets_row.smtp_password_encrypted or "").strip():
missing.append("smtp_password")
if not recipients:
missing.append("recipients")
return missing
@staticmethod
def _decrypt_password(secrets_row: SystemSettingSecret) -> str:
encrypted = str(secrets_row.smtp_password_encrypted or "").strip()
return decrypt_secret(encrypted) if encrypted else ""
@staticmethod
def _subject(context: dict[str, Any]) -> str:
period = context.get("period") if isinstance(context.get("period"), dict) else {}
title = str(period.get("title") or "财务经营报告")
label = str(period.get("label") or "").strip()
return f"X-Financial {title} | {label}".strip()
@staticmethod
def _send(settings_row: SystemSetting, message: EmailMessage, password: str) -> None:
host = str(settings_row.smtp_host or "").strip()
port = int(settings_row.smtp_port or 465)
username = str(settings_row.smtp_username or "").strip()
encryption = str(settings_row.smtp_encryption or "").strip().lower()
if "ssl" in encryption:
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
smtp.login(username, password)
smtp.send_message(message)
return
with smtplib.SMTP(host, port, timeout=20) as smtp:
if "tls" in encryption or "starttls" in encryption:
smtp.starttls()
smtp.login(username, password)
smtp.send_message(message)

View File

@@ -0,0 +1,397 @@
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.core.config import get_settings
@dataclass(frozen=True, slots=True)
class RenderedFinanceReport:
html_path: Path
pdf_path: Path
storage_key: str
title: str
page_count: int
class FinanceReportRenderer:
def render(self, context: dict[str, Any]) -> RenderedFinanceReport:
report_dir = self._report_dir(context)
report_dir.mkdir(parents=True, exist_ok=True)
title = str((context.get("period") or {}).get("title") or "财务经营报告")
html_text = self.render_html(context)
html_path = report_dir / "report.html"
pdf_path = report_dir / "report.pdf"
html_path.write_text(html_text, encoding="utf-8")
page_count = SimpleFinancePdfWriter().write(pdf_path, context)
return RenderedFinanceReport(
html_path=html_path,
pdf_path=pdf_path,
storage_key=self._storage_key(pdf_path),
title=title,
page_count=page_count,
)
def render_html(self, context: dict[str, Any]) -> str:
period = context.get("period") or {}
dashboard = context.get("dashboard") or {}
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
trend = dashboard.get("trend") if isinstance(dashboard.get("trend"), dict) else {}
departments = list(dashboard.get("department_ranking") or [])
top_claims = list(dashboard.get("top_claims") or [])
actions = list(context.get("action_items") or [])
insights = list(context.get("insights") or [])
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>{_e(period.get("title"))}</title>
<style>
body {{
margin: 0;
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f7f9fc;
}}
.page {{ width: 980px; margin: 0 auto; padding: 36px 42px; background: #fff; }}
.cover {{ border-bottom: 3px solid #2f6fed; padding-bottom: 24px; }}
h1 {{ margin: 0 0 10px; font-size: 30px; color: #172554; }}
h2 {{ margin: 28px 0 14px; font-size: 20px; color: #1e3a8a; }}
.muted {{ color: #64748b; }}
.grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }}
.metric {{ border: 1px solid #dbe4f0; border-radius: 6px; padding: 14px; background: #f8fbff; }}
.metric .label {{ color: #64748b; font-size: 13px; }}
.metric .value {{ margin-top: 8px; font-size: 24px; font-weight: 700; color: #0f172a; }}
.insight {{
border-left: 4px solid #2f6fed;
padding: 10px 14px;
background: #f8fbff;
margin: 8px 0;
}}
.bar-row {{ display: flex; align-items: center; gap: 10px; margin: 8px 0; }}
.bar-label {{ width: 120px; color: #475569; }}
.bar-track {{ flex: 1; height: 12px; background: #e2e8f0; border-radius: 0; overflow: hidden; }}
.bar-fill {{ height: 100%; background: #2f6fed; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 8px; }}
th, td {{ padding: 10px 8px; border-bottom: 1px solid #e5edf7; text-align: left; }}
th {{ color: #475569; background: #f8fbff; }}
</style>
</head>
<body>
<main class="page">
<section class="cover">
<h1>{_e(period.get("title"))}</h1>
<div class="muted">
周期:{_e(period.get("label"))} 生成时间:{_e(context.get("generated_at"))}
</div>
</section>
<h2>管理摘要</h2>
{''.join(f'<div class="insight">{_e(item)}</div>' for item in insights)}
<h2>关键指标</h2>
<section class="grid">
{_metric_html("报销金额", _money(totals.get("reimbursementAmount")))}
{_metric_html("报销单数", f'{int(totals.get("reimbursementCount") or 0)}')}
{_metric_html("待付款", _money(totals.get("pendingPaymentAmount")))}
{_metric_html("预算使用率", f'{float(totals.get("budgetUsageRate") or 0):.1f}%')}
</section>
<h2>每日报销趋势</h2>
{_trend_html(trend)}
<h2>部门费用排行</h2>
{_ranking_html(departments, "amount")}
<h2>高额单据</h2>
{_top_claims_html(top_claims)}
<h2>行动清单</h2>
{_actions_html(actions)}
</main>
</body>
</html>"""
def _report_dir(self, context: dict[str, Any]) -> Path:
settings = get_settings()
period = context.get("period") or {}
report_type = str(context.get("report_type") or "weekly")
label = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff_-]+", "_", str(period.get("label") or "latest"))
return settings.resolved_storage_root_dir / "finance_reports" / report_type / label
@staticmethod
def _storage_key(pdf_path: Path) -> str:
root = get_settings().resolved_storage_root_dir.resolve()
return pdf_path.resolve().relative_to(root).as_posix()
class SimpleFinancePdfWriter:
width = 595
height = 842
margin = 48
def write(self, path: Path, context: dict[str, Any]) -> int:
pages = self._build_pages(context)
objects: list[bytes] = []
page_ids: list[int] = []
font_id = 3
for page in pages:
content = self._content_stream(page)
content_id = len(objects) + 4
page_id = len(objects) + 5
objects.append(
f"<< /Length {len(content)} >>\nstream\n".encode("latin-1")
+ content
+ b"\nendstream"
)
objects.append(
(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.width} {self.height}] "
f"/Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>"
).encode("latin-1")
)
page_ids.append(page_id)
catalog = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{page_id} 0 R" for page_id in page_ids)
pages_obj = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode("latin-1")
font_obj = (
b"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light "
b"/Encoding /UniGB-UCS2-H /DescendantFonts ["
b"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light "
b"/CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 5 >> >>] >>"
)
all_objects = [catalog, pages_obj, font_obj, *objects]
self._write_pdf(path, all_objects)
return len(pages)
def _build_pages(self, context: dict[str, Any]) -> list[list[dict[str, Any]]]:
period = context.get("period") or {}
dashboard = context.get("dashboard") or {}
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
trend = dashboard.get("trend") if isinstance(dashboard.get("trend"), dict) else {}
departments = list(dashboard.get("department_ranking") or [])
actions = list(context.get("action_items") or [])
insights = list(context.get("insights") or [])
pages: list[list[dict[str, Any]]] = []
pages.append(
[
{"type": "title", "text": str(period.get("title") or "财务经营报告")},
{"type": "text", "text": f"报告周期:{period.get('label') or ''}"},
{"type": "text", "text": f"生成时间:{context.get('generated_at') or ''}"},
{"type": "heading", "text": "管理摘要"},
*[{"type": "bullet", "text": str(item)} for item in insights],
{"type": "heading", "text": "关键指标"},
{
"type": "metrics",
"items": [
("报销金额", _money(totals.get("reimbursementAmount"))),
("报销单数", f"{int(totals.get('reimbursementCount') or 0)}"),
("待付款", _money(totals.get("pendingPaymentAmount"))),
("预算使用率", f"{float(totals.get('budgetUsageRate') or 0):.1f}%"),
],
},
]
)
pages.append(
[
{"type": "heading", "text": "每日报销趋势"},
{
"type": "bars",
"labels": trend.get("labels") or [],
"values": trend.get("claimAmount") or [],
},
{"type": "heading", "text": "部门费用排行"},
{
"type": "bars",
"labels": [str(item.get("name") or "") for item in departments[:8]],
"values": [float(item.get("amount") or 0) for item in departments[:8]],
},
]
)
pages.append(
[
{"type": "heading", "text": "行动清单"},
*[
{
"type": "bullet",
"text": (
f"{item.get('title')} / {item.get('owner')}"
f"{item.get('suggestion')}"
),
}
for item in actions
],
]
)
return pages
def _content_stream(self, blocks: list[dict[str, Any]]) -> bytes:
commands: list[str] = ["q", "1 1 1 rg 0 0 595 842 re f"]
y = self.height - self.margin
for block in blocks:
block_type = block["type"]
if block_type == "title":
commands.extend(self._text(block["text"], self.margin, y, 24, "0.05 0.15 0.35"))
y -= 42
elif block_type == "heading":
y -= 8
commands.extend(self._text(block["text"], self.margin, y, 15, "0.10 0.25 0.55"))
y -= 26
elif block_type == "text":
commands.extend(self._text(block["text"], self.margin, y, 10, "0.25 0.30 0.38"))
y -= 18
elif block_type == "bullet":
lines = self._wrap(str(block["text"]), 34)
for line in lines:
commands.extend(self._text(f"{line}", self.margin, y, 10, "0.12 0.16 0.22"))
y -= 17
elif block_type == "metrics":
y = self._metrics(commands, block["items"], y)
elif block_type == "bars":
y = self._bars(commands, block.get("labels") or [], block.get("values") or [], y)
commands.append("Q")
return "\n".join(commands).encode("latin-1")
def _metrics(self, commands: list[str], items: list[tuple[str, str]], y: int) -> int:
box_w = 122
for index, (label, value) in enumerate(items):
x = self.margin + index * (box_w + 8)
commands.append("0.95 0.97 1.00 rg")
commands.append(f"{x} {y - 48} {box_w} 46 re f")
commands.extend(self._text(label, x + 8, y - 18, 8, "0.35 0.42 0.50"))
commands.extend(self._text(value, x + 8, y - 36, 13, "0.05 0.15 0.35"))
return y - 68
def _bars(self, commands: list[str], labels: list[Any], values: list[Any], y: int) -> int:
pairs = [
(str(label), float(value or 0))
for label, value in zip(labels, values, strict=False)
]
max_value = max([value for _label, value in pairs] or [1])
for label, value in pairs[:10]:
width = 310 * (value / max_value) if max_value else 0
commands.extend(self._text(_trim(label, 14), self.margin, y, 9, "0.25 0.30 0.38"))
commands.append("0.88 0.92 0.96 rg")
commands.append(f"{self.margin + 90} {y - 4} 320 8 re f")
commands.append("0.18 0.44 0.93 rg")
commands.append(f"{self.margin + 90} {y - 4} {width:.1f} 8 re f")
commands.extend(self._text(_money(value), self.margin + 420, y, 8, "0.25 0.30 0.38"))
y -= 22
return y - 8
@staticmethod
def _text(text: Any, x: int | float, y: int | float, size: int, color: str) -> list[str]:
return [
f"{color} rg",
"BT",
f"/F1 {size} Tf",
f"{x:.1f} {y:.1f} Td",
f"<{_pdf_hex(str(text))}> Tj",
"ET",
]
@staticmethod
def _wrap(text: str, length: int) -> list[str]:
value = str(text or "").strip()
return [value[index : index + length] for index in range(0, len(value), length)] or [""]
@staticmethod
def _write_pdf(path: Path, objects: list[bytes]) -> None:
offsets: list[int] = []
payload = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
for index, obj in enumerate(objects, start=1):
offsets.append(len(payload))
payload.extend(f"{index} 0 obj\n".encode("latin-1"))
payload.extend(obj)
payload.extend(b"\nendobj\n")
xref_at = len(payload)
payload.extend(f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("latin-1"))
for offset in offsets:
payload.extend(f"{offset:010d} 00000 n \n".encode("latin-1"))
payload.extend(
(
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{xref_at}\n%%EOF"
).encode("latin-1")
)
path.write_bytes(bytes(payload))
def _metric_html(label: str, value: str) -> str:
return (
f'<div class="metric"><div class="label">{_e(label)}</div>'
f'<div class="value">{_e(value)}</div></div>'
)
def _trend_html(trend: dict[str, Any]) -> str:
labels = list(trend.get("labels") or [])
values = [float(value or 0) for value in list(trend.get("claimAmount") or [])]
return _bar_html(labels, values)
def _ranking_html(rows: list[dict[str, Any]], value_key: str) -> str:
labels = [str(item.get("name") or "") for item in rows[:8]]
values = [float(item.get(value_key) or 0) for item in rows[:8]]
return _bar_html(labels, values)
def _bar_html(labels: list[Any], values: list[float]) -> str:
max_value = max(values or [1])
rows = []
for label, value in zip(labels, values, strict=False):
width = 100 * value / max_value if max_value else 0
rows.append(
'<div class="bar-row">'
f'<div class="bar-label">{_e(label)}</div>'
f'<div class="bar-track"><div class="bar-fill" style="width:{width:.1f}%"></div></div>'
f'<div>{_e(_money(value))}</div>'
"</div>"
)
return "".join(rows) or '<div class="muted">暂无数据</div>'
def _top_claims_html(rows: list[dict[str, Any]]) -> str:
body = "".join(
"<tr>"
f"<td>{_e(item.get('claimNo'))}</td>"
f"<td>{_e(item.get('employeeName'))}</td>"
f"<td>{_e(item.get('departmentName'))}</td>"
f"<td>{_e(item.get('amountLabel') or _money(item.get('amount')))}</td>"
"</tr>"
for item in rows[:6]
)
return (
"<table><thead><tr><th>单号</th><th>员工</th><th>部门</th><th>金额</th>"
f"</tr></thead><tbody>{body}</tbody></table>"
)
def _actions_html(rows: list[dict[str, Any]]) -> str:
if not rows:
return '<div class="muted">暂无需要升级的行动项。</div>'
return "".join(
(
f'<div class="insight"><strong>{_e(item.get("title"))}</strong>'
f'{_e(item.get("owner"))}<br>{_e(item.get("suggestion"))}</div>'
)
for item in rows
)
def _e(value: Any) -> str:
return html.escape(str(value or ""))
def _money(value: Any) -> str:
try:
return f"¥{float(value or 0):,.0f}"
except (TypeError, ValueError):
return "¥0"
def _trim(value: str, max_len: int) -> str:
return value if len(value) <= max_len else value[: max_len - 1] + ""
def _pdf_hex(value: str) -> str:
return value.encode("utf-16-be").hex().upper()

View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import os
import threading
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy import select
from app.core.agent_enums import AgentRunSource, AgentRunStatus
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.models.agent_run import AgentRun
from app.services.digital_employee_finance_report_task import (
FINANCE_REPORT_TASK_TYPE,
DigitalEmployeeFinanceReportTaskService,
)
logger = get_logger("app.services.finance_report_scheduler")
class FinanceReportScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
report_time = str(os.environ.get("X_FINANCIAL_FINANCE_REPORT_TIME") or "08:30").strip()
initial_delay = int(
os.environ.get("X_FINANCIAL_FINANCE_REPORT_INITIAL_DELAY_SECONDS") or "36"
)
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._report_time = self._parse_time(report_time)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="finance-report-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Finance report scheduler started timezone=%s report_time=%s",
self._timezone.key,
self._report_time.strftime("%H:%M"),
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Finance report scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
wait_seconds = self._seconds_until_next_report_time()
if self._stop_event.wait(wait_seconds):
break
self._run_due_reports()
def _run_due_reports(self) -> None:
now = datetime.now(self._timezone)
due_types = ["weekly"]
if now.day <= 7 and now.month in {1, 4, 7, 10}:
due_types.append("quarterly")
if now.day <= 7 and now.month == 1:
due_types.append("annual")
for report_type in due_types:
self._run_report_once(report_type=report_type, now=now)
def _run_report_once(self, *, report_type: str, now: datetime) -> None:
db = get_session_factory()()
try:
if self._already_generated(db, report_type=report_type, now=now):
return
result = DigitalEmployeeFinanceReportTaskService(db).generate_report(
report_type=report_type, # type: ignore[arg-type]
source=AgentRunSource.SCHEDULE.value,
)
db.commit()
logger.info(
"Finance report generated type=%s status=%s",
report_type,
(result.get("delivery") or {}).get("status"),
)
except Exception:
db.rollback()
logger.exception("Scheduled finance report failed type=%s", report_type)
finally:
db.close()
def _already_generated(self, db, *, report_type: str, now: datetime) -> bool:
day_start = datetime.combine(
now.date(),
time.min,
tzinfo=self._timezone,
).astimezone(ZoneInfo("UTC"))
day_end = day_start + timedelta(days=1)
stmt = (
select(AgentRun)
.where(AgentRun.started_at >= day_start)
.where(AgentRun.started_at < day_end)
.where(AgentRun.status == AgentRunStatus.SUCCEEDED.value)
)
for run in db.scalars(stmt).all():
route_json = run.route_json or {}
if (
str(route_json.get("task_type") or "") == FINANCE_REPORT_TASK_TYPE
and str(route_json.get("report_type") or "") == report_type
):
return True
return False
def _seconds_until_next_report_time(self) -> float:
now = datetime.now(self._timezone)
target = datetime.combine(now.date(), self._report_time, tzinfo=self._timezone)
if target <= now:
target += timedelta(days=1)
return max(1.0, (target - now).total_seconds())
@staticmethod
def _parse_time(raw_value: str) -> time:
try:
hour_text, minute_text = str(raw_value or "").split(":", 1)
return time(
hour=max(0, min(int(hour_text), 23)),
minute=max(0, min(int(minute_text), 59)),
)
except Exception:
return time(hour=8, minute=30)
finance_report_scheduler = FinanceReportScheduler()

View File

@@ -12,6 +12,9 @@ from app.schemas.agent_asset import AgentAssetListItem, AgentAssetRead
from app.schemas.ontology import OntologyParseResult
from app.schemas.orchestrator import OrchestratorRequest
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
from app.services.digital_employee_finance_report_task import (
DigitalEmployeeFinanceReportTaskService,
)
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
from app.services.hermes_risk_scanner import HermesRiskScannerService
@@ -388,6 +391,11 @@ class OrchestratorExecutionEngine:
)
if task_type == "risk_clue_collect":
return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json)
if task_type == "finance_report_orchestration":
return self._execute_finance_report_orchestration(
run_id=run_id,
context_json=context_json,
)
return None
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome:
@@ -542,6 +550,56 @@ class OrchestratorExecutionEngine:
failed_tool_count=1 if degraded else 0,
)
def _execute_finance_report_orchestration(
self,
*,
run_id: str,
context_json: dict[str, Any],
) -> ExecutionOutcome:
report_type = str(context_json.get("report_type") or "weekly").strip().lower()
if report_type not in {"weekly", "quarterly", "annual"}:
report_type = "weekly"
summary, degraded = self._invoke_tool(
run_id=run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name="digital_employee.finance_report.orchestrate",
request_json={
"task_type": "finance_report_orchestration",
"report_type": report_type,
},
context_json=context_json,
executor=lambda: DigitalEmployeeFinanceReportTaskService(self.db).generate_report(
report_type=report_type, # type: ignore[arg-type]
send_email=bool(context_json.get("send_email", True)),
dry_run_email=bool(context_json.get("dry_run_email", False)),
source=AgentRunSource.SCHEDULE.value,
run_id=run_id,
record_tool_call=False,
),
fallback_factory=lambda exc: {
"message": f"财务报告生成失败,已保留失败记录:{exc}",
"degraded": True,
},
)
message = (
str(summary.get("message") or "").strip()
or "财务报告编排完成:"
f"{summary.get('title', '财务经营报告')}"
f"邮件状态 {(summary.get('delivery') or {}).get('status', 'skipped')}"
)
return ExecutionOutcome(
status=AgentRunStatus.SUCCEEDED.value,
result={
"message": message,
"report_type": "finance_report_orchestration",
"summary": summary,
"degraded": degraded,
},
degraded=degraded,
tool_count=1,
failed_tool_count=1 if degraded else 0,
)
@staticmethod
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
if task_asset is None:

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from app.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft
from app.db.base import Base
@@ -326,6 +326,7 @@ class RiskObservationService:
since = datetime.now(UTC) - timedelta(days=window_days)
stmt = (
select(RiskObservation)
.options(joinedload(RiskObservation.claim))
.where(RiskObservation.created_at >= since)
.order_by(RiskObservation.created_at.desc())
.limit(limit)

View File

@@ -178,12 +178,17 @@ class UserAgentApplicationMixin:
step = self._resolve_expense_application_step(payload, facts)
application_claim = None
if step == "submitted":
application_claim = self._find_duplicate_expense_application_record(payload, facts)
if application_claim is not None:
step = "duplicate"
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
editable_claim = self._find_editable_expense_application_record(payload)
if editable_claim is not None:
application_claim = self._update_expense_application_record(payload, facts, editable_claim)
facts["application_edit_mode"] = "true"
else:
application_claim = self._create_expense_application_record(payload, facts)
application_claim = self._find_duplicate_expense_application_record(payload, facts)
if application_claim is not None:
step = "duplicate"
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
else:
application_claim = self._create_expense_application_record(payload, facts)
facts["application_no"] = application_claim.claim_no
facts["application_claim_id"] = application_claim.id
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
@@ -229,9 +234,14 @@ class UserAgentApplicationMixin:
if step == "submitted":
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
submitted_title = (
"申请单据已修改并重新提交,已进入审批流程。"
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
else "申请单据已生成,并已进入审批流程。"
)
return "\n\n".join(
[
"申请单据已生成,并已进入审批流程。",
submitted_title,
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
f"申请单号:{application_no}",
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
@@ -930,6 +940,101 @@ class UserAgentApplicationMixin:
return "会务费用申请"
return "差旅费用申请"
@staticmethod
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
if not isinstance(context_json, dict):
return ""
is_edit_mode = bool(context_json.get("application_edit_mode") or context_json.get("applicationEditMode"))
claim_id = str(
context_json.get("application_edit_claim_id")
or context_json.get("applicationEditClaimId")
or ""
).strip()
return claim_id if is_edit_mode and claim_id else ""
@staticmethod
def _is_expense_application_claim_like(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
flags = []
has_application_detail = any(
isinstance(flag, dict)
and (
str(flag.get("business_stage") or "").strip() == "expense_application"
or isinstance(flag.get("application_detail"), dict)
)
for flag in flags
)
return (
expense_type in {"application", "expense_application"}
or expense_type.endswith("_application")
or claim_no.startswith("AP-")
or claim_no.startswith("APP-")
or has_application_detail
)
def _find_editable_expense_application_record(
self,
payload: UserAgentRequest,
) -> ExpenseClaim | None:
claim_id = self._resolve_application_edit_claim_id(payload.context_json or {})
if not claim_id:
return None
claim = self.db.get(ExpenseClaim, claim_id)
if claim is None:
raise ValueError("未找到要修改的申请单。")
if not self._is_expense_application_claim_like(claim):
raise ValueError("只能修改申请单。")
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
if not (current_user.is_admin or access_policy.is_claim_owned_by_current_user(claim, current_user)):
raise ValueError("只能修改本人被退回的申请单。")
status = str(claim.status or "").strip().lower()
if status not in {"returned", "draft", "supplement"}:
raise ValueError("当前申请单状态不支持修改。")
return claim
def _update_expense_application_record(
self,
payload: UserAgentRequest,
facts: dict[str, str],
claim: ExpenseClaim,
) -> ExpenseClaim:
current_user = self._build_application_current_user(payload)
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
flags = []
preserved_flags = [
flag
for flag in flags
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "application_detail"
)
]
claim.expense_type = self._resolve_application_expense_type_code(facts)
claim.reason = str(facts.get("reason") or "费用申请").strip() or "费用申请"
claim.location = str(facts.get("location") or "待补充").strip() or "待补充"
claim.amount = self._parse_application_amount_to_decimal(facts.get("amount", ""))
claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]
from app.services.expense_claims import ExpenseClaimService
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
if submitted is None:
raise ValueError("未找到可修改的申请单。")
return submitted
def _create_expense_application_record(
self,
payload: UserAgentRequest,