feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -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: