feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user