feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -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: "升级",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
163
server/src/app/services/digital_employee_finance_report_task.py
Normal file
163
server/src/app/services/digital_employee_finance_report_task.py
Normal 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'}。"
|
||||
)
|
||||
102
server/src/app/services/digital_employee_reminder_scheduler.py
Normal file
102
server/src/app/services/digital_employee_reminder_scheduler.py
Normal 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()
|
||||
547
server/src/app/services/digital_employee_reminder_task.py
Normal file
547
server/src/app/services/digital_employee_reminder_task.py
Normal 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),
|
||||
}
|
||||
123
server/src/app/services/employee_profile_scan_task.py
Normal file
123
server/src/app/services/employee_profile_scan_task.py
Normal 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 {},
|
||||
}
|
||||
88
server/src/app/services/employee_profile_scheduler.py
Normal file
88
server/src/app/services/employee_profile_scheduler.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
65
server/src/app/services/finance_dashboard_constants.py
Normal file
65
server/src/app/services/finance_dashboard_constants.py
Normal 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": "人工复核",
|
||||
}
|
||||
85
server/src/app/services/finance_dashboard_scheduler.py
Normal file
85
server/src/app/services/finance_dashboard_scheduler.py
Normal 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()
|
||||
268
server/src/app/services/finance_dashboard_snapshot.py
Normal file
268
server/src/app/services/finance_dashboard_snapshot.py
Normal 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)
|
||||
319
server/src/app/services/finance_report_context.py
Normal file
319
server/src/app/services/finance_report_context.py
Normal 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}"
|
||||
205
server/src/app/services/finance_report_mailer.py
Normal file
205
server/src/app/services/finance_report_mailer.py
Normal 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)
|
||||
397
server/src/app/services/finance_report_renderer.py
Normal file
397
server/src/app/services/finance_report_renderer.py
Normal 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()
|
||||
143
server/src/app/services/finance_report_scheduler.py
Normal file
143
server/src/app/services/finance_report_scheduler.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user