Files
X-Financial/server/src/app/services/employee.py

830 lines
33 KiB
Python
Raw Normal View History

from __future__ import annotations
from collections import Counter
from datetime import UTC, date, datetime
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:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = EmployeeRepository(db)
def ensure_directory_ready(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))