Files
X-Financial/server/src/app/services/employee.py
caoxiaozhu 3f17619e0c fix(auth): 登录目录就绪幂等化与并发控制
- employee/settings/user_session_metrics 的 ensure_*_ready 改为按 bind 缓存 + 锁,
  避免每次登录重复建表与并发场景下的竞态
- auth 登录链路先查员工再降级触发目录就绪,并吞掉查询期 SQLAlchemy 异常
- 默认管理员账号由 superadmin 迁移为 admin,兼容历史账号回填
- 补充登录降级与设置持久化相关测试
2026-06-18 22:11:53 +08:00

851 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections import Counter
from datetime import UTC, date, datetime
import threading
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import hash_password
from app.db.base import Base
from app.db.session import get_session_factory
from app.models.employee import Employee
from app.models.employee_change_log import EmployeeChangeLog
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.repositories.employee import EmployeeRepository
from app.schemas.employee import (
EmployeeCreate,
EmployeeImportResultRead,
EmployeeMetaRead,
EmployeeOrganizationRead,
EmployeeRead,
EmployeeRoleOptionRead,
EmployeeStatusSummaryRead,
EmployeeUpdate,
)
from app.services.employee_import import EmployeeImportCoordinator
from app.services.employee_bank_info import apply_default_bank_info
from app.services.employee_schema import ensure_employee_schema
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
STATUS_TONE_MAP = {
"在职": "success",
"试用中": "warning",
"停用": "neutral",
}
STATUS_ORDER = ["全部员工", "在职", "试用中", "停用"]
SEEDED_EMPLOYEE_DEFINITIONS = EMPLOYEE_DEFINITIONS[:30]
EXTRA_SEED_EMPLOYEE_NOS = {item["employee_no"] for item in EMPLOYEE_DEFINITIONS[30:]}
def prepare_employee_directory() -> None:
settings = get_settings()
if not settings.setup_completed:
logger.info("Employee directory bootstrap skipped because setup is incomplete")
return
session_factory = get_session_factory()
with session_factory() as db:
service = EmployeeService(db)
service.ensure_directory_ready()
service.apply_profile_repairs()
class EmployeeService:
_directory_ready_lock = threading.Lock()
_directory_ready_keys: set[tuple[str, int]] = set()
def __init__(self, db: Session) -> None:
self.db = db
self.repository = EmployeeRepository(db)
@staticmethod
def _bind_cache_key(db: Session) -> tuple[str, int]:
bind = db.get_bind()
return (bind.url.render_as_string(hide_password=True), id(bind.pool))
def ensure_directory_ready(self) -> None:
cache_key = self._bind_cache_key(self.db)
if cache_key in self._directory_ready_keys:
return
with self._directory_ready_lock:
if cache_key in self._directory_ready_keys:
return
self._ensure_directory_ready_uncached()
self._directory_ready_keys.add(cache_key)
def _ensure_directory_ready_uncached(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
ensure_employee_schema(self.db)
self._prune_extra_seed_employees()
self._seed_roles()
self._seed_organization_units()
self._seed_employees()
self._normalize_legacy_employee_departments()
self._backfill_employee_bank_info()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to prepare employee directory")
raise
def list_employees(self, status: str | None = None, keyword: str | None = None) -> list[EmployeeRead]:
self.ensure_directory_ready()
employees = self.repository.list(status=status, keyword=keyword)
logger.info("Listed employees (count=%d, status=%s, keyword=%s)", len(employees), status, keyword)
return [self._serialize_employee(item) for item in employees]
def get_employee(self, employee_id: str) -> EmployeeRead | None:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee is None:
logger.warning("Employee not found id=%s", employee_id)
return None
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
return self._serialize_employee(employee)
def get_employee_meta(self) -> EmployeeMetaRead:
self.ensure_directory_ready()
employees = self.repository.list()
status_counter = Counter(item.employment_status for item in employees)
status_summary = [
EmployeeStatusSummaryRead(
id=status,
label=status,
count=len(employees) if status == "全部员工" else status_counter.get(status, 0),
)
for status in STATUS_ORDER
]
visible_role_codes = {item["role_code"] for item in ROLE_DEFINITIONS}
role_options = [
EmployeeRoleOptionRead(
id=role.role_code,
code=role.role_code,
label=role.name,
desc=role.description,
permissions=list(ROLE_PERMISSION_MAP.get(role.role_code, [])),
)
for role in self._sorted_roles(self.repository.list_roles())
if role.role_code in visible_role_codes
]
canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES)
organization_options = [
EmployeeOrganizationRead(
id=unit.id,
code=unit.unit_code,
name=unit.name,
unitType=unit.unit_type,
costCenter=unit.cost_center,
location=unit.location,
managerName=unit.manager_name,
)
for unit in sorted(
(
unit
for unit in self.repository.list_organization_units()
if unit.unit_code in canonical_department_codes
),
key=lambda item: item.name,
)
]
return EmployeeMetaRead(
totalEmployees=len(employees),
statusSummary=status_summary,
roleOptions=role_options,
organizationOptions=organization_options,
)
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
self.ensure_directory_ready()
if self.repository.get_by_employee_no(payload.employee_no):
raise ValueError(f"员工编号 {payload.employee_no} 已存在")
if self.repository.get_by_email(str(payload.email)):
raise ValueError(f"邮箱 {payload.email} 已存在")
employee = Employee(
employee_no=payload.employee_no,
name=payload.name,
email=str(payload.email),
gender=payload.gender,
birth_date=payload.parsed_birth_date(),
phone=payload.phone,
join_date=payload.parsed_join_date(),
location=payload.location,
position=payload.position,
grade=payload.grade,
cost_center=payload.cost_center,
finance_owner_name=payload.finance_owner_name,
bank_name=normalize_optional_text(payload.bank_name),
bank_account_no=normalize_optional_text(payload.bank_account_no),
bank_account_name=normalize_optional_text(payload.bank_account_name),
employment_status=payload.employment_status,
sync_state=payload.sync_state,
spotlight=payload.spotlight,
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
last_sync_at=datetime.now(UTC),
)
apply_default_bank_info(employee)
if 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)
roles = [
role
for code in payload.role_codes
if (role := self.repository.get_role_by_code(code)) is not None
]
employee.roles = self._sorted_roles(roles)
created = self.repository.create(employee)
logger.info(
"Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name
)
hydrated = self.repository.get(created.id)
return self._serialize_employee(hydrated or created)
def update_employee(self, employee_id: str, payload: EmployeeUpdate) -> EmployeeRead:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee is None:
raise LookupError("Employee not found")
changed_fields: list[str] = []
password_changed = False
if "name" in payload.model_fields_set and payload.name is not None:
name = payload.name.strip()
if not name:
raise ValueError("员工姓名不能为空")
if name != employee.name:
employee.name = name
changed_fields.append("姓名")
if "gender" in payload.model_fields_set:
gender = normalize_optional_text(payload.gender)
if gender != employee.gender:
employee.gender = gender
changed_fields.append("性别")
if "birth_date" in payload.model_fields_set:
birth_date = payload.parsed_birth_date() if payload.birth_date else None
if birth_date != employee.birth_date:
employee.birth_date = birth_date
changed_fields.append("出生日期")
if "phone" in payload.model_fields_set:
phone = normalize_optional_text(payload.phone)
if phone != employee.phone:
employee.phone = phone
changed_fields.append("手机号")
if "email" in payload.model_fields_set and payload.email is not None:
email = str(payload.email)
existing = self.repository.get_by_email(email)
if existing is not None and existing.id != employee.id:
raise ValueError(f"邮箱 {email} 已存在")
if email != employee.email:
employee.email = email
changed_fields.append("邮箱")
if "join_date" in payload.model_fields_set:
join_date = payload.parsed_join_date() if payload.join_date else None
if join_date != employee.join_date:
employee.join_date = join_date
changed_fields.append("入职日期")
if "location" in payload.model_fields_set:
location = normalize_optional_text(payload.location)
if location != employee.location:
employee.location = location
changed_fields.append("办公地点")
if "position" in payload.model_fields_set and payload.position is not None:
position = payload.position.strip()
if not position:
raise ValueError("岗位不能为空")
if position != employee.position:
employee.position = position
changed_fields.append("岗位")
if "grade" in payload.model_fields_set and payload.grade is not None:
grade = payload.grade.strip()
if not grade:
raise ValueError("职级不能为空")
if grade != employee.grade:
employee.grade = grade
changed_fields.append("职级")
if "cost_center" in payload.model_fields_set:
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 = 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 "bank_account_name" in payload.model_fields_set:
bank_account_name = normalize_optional_text(payload.bank_account_name)
if bank_account_name != employee.bank_account_name:
employee.bank_account_name = bank_account_name
changed_fields.append("银行户名")
if "bank_name" in payload.model_fields_set:
bank_name = normalize_optional_text(payload.bank_name)
if bank_name != employee.bank_name:
employee.bank_name = bank_name
changed_fields.append("开户行")
if "bank_account_no" in payload.model_fields_set:
bank_account_no = normalize_optional_text(payload.bank_account_no)
if bank_account_no != employee.bank_account_no:
employee.bank_account_no = bank_account_no
changed_fields.append("银行账号")
if "organization_unit_code" in payload.model_fields_set:
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
)
if organization_code != current_code:
if organization_code:
organization = self.repository.get_organization_by_code(organization_code)
if organization is None:
raise ValueError(f"部门编码 {organization_code} 不存在")
employee.organization_unit = organization
else:
employee.organization_unit = None
changed_fields.append("所属部门")
if "manager_employee_no" in payload.model_fields_set:
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:
if manager_employee_no == employee.employee_no:
raise ValueError("直属上级不能是员工本人")
manager = self.repository.get_by_employee_no(manager_employee_no)
if manager is None:
raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
if manager_employee_no != current_manager_no:
employee.manager = manager
changed_fields.append("直属上级")
elif current_manager_no is not None:
employee.manager = None
changed_fields.append("直属上级")
role_changed = False
sorted_roles: list[Role] = []
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
requested_codes = list(dict.fromkeys(payload.role_codes))
roles: list[Role] = []
invalid_codes: list[str] = []
for code in requested_codes:
role = self.repository.get_role_by_code(code)
if role is None:
invalid_codes.append(code)
continue
roles.append(role)
if invalid_codes:
raise ValueError(f"角色不存在:{''.join(invalid_codes)}")
sorted_roles = self._sorted_roles(roles)
next_role_codes = [role.role_code for role in sorted_roles]
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
if next_role_codes != current_role_codes:
employee.roles = sorted_roles
role_changed = True
if "password" in payload.model_fields_set and payload.password:
password = payload.password.strip()
if len(password) < 5:
raise ValueError("员工密码至少需要 5 位")
employee.password_hash = hash_password(password)
password_changed = True
if not changed_fields and not password_changed and not role_changed:
return self._serialize_employee(employee)
now = datetime.now(UTC)
employee.last_sync_at = now
employee.sync_state = "已同步"
if changed_fields:
self._append_change_log(
employee,
action=f"更新员工信息({''.join(changed_fields)}",
occurred_at=now,
)
if role_changed:
role_labels = "".join(role.name for role in sorted_roles)
self._append_change_log(
employee,
action=f"更新系统角色({role_labels}",
occurred_at=now,
)
if password_changed:
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
hydrated = self._save_employee_and_reload(employee)
logger.info(
"Updated employee id=%s fields=%s role_changed=%s",
employee.id,
",".join(changed_fields),
role_changed,
)
return self._serialize_employee(hydrated)
def disable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee is None:
raise LookupError("Employee not found")
if employee.employment_status == "停用":
return self._serialize_employee(employee)
now = datetime.now(UTC)
employee.employment_status = "停用"
employee.sync_state = "已同步"
employee.last_sync_at = now
employee.spotlight = False
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
hydrated = self._save_employee_and_reload(employee)
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated)
def enable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee is None:
raise LookupError("Employee not found")
if employee.employment_status != "停用":
return self._serialize_employee(employee)
now = datetime.now(UTC)
employee.employment_status = "在职"
employee.sync_state = "已同步"
employee.last_sync_at = now
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
hydrated = self._save_employee_and_reload(employee)
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated)
def build_import_template(self) -> bytes:
self.ensure_directory_ready()
return build_import_template_bytes()
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
self.ensure_directory_ready()
return self._import_coordinator().export_employees(status=status, keyword=keyword)
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
self.ensure_directory_ready()
return self._import_coordinator().import_employees(content, actor=actor)
def _import_coordinator(self) -> EmployeeImportCoordinator:
return EmployeeImportCoordinator(
self.db,
self.repository,
sorted_roles=self._sorted_roles,
append_change_log=self._append_change_log,
format_date=format_date,
format_datetime=format_datetime,
default_password=DEFAULT_EMPLOYEE_PASSWORD,
)
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
legacy_auditor = existing_by_code.get("auditor")
if legacy_auditor is not None and "budget_monitor" not in existing_by_code:
legacy_auditor.role_code = "budget_monitor"
existing_by_code["budget_monitor"] = legacy_auditor
existing_by_code.pop("auditor", None)
for definition in ROLE_DEFINITIONS:
role = existing_by_code.get(definition["role_code"])
if role is None:
role = Role(
role_code=definition["role_code"],
name=definition["name"],
description=definition["description"],
)
self.db.add(role)
existing_by_code[role.role_code] = role
else:
role.name = definition["name"]
role.description = definition["description"]
self.db.flush()
def _seed_organization_units(self) -> None:
existing_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in ORGANIZATION_DEFINITIONS:
organization = existing_by_code.get(definition["unit_code"])
if organization is None:
organization = OrganizationUnit(
unit_code=definition["unit_code"],
name=definition["name"],
unit_type=definition["unit_type"],
cost_center=definition.get("cost_center"),
location=definition.get("location"),
manager_name=definition.get("manager_name"),
)
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()
for definition in ORGANIZATION_DEFINITIONS:
parent_code = definition.get("parent_code")
if not parent_code:
continue
organization = existing_by_code[definition["unit_code"]]
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
organization = organizations_by_code.get(next_code)
if organization is not None:
employee.organization_unit = organization
self.db.flush()
def _seed_employees(self) -> None:
employees_by_no = {
employee.employee_no: employee for employee in self.repository.list()
}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in SEEDED_EMPLOYEE_DEFINITIONS:
employee_no = definition["employee_no"]
if employee_no in employees_by_no:
continue
employee = Employee(
employee_no=employee_no,
name=definition["name"],
email=definition["email"],
gender=definition.get("gender"),
birth_date=parse_date(definition.get("birth_date")),
phone=definition.get("phone"),
join_date=parse_date(definition.get("join_date")),
location=definition.get("location"),
position=definition.get("position", "员工"),
grade=definition.get("grade", "P3"),
cost_center=definition.get("cost_center"),
finance_owner_name=definition.get("finance_owner_name"),
bank_name=definition.get("bank_name"),
bank_account_no=definition.get("bank_account_no"),
bank_account_name=definition.get("bank_account_name"),
employment_status=definition.get("employment_status", "在职"),
sync_state=definition.get("sync_state", "已同步"),
spotlight=bool(definition.get("spotlight")),
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
self.db.flush()
for definition in SEEDED_EMPLOYEE_DEFINITIONS:
employee = employees_by_no[definition["employee_no"]]
organization_code = definition.get("organization_unit_code")
manager_employee_no = definition.get("manager_employee_no")
if employee.organization_unit_id is None and organization_code:
employee.organization_unit = organizations_by_code.get(organization_code)
if employee.manager_id is None and manager_employee_no:
employee.manager = employees_by_no.get(manager_employee_no)
if not employee.password_hash:
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
apply_default_bank_info(employee)
if not employee.roles:
employee.roles = self._sorted_roles(
[
roles_by_code[role_code]
for role_code in definition.get("role_codes", [])
if role_code in roles_by_code
]
)
self._seed_employee_history(employee, definition)
self.db.flush()
def apply_profile_repairs(self) -> None:
"""Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
try:
self._repair_employee_profiles()
self._trim_all_employee_change_logs()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to apply employee profile repairs")
raise
def _repair_employee_profiles(self) -> None:
if not EMPLOYEE_PROFILE_REPAIRS:
return
employees = self.repository.list()
employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in EMPLOYEE_PROFILE_REPAIRS:
email = str(definition.get("email") or "").strip().lower()
employee_no = str(definition.get("employee_no") or "").strip()
employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
if employee is None:
continue
for field_name in (
"position",
"grade",
"location",
"cost_center",
"finance_owner_name",
"bank_name",
"bank_account_no",
"bank_account_name",
"employment_status",
"sync_state",
):
value = definition.get(field_name)
if value:
setattr(employee, field_name, value)
organization_code = definition.get("organization_unit_code")
if organization_code:
employee.organization_unit = organizations_by_code.get(organization_code)
manager_employee_no = definition.get("manager_employee_no")
if manager_employee_no:
employee.manager = employees_by_no.get(manager_employee_no)
if not employee.password_hash:
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
apply_default_bank_info(employee)
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
if role_codes:
merged_roles = {role.role_code: role for role in employee.roles}
for role_code in role_codes:
merged_roles[role_code] = roles_by_code[role_code]
employee.roles = self._sorted_roles(list(merged_roles.values()))
self.db.flush()
def _prune_extra_seed_employees(self) -> None:
if not EXTRA_SEED_EMPLOYEE_NOS:
return
for employee_no in EXTRA_SEED_EMPLOYEE_NOS:
employee = self.repository.get_by_employee_no(employee_no)
if employee is not None:
self.db.delete(employee)
def _backfill_employee_bank_info(self) -> None:
for employee in self.repository.list():
apply_default_bank_info(employee)
self.db.flush()
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = {
(item.action, item.owner, format_datetime(item.occurred_at))
for item in employee.change_logs
}
history_items = list(definition.get("history", []))
if not history_items:
history_items = [
{
"action": "初始化员工档案",
"owner": "系统初始化任务",
"occurred_at": definition.get("updated_at") or definition.get("last_sync_at"),
}
]
for history in history_items:
occurred_at = parse_datetime(history.get("occurred_at"))
if occurred_at is None:
continue
identity = (
history["action"],
history["owner"],
format_datetime(occurred_at),
)
if identity in existing_keys:
continue
self.db.add(
EmployeeChangeLog(
employee=employee,
action=history["action"],
owner=history["owner"],
occurred_at=occurred_at,
)
)
existing_keys.add(identity)
def _save_employee_and_reload(self, employee: Employee) -> Employee:
saved = self.repository.save(employee)
self._trim_employee_change_logs(saved.id)
self.db.commit()
return self.repository.get(saved.id) or saved
def _append_change_log(
self,
employee: Employee,
action: str,
owner: str = "系统管理员",
occurred_at: datetime | None = None,
) -> None:
self.db.add(
EmployeeChangeLog(
employee=employee,
action=action,
owner=owner,
occurred_at=occurred_at or datetime.now(UTC),
)
)
def _trim_all_employee_change_logs(self) -> None:
for employee in self.repository.list():
self._trim_employee_change_logs(employee.id)
def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
def _trim_employee_change_logs(self, employee_id: str) -> None:
stmt = (
select(EmployeeChangeLog)
.where(EmployeeChangeLog.employee_id == employee_id)
.order_by(EmployeeChangeLog.occurred_at.desc())
)
logs = list(self.db.execute(stmt).scalars().all())
if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
return
for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
self.db.delete(stale)
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
return serialize_employee(
employee,
sorted_roles=self._sorted_roles(list(employee.roles)),
sorted_change_logs=self._sorted_change_logs(employee),
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,
)
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))