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