Files
X-Financial/server/src/app/services/digital_employee_reminder_task.py
caoxiaozhu 15006a05a7 feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
2026-06-03 09:25:23 +08:00

548 lines
22 KiB
Python

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),
}