- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
548 lines
22 KiB
Python
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),
|
|
}
|