refactor(server): split oversized backend services
This commit is contained in:
@@ -20,10 +20,7 @@ from app.models.role import Role
|
||||
from app.repositories.employee import EmployeeRepository
|
||||
from app.schemas.employee import (
|
||||
EmployeeCreate,
|
||||
EmployeeHistoryRead,
|
||||
EmployeeImportErrorRead,
|
||||
EmployeeImportResultRead,
|
||||
EmployeeImportSummaryRead,
|
||||
EmployeeMetaRead,
|
||||
EmployeeOrganizationRead,
|
||||
EmployeeRead,
|
||||
@@ -31,13 +28,12 @@ from app.schemas.employee import (
|
||||
EmployeeStatusSummaryRead,
|
||||
EmployeeUpdate,
|
||||
)
|
||||
from app.services.employee_spreadsheet import (
|
||||
EmployeeImportRow,
|
||||
EmployeeSpreadsheetError,
|
||||
build_export_workbook_bytes,
|
||||
build_import_template_bytes,
|
||||
parse_employee_workbook,
|
||||
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_spreadsheet import build_import_template_bytes
|
||||
from app.services.employee_seed import (
|
||||
EMPLOYEE_DEFINITIONS,
|
||||
EMPLOYEE_PROFILE_REPAIRS,
|
||||
@@ -440,288 +436,21 @@ class EmployeeService:
|
||||
|
||||
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
|
||||
self.ensure_directory_ready()
|
||||
employees = self.repository.list(status=status, keyword=keyword)
|
||||
rows: list[list[str]] = []
|
||||
|
||||
for employee in employees:
|
||||
organization = employee.organization_unit
|
||||
role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
|
||||
rows.append(
|
||||
[
|
||||
employee.employee_no,
|
||||
employee.name,
|
||||
employee.email,
|
||||
employee.gender or "",
|
||||
self._format_date(employee.birth_date) or "",
|
||||
employee.phone or "",
|
||||
self._format_date(employee.join_date) or "",
|
||||
employee.location or "",
|
||||
employee.position,
|
||||
employee.grade,
|
||||
organization.unit_code if organization else "",
|
||||
employee.manager.employee_no if employee.manager else "",
|
||||
employee.finance_owner_name or "",
|
||||
employee.cost_center or "",
|
||||
employee.employment_status,
|
||||
role_codes,
|
||||
]
|
||||
)
|
||||
|
||||
return build_export_workbook_bytes(rows)
|
||||
return self._import_coordinator().export_employees(status=status, keyword=keyword)
|
||||
|
||||
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
|
||||
self.ensure_directory_ready()
|
||||
parsed_rows, parse_errors = parse_employee_workbook(content)
|
||||
if parse_errors:
|
||||
return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
|
||||
return self._import_coordinator().import_employees(content, actor=actor)
|
||||
|
||||
validation_errors = self._validate_import_rows(parsed_rows)
|
||||
if validation_errors:
|
||||
return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
|
||||
|
||||
try:
|
||||
summary = self._apply_import_rows(parsed_rows, actor=actor)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
logger.exception("Employee import failed during database write")
|
||||
raise
|
||||
|
||||
imported_at = self._format_datetime(datetime.now(UTC)) or ""
|
||||
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
|
||||
logger.info(
|
||||
"Imported employees created=%d updated=%d total=%d",
|
||||
summary["created"],
|
||||
summary["updated"],
|
||||
len(parsed_rows),
|
||||
)
|
||||
return EmployeeImportResultRead(
|
||||
success=True,
|
||||
message=message,
|
||||
summary=EmployeeImportSummaryRead(
|
||||
totalRows=len(parsed_rows),
|
||||
created=summary["created"],
|
||||
updated=summary["updated"],
|
||||
errorCount=0,
|
||||
),
|
||||
errors=[],
|
||||
importedAt=imported_at,
|
||||
)
|
||||
|
||||
def _validate_import_rows(
|
||||
self, rows: list[EmployeeImportRow]
|
||||
) -> list[EmployeeSpreadsheetError]:
|
||||
errors: list[EmployeeSpreadsheetError] = []
|
||||
employee_nos_in_file: dict[str, int] = {}
|
||||
emails_in_file: dict[str, int] = {}
|
||||
|
||||
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()
|
||||
}
|
||||
employees_by_no = {
|
||||
employee.employee_no: employee for employee in self.repository.list()
|
||||
}
|
||||
import_employee_nos = {row.employee_no for row in rows}
|
||||
|
||||
for row in rows:
|
||||
if row.employee_no in employee_nos_in_file:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="员工编号*",
|
||||
employee_no=row.employee_no,
|
||||
message=f"员工编号 {row.employee_no} 在文件中重复。",
|
||||
)
|
||||
)
|
||||
else:
|
||||
employee_nos_in_file[row.employee_no] = row.row_number
|
||||
|
||||
if row.email in emails_in_file:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="邮箱*",
|
||||
employee_no=row.employee_no,
|
||||
message=f"邮箱 {row.email} 在文件中重复。",
|
||||
)
|
||||
)
|
||||
else:
|
||||
emails_in_file[row.email] = row.row_number
|
||||
|
||||
existing_by_email = self.repository.get_by_email(row.email)
|
||||
if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="邮箱*",
|
||||
employee_no=row.employee_no,
|
||||
message=(
|
||||
f"邮箱 {row.email} 已被员工 "
|
||||
f"{existing_by_email.employee_no} 使用。"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="部门编码",
|
||||
employee_no=row.employee_no,
|
||||
message=f"部门编码 {row.organization_unit_code} 不存在。",
|
||||
)
|
||||
)
|
||||
|
||||
if row.manager_employee_no:
|
||||
manager_exists = (
|
||||
row.manager_employee_no in employees_by_no
|
||||
or row.manager_employee_no in import_employee_nos
|
||||
)
|
||||
if not manager_exists:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="直属上级工号",
|
||||
employee_no=row.employee_no,
|
||||
message=f"直属上级工号 {row.manager_employee_no} 不存在。",
|
||||
)
|
||||
)
|
||||
if row.manager_employee_no == row.employee_no:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="直属上级工号",
|
||||
employee_no=row.employee_no,
|
||||
message="直属上级不能是员工本人。",
|
||||
)
|
||||
)
|
||||
|
||||
invalid_role_codes = [
|
||||
code for code in row.role_codes if code not in roles_by_code
|
||||
]
|
||||
if invalid_role_codes:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="角色编码",
|
||||
employee_no=row.employee_no,
|
||||
message=f"角色不存在:{'、'.join(invalid_role_codes)}。",
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
def _apply_import_rows(
|
||||
self,
|
||||
rows: list[EmployeeImportRow],
|
||||
*,
|
||||
actor: str,
|
||||
) -> dict[str, int]:
|
||||
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()
|
||||
}
|
||||
employees_by_no = {
|
||||
employee.employee_no: employee for employee in self.repository.list()
|
||||
}
|
||||
created = 0
|
||||
updated = 0
|
||||
now = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
for row in rows:
|
||||
employee = employees_by_no.get(row.employee_no)
|
||||
is_new = employee is None
|
||||
|
||||
if is_new:
|
||||
employee = Employee(
|
||||
employee_no=row.employee_no,
|
||||
name=row.name,
|
||||
email=row.email,
|
||||
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||
)
|
||||
self.db.add(employee)
|
||||
employees_by_no[row.employee_no] = employee
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
employee.name = row.name
|
||||
employee.email = row.email
|
||||
employee.gender = row.gender
|
||||
employee.birth_date = row.birth_date
|
||||
employee.phone = row.phone
|
||||
employee.join_date = row.join_date
|
||||
employee.location = row.location
|
||||
employee.position = row.position
|
||||
employee.grade = row.grade
|
||||
employee.finance_owner_name = row.finance_owner_name
|
||||
employee.cost_center = row.cost_center
|
||||
employee.employment_status = row.employment_status
|
||||
employee.sync_state = "已同步"
|
||||
employee.last_sync_at = now
|
||||
|
||||
if row.organization_unit_code:
|
||||
employee.organization_unit = organizations_by_code[row.organization_unit_code]
|
||||
else:
|
||||
employee.organization_unit = None
|
||||
|
||||
employee.roles = self._sorted_roles(
|
||||
[roles_by_code[code] for code in row.role_codes if code in roles_by_code]
|
||||
)
|
||||
|
||||
action = (
|
||||
"通过 Excel 导入新建员工档案"
|
||||
if is_new
|
||||
else "通过 Excel 导入更新员工档案"
|
||||
)
|
||||
self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
|
||||
|
||||
self.db.flush()
|
||||
|
||||
for row in rows:
|
||||
employee = employees_by_no[row.employee_no]
|
||||
if row.manager_employee_no:
|
||||
employee.manager = employees_by_no.get(row.manager_employee_no)
|
||||
else:
|
||||
employee.manager = None
|
||||
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
return {"created": created, "updated": updated}
|
||||
|
||||
def _build_import_failure(
|
||||
self,
|
||||
errors: list[EmployeeSpreadsheetError],
|
||||
*,
|
||||
total_rows: int,
|
||||
) -> EmployeeImportResultRead:
|
||||
error_reads = [
|
||||
EmployeeImportErrorRead(
|
||||
row=item.row,
|
||||
column=item.column,
|
||||
employeeNo=item.employee_no,
|
||||
message=item.message,
|
||||
)
|
||||
for item in errors
|
||||
]
|
||||
return EmployeeImportResultRead(
|
||||
success=False,
|
||||
message=(
|
||||
f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
|
||||
"原有员工数据未变更。"
|
||||
),
|
||||
summary=EmployeeImportSummaryRead(
|
||||
totalRows=total_rows,
|
||||
created=0,
|
||||
updated=0,
|
||||
errorCount=len(error_reads),
|
||||
),
|
||||
errors=error_reads,
|
||||
importedAt=None,
|
||||
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=self._format_date,
|
||||
format_datetime=self._format_datetime,
|
||||
default_password=DEFAULT_EMPLOYEE_PASSWORD,
|
||||
)
|
||||
|
||||
def _seed_roles(self) -> None:
|
||||
@@ -1006,78 +735,18 @@ class EmployeeService:
|
||||
self.db.delete(stale)
|
||||
|
||||
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
|
||||
organization = employee.organization_unit
|
||||
roles = self._sorted_roles(list(employee.roles))
|
||||
role_labels = [role.name for role in roles]
|
||||
role_codes = [role.role_code for role in roles]
|
||||
|
||||
history = [
|
||||
EmployeeHistoryRead(
|
||||
action=item.action,
|
||||
owner=item.owner,
|
||||
time=self._format_history_datetime(item.occurred_at),
|
||||
occurredAt=self._format_history_datetime(item.occurred_at),
|
||||
)
|
||||
for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
|
||||
]
|
||||
|
||||
return EmployeeRead(
|
||||
id=employee.id,
|
||||
avatar=(employee.name or "?")[:1],
|
||||
name=employee.name,
|
||||
employeeNo=employee.employee_no,
|
||||
department=organization.name if organization else "",
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
manager=employee.manager.name if employee.manager else "CEO",
|
||||
managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
|
||||
financeOwner=employee.finance_owner_name or "",
|
||||
roles=role_labels,
|
||||
roleCodes=role_codes,
|
||||
status=employee.employment_status,
|
||||
statusTone=STATUS_TONE_MAP.get(employee.employment_status, "neutral"),
|
||||
gender=employee.gender,
|
||||
age=self._calculate_age(employee.birth_date),
|
||||
birthDate=self._format_date(employee.birth_date),
|
||||
email=employee.email,
|
||||
phone=employee.phone,
|
||||
joinDate=self._format_date(employee.join_date),
|
||||
location=employee.location,
|
||||
costCenter=employee.cost_center,
|
||||
updatedAt=self._format_datetime(employee.updated_at or employee.created_at),
|
||||
lastSync=self._format_datetime(employee.last_sync_at),
|
||||
syncState=employee.sync_state,
|
||||
spotlight=employee.spotlight,
|
||||
permissions=self._collect_permissions(role_codes),
|
||||
history=history,
|
||||
organization=(
|
||||
EmployeeOrganizationRead(
|
||||
id=organization.id,
|
||||
code=organization.unit_code,
|
||||
name=organization.name,
|
||||
unitType=organization.unit_type,
|
||||
costCenter=organization.cost_center,
|
||||
location=organization.location,
|
||||
managerName=organization.manager_name,
|
||||
)
|
||||
if organization
|
||||
else None
|
||||
),
|
||||
return serialize_employee(
|
||||
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,
|
||||
role_permission_map=ROLE_PERMISSION_MAP,
|
||||
status_tone_map=STATUS_TONE_MAP,
|
||||
max_change_logs=MAX_EMPLOYEE_CHANGE_LOGS,
|
||||
)
|
||||
|
||||
def _collect_permissions(self, role_codes: list[str]) -> list[str]:
|
||||
permissions: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for role_code in role_codes:
|
||||
for permission in ROLE_PERMISSION_MAP.get(role_code, []):
|
||||
if permission in seen:
|
||||
continue
|
||||
permissions.append(permission)
|
||||
seen.add(permission)
|
||||
|
||||
return permissions
|
||||
|
||||
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))
|
||||
|
||||
@@ -1125,21 +794,7 @@ class EmployeeService:
|
||||
|
||||
@staticmethod
|
||||
def _format_history_datetime(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
local = EmployeeService._to_display_datetime(value)
|
||||
return (
|
||||
f"{local.year}年{local.month}月{local.day}日"
|
||||
f"{local.hour}时{local.minute}分"
|
||||
return serialize_history_datetime(
|
||||
value,
|
||||
to_display_datetime=EmployeeService._to_display_datetime,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_age(birth_date: date | None) -> int | None:
|
||||
if birth_date is None:
|
||||
return None
|
||||
|
||||
today = date.today()
|
||||
age = today.year - birth_date.year
|
||||
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
Reference in New Issue
Block a user