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