feat: 增强员工管理与报销单全流程功能

- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:21:56 +08:00
parent 57957d11a0
commit d7e98a58b9
46 changed files with 4022 additions and 305 deletions

View File

@@ -4,7 +4,7 @@ from collections import Counter
from datetime import date, datetime
from typing import Any
from sqlalchemy import inspect, text
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -20,6 +20,9 @@ from app.repositories.employee import EmployeeRepository
from app.schemas.employee import (
EmployeeCreate,
EmployeeHistoryRead,
EmployeeImportErrorRead,
EmployeeImportResultRead,
EmployeeImportSummaryRead,
EmployeeMetaRead,
EmployeeOrganizationRead,
EmployeeRead,
@@ -27,8 +30,16 @@ 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_seed import (
EMPLOYEE_DEFINITIONS,
EMPLOYEE_PROFILE_REPAIRS,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
@@ -37,6 +48,7 @@ from app.services.employee_seed import (
logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456"
MAX_EMPLOYEE_CHANGE_LOGS = 5
STATUS_TONE_MAP = {
"在职": "success",
@@ -57,7 +69,9 @@ def prepare_employee_directory() -> None:
session_factory = get_session_factory()
with session_factory() as db:
EmployeeService(db).ensure_directory_ready()
service = EmployeeService(db)
service.ensure_directory_ready()
service.apply_profile_repairs()
class EmployeeService:
@@ -120,10 +134,27 @@ class EmployeeService:
for role in self._sorted_roles(self.repository.list_roles())
]
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(
self.repository.list_organization_units(),
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:
@@ -261,6 +292,43 @@ class EmployeeService:
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)
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 = self._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] = []
@@ -280,7 +348,7 @@ class EmployeeService:
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
changed_fields.append("系统角色")
role_changed = True
if "password" in payload.model_fields_set and payload.password:
password = payload.password.strip()
@@ -289,7 +357,7 @@ class EmployeeService:
employee.password_hash = hash_password(password)
password_changed = True
if not changed_fields and not password_changed:
if not changed_fields and not password_changed and not role_changed:
return self._serialize_employee(employee)
now = datetime.now()
@@ -303,13 +371,25 @@ class EmployeeService:
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)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
return self._serialize_employee(hydrated or saved)
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()
@@ -328,10 +408,9 @@ class EmployeeService:
employee.spotlight = False
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
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 or saved)
return self._serialize_employee(hydrated)
def enable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
@@ -349,10 +428,299 @@ class EmployeeService:
employee.last_sync_at = now
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
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 or saved)
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()
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)
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))
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()) 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()
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 _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
@@ -471,6 +839,69 @@ class EmployeeService:
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",
"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)
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
@@ -530,6 +961,12 @@ class EmployeeService:
)
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,
@@ -546,6 +983,26 @@ class EmployeeService:
)
)
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:
organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles))
@@ -556,10 +1013,10 @@ class EmployeeService:
EmployeeHistoryRead(
action=item.action,
owner=item.owner,
time=self._format_datetime(item.occurred_at) or "",
occurredAt=self._format_datetime(item.occurred_at) or "",
time=self._format_history_datetime(item.occurred_at),
occurredAt=self._format_history_datetime(item.occurred_at),
)
for item in employee.change_logs
for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
]
return EmployeeRead(
@@ -571,6 +1028,7 @@ class EmployeeService:
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,
@@ -654,6 +1112,15 @@ class EmployeeService:
return None
return value.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _format_history_datetime(value: datetime | None) -> str:
if value is None:
return ""
return (
f"{value.year}{value.month}{value.day}"
f"{value.hour}{value.minute}{value.second}"
)
@staticmethod
def _calculate_age(birth_date: date | None) -> int | None:
if birth_date is None: