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