feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from collections import Counter
from datetime import UTC, date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
@@ -29,24 +28,31 @@ from app.schemas.employee import (
EmployeeUpdate,
)
from app.services.employee_import import EmployeeImportCoordinator
from app.services.employee_serialization import (
format_history_datetime as serialize_history_datetime,
serialize_employee,
)
from app.services.employee_serialization import serialize_employee
from app.services.employee_spreadsheet import build_import_template_bytes
from app.services.employee_seed import (
CANONICAL_DEPARTMENT_CODES,
EMPLOYEE_DEFINITIONS,
EMPLOYEE_PROFILE_REPAIRS,
LEGACY_ORGANIZATION_UNIT_CODE_MAP,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
ROLE_PERMISSION_MAP,
normalize_organization_unit_code,
)
from app.services.employee_time import (
format_date,
format_datetime,
format_history_datetime,
normalize_optional_text,
parse_date,
parse_datetime,
)
logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456"
MAX_EMPLOYEE_CHANGE_LOGS = 5
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
STATUS_TONE_MAP = {
"在职": "success",
@@ -85,6 +91,7 @@ class EmployeeService:
self._seed_roles()
self._seed_organization_units()
self._seed_employees()
self._normalize_legacy_employee_departments()
self.db.commit()
except Exception:
self.db.rollback()
@@ -132,6 +139,7 @@ class EmployeeService:
for role in self._sorted_roles(self.repository.list_roles())
]
canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES)
organization_options = [
EmployeeOrganizationRead(
id=unit.id,
@@ -143,7 +151,11 @@ class EmployeeService:
managerName=unit.manager_name,
)
for unit in sorted(
self.repository.list_organization_units(),
(
unit
for unit in self.repository.list_organization_units()
if unit.unit_code in canonical_department_codes
),
key=lambda item: item.name,
)
]
@@ -185,7 +197,8 @@ class EmployeeService:
)
if payload.organization_unit_code:
employee.organization_unit = self.repository.get_organization_by_code(payload.organization_unit_code)
organization_code = normalize_organization_unit_code(payload.organization_unit_code)
employee.organization_unit = self.repository.get_organization_by_code(organization_code)
if payload.manager_employee_no:
employee.manager = self.repository.get_by_employee_no(payload.manager_employee_no)
@@ -224,7 +237,7 @@ class EmployeeService:
changed_fields.append("姓名")
if "gender" in payload.model_fields_set:
gender = self._normalize_optional_text(payload.gender)
gender = normalize_optional_text(payload.gender)
if gender != employee.gender:
employee.gender = gender
changed_fields.append("性别")
@@ -236,7 +249,7 @@ class EmployeeService:
changed_fields.append("出生日期")
if "phone" in payload.model_fields_set:
phone = self._normalize_optional_text(payload.phone)
phone = normalize_optional_text(payload.phone)
if phone != employee.phone:
employee.phone = phone
changed_fields.append("手机号")
@@ -257,7 +270,7 @@ class EmployeeService:
changed_fields.append("入职日期")
if "location" in payload.model_fields_set:
location = self._normalize_optional_text(payload.location)
location = normalize_optional_text(payload.location)
if location != employee.location:
employee.location = location
changed_fields.append("办公地点")
@@ -279,19 +292,21 @@ class EmployeeService:
changed_fields.append("职级")
if "cost_center" in payload.model_fields_set:
cost_center = self._normalize_optional_text(payload.cost_center)
cost_center = normalize_optional_text(payload.cost_center)
if cost_center != employee.cost_center:
employee.cost_center = cost_center
changed_fields.append("成本中心")
if "finance_owner_name" in payload.model_fields_set:
finance_owner_name = self._normalize_optional_text(payload.finance_owner_name)
finance_owner_name = normalize_optional_text(payload.finance_owner_name)
if finance_owner_name != employee.finance_owner_name:
employee.finance_owner_name = finance_owner_name
changed_fields.append("财务归口")
if "organization_unit_code" in payload.model_fields_set:
organization_code = self._normalize_optional_text(payload.organization_unit_code)
organization_code = normalize_organization_unit_code(
normalize_optional_text(payload.organization_unit_code)
)
current_code = (
employee.organization_unit.unit_code if employee.organization_unit else None
)
@@ -306,7 +321,7 @@ class EmployeeService:
changed_fields.append("所属部门")
if "manager_employee_no" in payload.model_fields_set:
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
manager_employee_no = normalize_optional_text(payload.manager_employee_no)
current_manager_no = employee.manager.employee_no if employee.manager else None
if manager_employee_no:
@@ -448,8 +463,8 @@ class EmployeeService:
self.repository,
sorted_roles=self._sorted_roles,
append_change_log=self._append_change_log,
format_date=self._format_date,
format_datetime=self._format_datetime,
format_date=format_date,
format_datetime=format_datetime,
default_password=DEFAULT_EMPLOYEE_PASSWORD,
)
@@ -487,6 +502,12 @@ class EmployeeService:
)
self.db.add(organization)
existing_by_code[organization.unit_code] = organization
else:
organization.name = definition["name"]
organization.unit_type = definition["unit_type"]
organization.cost_center = definition.get("cost_center")
organization.location = definition.get("location")
organization.manager_name = definition.get("manager_name")
self.db.flush()
@@ -496,12 +517,30 @@ class EmployeeService:
continue
organization = existing_by_code[definition["unit_code"]]
if organization.parent_id:
parent = existing_by_code.get(parent_code)
if parent is not None and organization.parent_id != parent.id:
organization.parent = parent
self.db.flush()
def _normalize_legacy_employee_departments(self) -> None:
if not LEGACY_ORGANIZATION_UNIT_CODE_MAP:
return
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for employee in self.repository.list():
current_code = (
employee.organization_unit.unit_code if employee.organization_unit else None
)
next_code = normalize_organization_unit_code(current_code)
if not next_code or next_code == current_code:
continue
parent = existing_by_code.get(parent_code)
if parent is not None:
organization.parent = parent
organization = organizations_by_code.get(next_code)
if organization is not None:
employee.organization_unit = organization
self.db.flush()
@@ -524,9 +563,9 @@ class EmployeeService:
name=definition["name"],
email=definition["email"],
gender=definition.get("gender"),
birth_date=self._parse_date(definition.get("birth_date")),
birth_date=parse_date(definition.get("birth_date")),
phone=definition.get("phone"),
join_date=self._parse_date(definition.get("join_date")),
join_date=parse_date(definition.get("join_date")),
location=definition.get("location"),
position=definition.get("position", "员工"),
grade=definition.get("grade", "P3"),
@@ -535,8 +574,8 @@ class EmployeeService:
employment_status=definition.get("employment_status", "在职"),
sync_state=definition.get("sync_state", "已同步"),
spotlight=bool(definition.get("spotlight")),
last_sync_at=self._parse_datetime(definition.get("last_sync_at")),
updated_at=self._parse_datetime(definition.get("updated_at")),
last_sync_at=parse_datetime(definition.get("last_sync_at")),
updated_at=parse_datetime(definition.get("updated_at")),
)
self.db.add(employee)
employees_by_no[employee_no] = employee
@@ -659,7 +698,7 @@ class EmployeeService:
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = {
(item.action, item.owner, self._format_datetime(item.occurred_at))
(item.action, item.owner, format_datetime(item.occurred_at))
for item in employee.change_logs
}
@@ -674,14 +713,14 @@ class EmployeeService:
]
for history in history_items:
occurred_at = self._parse_datetime(history.get("occurred_at"))
occurred_at = parse_datetime(history.get("occurred_at"))
if occurred_at is None:
continue
identity = (
history["action"],
history["owner"],
self._format_datetime(occurred_at),
format_datetime(occurred_at),
)
if identity in existing_keys:
continue
@@ -743,9 +782,9 @@ class EmployeeService:
employee,
sorted_roles=self._sorted_roles(list(employee.roles)),
sorted_change_logs=self._sorted_change_logs(employee),
format_date=self._format_date,
format_datetime=self._format_datetime,
format_history_datetime=self._format_history_datetime,
format_date=format_date,
format_datetime=format_datetime,
format_history_datetime=format_history_datetime,
role_permission_map=ROLE_PERMISSION_MAP,
status_tone_map=STATUS_TONE_MAP,
max_change_logs=MAX_EMPLOYEE_CHANGE_LOGS,
@@ -753,52 +792,3 @@ class EmployeeService:
def _sorted_roles(self, roles: list[Role]) -> list[Role]:
return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name))
@staticmethod
def _normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
text_value = value.strip()
return text_value or None
@staticmethod
def _parse_date(value: str | None) -> date | None:
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
@staticmethod
def _parse_datetime(value: str | datetime | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return datetime.strptime(value, "%Y-%m-%d %H:%M")
@staticmethod
def _format_date(value: date | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d")
@staticmethod
def _to_display_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.astimezone(DISPLAY_TIMEZONE)
@staticmethod
def _format_datetime(value: datetime | None) -> str | None:
if value is None:
return None
local = EmployeeService._to_display_datetime(value)
return local.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _format_history_datetime(value: datetime | None) -> str:
return serialize_history_datetime(
value,
to_display_datetime=EmployeeService._to_display_datetime,
)