feat: add employee management, backend health check, and UI improvements

This commit is contained in:
2026-05-07 11:50:10 +08:00
parent a5db09f41e
commit c00db75c13
59 changed files with 3926 additions and 5796 deletions

View File

@@ -2,25 +2,37 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.employee import EmployeeCreate, EmployeeRead
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead
from app.services.employee import EmployeeService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("/meta", response_model=EmployeeMetaRead)
def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
return EmployeeService(db).get_employee_meta()
@router.get("", response_model=list[EmployeeRead])
def list_employees(db: DbSession) -> list[EmployeeRead]:
return EmployeeService(db).list_employees()
def list_employees(
db: DbSession,
status_filter: Annotated[str | None, Query(alias="status")] = None,
keyword: str | None = None,
) -> list[EmployeeRead]:
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
@router.post("", response_model=EmployeeRead, status_code=status.HTTP_201_CREATED)
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
return EmployeeService(db).create_employee(payload)
try:
return EmployeeService(db).create_employee(payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.get("/{employee_id}", response_model=EmployeeRead)

View File

@@ -1,6 +1,17 @@
from app.db.base_class import Base
from app.models.approval import ApprovalRecord
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
__all__ = ["Base", "Employee", "ReimbursementRequest", "ApprovalRecord"]
__all__ = [
"Base",
"ApprovalRecord",
"Employee",
"EmployeeChangeLog",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
]

View File

@@ -7,6 +7,7 @@ from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware
from app.services.employee import prepare_employee_directory
def create_app() -> FastAPI:
@@ -48,8 +49,9 @@ def create_app() -> FastAPI:
@app.on_event("startup")
def _on_startup() -> None:
prepare_employee_directory()
logger.info(
"Server ready host=%s port=%s prefix=%s",
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
settings.app_port,
settings.api_v1_prefix,

View File

@@ -1,5 +1,15 @@
from app.models.approval import ApprovalRecord
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
__all__ = ["ApprovalRecord", "Employee", "ReimbursementRequest"]
__all__ = [
"ApprovalRecord",
"Employee",
"EmployeeChangeLog",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
]

View File

@@ -1,13 +1,20 @@
from __future__ import annotations
import uuid
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, String, Table, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
employee_role_links = Table(
"employee_role_links",
Base.metadata,
Column("employee_id", String(36), ForeignKey("employees.id"), primary_key=True),
Column("role_id", String(36), ForeignKey("roles.id"), primary_key=True),
)
class Employee(Base):
__tablename__ = "employees"
@@ -15,11 +22,37 @@ class Employee(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
department: Mapped[str] = mapped_column(String(100), index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
birth_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
join_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
position: Mapped[str] = mapped_column(String(100), default="员工")
grade: Mapped[str] = mapped_column(String(20), default="P3", index=True)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
organization_unit_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
manager_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
organization_unit = relationship("OrganizationUnit", back_populates="employees")
manager = relationship("Employee", remote_side=[id], back_populates="reports")
reports = relationship("Employee", back_populates="manager")
roles = relationship("Role", secondary=employee_role_links, back_populates="employees")
change_logs = relationship(
"EmployeeChangeLog",
back_populates="employee",
cascade="all, delete-orphan",
order_by="desc(EmployeeChangeLog.occurred_at)",
)
reimbursement_requests = relationship("ReimbursementRequest", back_populates="employee")

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class EmployeeChangeLog(Base):
__tablename__ = "employee_change_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_id: Mapped[str] = mapped_column(ForeignKey("employees.id"), index=True)
action: Mapped[str] = mapped_column(String(255))
owner: Mapped[str] = mapped_column(String(100))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
employee = relationship("Employee", back_populates="change_logs")

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class OrganizationUnit(Base):
__tablename__ = "organization_units"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
unit_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
unit_type: Mapped[str] = mapped_column(String(30), default="department", index=True)
parent_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
parent = relationship("OrganizationUnit", remote_side=[id], back_populates="children")
children = relationship("OrganizationUnit", back_populates="parent")
employees = relationship("Employee", back_populates="organization_unit")

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
role_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
description: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
employees = relationship("Employee", secondary="employee_role_links", back_populates="roles")

View File

@@ -1,17 +1,93 @@
from sqlalchemy.orm import Session
from __future__ import annotations
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.role import Role
class EmployeeRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(self) -> list[Employee]:
return self.db.query(Employee).order_by(Employee.created_at.desc()).all()
def list(self, status: str | None = None, keyword: str | None = None) -> list[Employee]:
stmt = (
select(Employee)
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
selectinload(Employee.change_logs),
)
.order_by(Employee.updated_at.desc(), Employee.name.asc())
)
if status and status != "全部员工":
stmt = stmt.where(Employee.employment_status == status)
if keyword:
pattern = f"%{keyword.strip()}%"
stmt = stmt.where(
or_(
Employee.name.ilike(pattern),
Employee.employee_no.ilike(pattern),
Employee.email.ilike(pattern),
Employee.position.ilike(pattern),
)
)
return list(self.db.execute(stmt).scalars().unique().all())
def get(self, employee_id: str) -> Employee | None:
return self.db.query(Employee).filter(Employee.id == employee_id).first()
stmt = (
select(Employee)
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
selectinload(Employee.change_logs),
)
.where(Employee.id == employee_id)
)
return self.db.execute(stmt).scalars().unique().first()
def get_by_employee_no(self, employee_no: str) -> Employee | None:
stmt = select(Employee).where(Employee.employee_no == employee_no)
return self.db.execute(stmt).scalars().first()
def get_by_email(self, email: str) -> Employee | None:
stmt = select(Employee).where(Employee.email == email)
return self.db.execute(stmt).scalars().first()
def list_roles(self) -> list[Role]:
stmt = select(Role)
return list(self.db.execute(stmt).scalars().all())
def get_role_by_code(self, role_code: str) -> Role | None:
stmt = select(Role).where(Role.role_code == role_code)
return self.db.execute(stmt).scalars().first()
def list_organization_units(self) -> list[OrganizationUnit]:
stmt = select(OrganizationUnit)
return list(self.db.execute(stmt).scalars().all())
def get_organization_by_code(self, unit_code: str) -> OrganizationUnit | None:
stmt = select(OrganizationUnit).where(OrganizationUnit.unit_code == unit_code)
return self.db.execute(stmt).scalars().first()
def count_employees(self) -> int:
stmt = select(func.count()).select_from(Employee)
return int(self.db.execute(stmt).scalar_one())
def count_roles(self) -> int:
stmt = select(func.count()).select_from(Role)
return int(self.db.execute(stmt).scalar_one())
def count_organization_units(self) -> int:
stmt = select(func.count()).select_from(OrganizationUnit)
return int(self.db.execute(stmt).scalar_one())
def create(self, employee: Employee) -> Employee:
self.db.add(employee)

View File

@@ -1,24 +1,102 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, EmailStr
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class EmployeeCreate(BaseModel):
employee_no: str
class EmployeeHistoryRead(BaseModel):
action: str
owner: str
time: str
occurredAt: str
class EmployeeOrganizationRead(BaseModel):
id: str
code: str
name: str
department: str
email: EmailStr
unitType: str
costCenter: str | None = None
location: str | None = None
managerName: str | None = None
class EmployeeRoleOptionRead(BaseModel):
id: str
code: str
label: str
desc: str
permissions: list[str] = Field(default_factory=list)
class EmployeeStatusSummaryRead(BaseModel):
id: str
label: str
count: int
class EmployeeMetaRead(BaseModel):
totalEmployees: int
statusSummary: list[EmployeeStatusSummaryRead]
roleOptions: list[EmployeeRoleOptionRead]
class EmployeeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=False)
id: str
employee_no: str
avatar: str
name: str
employeeNo: str
department: str
position: str
grade: str
manager: str
financeOwner: str
roles: list[str] = Field(default_factory=list)
roleCodes: list[str] = Field(default_factory=list)
status: str
statusTone: str
gender: str | None = None
age: int | None = None
birthDate: str | None = None
email: EmailStr
created_at: datetime
updated_at: datetime
phone: str | None = None
joinDate: str | None = None
location: str | None = None
costCenter: str | None = None
updatedAt: str | None = None
lastSync: str | None = None
syncState: str
spotlight: bool = False
permissions: list[str] = Field(default_factory=list)
history: list[EmployeeHistoryRead] = Field(default_factory=list)
organization: EmployeeOrganizationRead | None = None
class EmployeeCreate(BaseModel):
employee_no: str = Field(min_length=1, max_length=50)
name: str = Field(min_length=1, max_length=100)
email: EmailStr
gender: str | None = Field(default=None, max_length=20)
birth_date: str | None = None
phone: str | None = Field(default=None, max_length=30)
join_date: str | None = None
location: str | None = Field(default=None, max_length=100)
position: str = Field(default="员工", max_length=100)
grade: str = Field(default="P3", max_length=20)
cost_center: str | None = Field(default=None, max_length=50)
finance_owner_name: str | None = Field(default=None, max_length=100)
employment_status: str = Field(default="在职", max_length=30)
sync_state: str = Field(default="已同步", max_length=30)
spotlight: bool = False
organization_unit_code: str | None = Field(default=None, max_length=50)
manager_employee_no: str | None = Field(default=None, max_length=50)
role_codes: list[str] = Field(default_factory=lambda: ["user"])
def parsed_birth_date(self) -> date | None:
return datetime.strptime(self.birth_date, "%Y-%m-%d").date() if self.birth_date else None
def parsed_join_date(self) -> date | None:
return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None

View File

@@ -1,34 +1,445 @@
from __future__ import annotations
from collections import Counter
from datetime import date, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.logging import get_logger
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
from app.schemas.employee import (
EmployeeCreate,
EmployeeHistoryRead,
EmployeeMetaRead,
EmployeeOrganizationRead,
EmployeeRead,
EmployeeRoleOptionRead,
EmployeeStatusSummaryRead,
)
from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
ROLE_PERMISSION_MAP,
)
logger = get_logger("app.services.employee")
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:
EmployeeService(db).ensure_directory_ready()
class EmployeeService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = EmployeeRepository(db)
def list_employees(self) -> list[Employee]:
employees = self.repository.list()
logger.info("Listed employees (count=%d)", len(employees))
return employees
def ensure_directory_ready(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._prune_extra_seed_employees()
self._seed_roles()
self._seed_organization_units()
self._seed_employees()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to prepare employee directory")
raise
def get_employee(self, employee_id: str) -> Employee | None:
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:
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
else:
if employee is None:
logger.warning("Employee not found id=%s", employee_id)
return employee
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
]
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())
]
return EmployeeMetaRead(
totalEmployees=len(employees),
statusSummary=status_summary,
roleOptions=role_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,
employment_status=payload.employment_status,
sync_state=payload.sync_state,
spotlight=payload.spotlight,
last_sync_at=datetime.now(),
)
if payload.organization_unit_code:
employee.organization_unit = self.repository.get_organization_by_code(payload.organization_unit_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)
def create_employee(self, payload: EmployeeCreate) -> Employee:
employee = Employee(**payload.model_dump())
created = self.repository.create(employee)
logger.info(
"Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name
)
return created
hydrated = self.repository.get(created.id)
return self._serialize_employee(hydrated or created)
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
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
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
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"]]
if organization.parent_id:
continue
parent = existing_by_code.get(parent_code)
if parent is not None:
organization.parent = parent
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=self._parse_date(definition.get("birth_date")),
phone=definition.get("phone"),
join_date=self._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"),
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")),
)
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.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 _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 _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = {
(item.action, item.owner, self._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 = self._parse_datetime(history.get("occurred_at"))
if occurred_at is None:
continue
identity = (
history["action"],
history["owner"],
self._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 _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_datetime(item.occurred_at) or "",
occurredAt=self._format_datetime(item.occurred_at) or "",
)
for item in 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",
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
),
)
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))
@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 _format_datetime(value: datetime | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d %H:%M")
@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

View File

@@ -0,0 +1,986 @@
from __future__ import annotations
ROLE_DISPLAY_ORDER = {
"manager": 1,
"finance": 2,
"approver": 3,
"executive": 4,
"auditor": 5,
"user": 6,
}
ROLE_DEFINITIONS = [
{
"role_code": "user",
"name": "使用者",
"description": "可以发起报销、查看个人单据和使用 AI 助手。",
},
{
"role_code": "finance",
"name": "财务人员",
"description": "可以处理复核、查看财务知识与风险校验结果。",
},
{
"role_code": "manager",
"name": "管理员",
"description": "可以维护员工档案、组织结构和角色权限。",
},
{
"role_code": "executive",
"name": "高级管理人员",
"description": "可以查看跨部门数据看板与关键审批结果。",
},
{
"role_code": "approver",
"name": "审批负责人",
"description": "可以处理审批中心中的待审单据。",
},
{
"role_code": "auditor",
"name": "审计观察员",
"description": "可以查看变更记录和权限调整历史。",
},
]
ROLE_PERMISSION_MAP = {
"user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"],
"finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"],
"manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"],
"executive": ["可查看跨部门经营看板", "可处理高金额报销最终审批"],
"approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"],
"auditor": ["可查看权限变更与审计留痕", "可导出员工权限观察记录"],
}
ORGANIZATION_DEFINITIONS = [
{
"unit_code": "ORG-ROOT",
"name": "星海科技",
"unit_type": "company",
"parent_code": None,
"cost_center": "CC-0000",
"location": "上海",
"manager_name": "李文静",
},
{
"unit_code": "EXEC-OFFICE",
"name": "总经办",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-1001",
"location": "上海",
"manager_name": "李文静",
},
{
"unit_code": "FIN-SSC",
"name": "财务共享中心",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-2108",
"location": "上海",
"manager_name": "张晓晴",
},
{
"unit_code": "HR-OD",
"name": "人力与组织",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-3206",
"location": "杭州",
"manager_name": "陈硕",
},
{
"unit_code": "SALES-SOUTH",
"name": "华南销售部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-4102",
"location": "深圳",
"manager_name": "陈嘉",
},
{
"unit_code": "SALES-EAST",
"name": "华东销售部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-4108",
"location": "上海",
"manager_name": "秦墨然",
},
{
"unit_code": "MKT-BRAND",
"name": "市场品牌部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-5203",
"location": "北京",
"manager_name": "刘思雨",
},
{
"unit_code": "RND-CENTER",
"name": "产品研发中心",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-6105",
"location": "北京",
"manager_name": "吴磊",
},
{
"unit_code": "OPS-ADMIN",
"name": "行政采购部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-7204",
"location": "南京",
"manager_name": "梁雨辰",
},
{
"unit_code": "AUDIT-RISK",
"name": "风控与审计部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-8102",
"location": "上海",
"manager_name": "顾承宇",
},
]
EMPLOYEE_DEFINITIONS = [
{
"employee_no": "E10018",
"name": "李文静",
"gender": "",
"birth_date": "1987-03-26",
"phone": "13900187688",
"email": "wenjing.li@xfinance.com",
"join_date": "2018-06-21",
"location": "上海",
"position": "高级财务总监",
"grade": "D2",
"organization_unit_code": "EXEC-OFFICE",
"manager_employee_no": None,
"finance_owner_name": "集团财务",
"cost_center": "CC-1001",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 16:20",
"last_sync_at": "2026-05-05 16:20",
"role_codes": ["executive", "approver"],
},
{
"employee_no": "E10234",
"name": "张晓晴",
"gender": "",
"birth_date": "1994-08-12",
"phone": "13810234567",
"email": "xiaoqing.zhang@xfinance.com",
"join_date": "2021-03-15",
"location": "上海",
"position": "费用运营经理",
"grade": "M3",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10018",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2108",
"employment_status": "在职",
"sync_state": "待生效",
"spotlight": True,
"updated_at": "2026-05-06 10:24",
"last_sync_at": "2026-05-06 10:24",
"role_codes": ["manager", "finance", "approver"],
"history": [
{
"action": "新增“审批负责人”角色",
"owner": "系统管理员 · 王敏",
"occurred_at": "2026-05-06 10:24",
},
{
"action": "调整财务归口为华东财务组",
"owner": "组织管理员 · 陈硕",
"occurred_at": "2026-05-05 18:10",
},
],
},
{
"employee_no": "E10258",
"name": "孙楠",
"gender": "",
"birth_date": "1992-09-17",
"phone": "13722580312",
"email": "nan.sun@xfinance.com",
"join_date": "2020-11-09",
"location": "上海",
"position": "财务分析师",
"grade": "P5",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2111",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 15:18",
"last_sync_at": "2026-05-04 15:18",
"role_codes": ["finance"],
},
{
"employee_no": "E10271",
"name": "周悦宁",
"gender": "",
"birth_date": "1993-04-21",
"phone": "13622711986",
"email": "yuening.zhou@xfinance.com",
"join_date": "2021-07-05",
"location": "上海",
"position": "财务系统专员",
"grade": "P5",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2112",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 09:35",
"last_sync_at": "2026-05-07 09:10",
"role_codes": ["finance", "auditor"],
},
{
"employee_no": "E10289",
"name": "高嘉禾",
"gender": "",
"birth_date": "1996-02-14",
"phone": "13522895642",
"email": "jiahe.gao@xfinance.com",
"join_date": "2023-01-10",
"location": "上海",
"position": "差旅合规专员",
"grade": "P4",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2115",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 11:42",
"last_sync_at": "2026-05-03 11:42",
"role_codes": ["finance"],
},
{
"employee_no": "E10867",
"name": "王敏",
"gender": "",
"birth_date": "1996-11-05",
"phone": "13688671200",
"email": "min.wang@xfinance.com",
"join_date": "2022-08-08",
"location": "杭州",
"position": "组织发展主管",
"grade": "P6",
"organization_unit_code": "HR-OD",
"manager_employee_no": "E11618",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3206",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 09:18",
"last_sync_at": "2026-05-05 09:18",
"role_codes": ["manager", "auditor"],
},
{
"employee_no": "E11618",
"name": "陈硕",
"gender": "",
"birth_date": "1990-05-09",
"phone": "13816186540",
"email": "shuo.chen@xfinance.com",
"join_date": "2019-09-16",
"location": "杭州",
"position": "人力资源经理",
"grade": "M2",
"organization_unit_code": "HR-OD",
"manager_employee_no": "E10018",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3201",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 17:08",
"last_sync_at": "2026-05-04 17:08",
"role_codes": ["manager", "approver"],
},
{
"employee_no": "E12311",
"name": "何思成",
"gender": "",
"birth_date": "1998-07-19",
"phone": "13723117654",
"email": "sicheng.he@xfinance.com",
"join_date": "2026-02-17",
"location": "杭州",
"position": "HRBP",
"grade": "P4",
"organization_unit_code": "HR-OD",
"manager_employee_no": "E11618",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3208",
"employment_status": "试用中",
"sync_state": "待生效",
"spotlight": False,
"updated_at": "2026-05-07 08:42",
"last_sync_at": "2026-05-07 08:42",
"role_codes": ["user"],
},
{
"employee_no": "E11026",
"name": "刘思雨",
"gender": "",
"birth_date": "1991-12-03",
"phone": "13921036540",
"email": "siyu.liu@xfinance.com",
"join_date": "2020-04-13",
"location": "北京",
"position": "品牌市场经理",
"grade": "M2",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E10018",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5203",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 14:36",
"last_sync_at": "2026-05-06 14:36",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12408",
"name": "冯可欣",
"gender": "",
"birth_date": "1997-10-28",
"phone": "13624085542",
"email": "kexin.feng@xfinance.com",
"join_date": "2024-03-11",
"location": "北京",
"position": "品牌策划",
"grade": "P4",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5207",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 10:02",
"last_sync_at": "2026-05-07 09:48",
"role_codes": ["user"],
},
{
"employee_no": "E12419",
"name": "许泽航",
"gender": "",
"birth_date": "1995-05-15",
"phone": "13524199508",
"email": "zehang.xu@xfinance.com",
"join_date": "2023-06-19",
"location": "北京",
"position": "数字营销专员",
"grade": "P4",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5209",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 16:52",
"last_sync_at": "2026-05-03 16:52",
"role_codes": ["user"],
},
{
"employee_no": "E11602",
"name": "陈嘉",
"gender": "",
"birth_date": "1997-02-18",
"phone": "13716029901",
"email": "jia.chen@xfinance.com",
"join_date": "2026-03-01",
"location": "深圳",
"position": "区域销售经理",
"grade": "M2",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E10018",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4102",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 14:12",
"last_sync_at": "2026-05-04 14:12",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12476",
"name": "马骁然",
"gender": "",
"birth_date": "1994-01-08",
"phone": "13824760139",
"email": "xiaoran.ma@xfinance.com",
"join_date": "2022-09-05",
"location": "深圳",
"position": "销售运营专家",
"grade": "P5",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4106",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 18:15",
"last_sync_at": "2026-05-06 18:15",
"role_codes": ["user"],
},
{
"employee_no": "E12508",
"name": "唐子墨",
"gender": "",
"birth_date": "1996-06-11",
"phone": "13925088761",
"email": "zimo.tang@xfinance.com",
"join_date": "2024-02-26",
"location": "深圳",
"position": "大客户代表",
"grade": "P4",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4109",
"employment_status": "停用",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-01 11:06",
"last_sync_at": "2026-05-01 11:06",
"role_codes": ["user"],
},
{
"employee_no": "E12514",
"name": "罗欣怡",
"gender": "",
"birth_date": "2000-03-02",
"phone": "13625141227",
"email": "xinyi.luo@xfinance.com",
"join_date": "2026-02-24",
"location": "深圳",
"position": "销售协调专员",
"grade": "P3",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4112",
"employment_status": "试用中",
"sync_state": "待生效",
"spotlight": False,
"updated_at": "2026-05-05 15:42",
"last_sync_at": "2026-05-05 15:42",
"role_codes": ["user"],
},
{
"employee_no": "E11745",
"name": "吴磊",
"gender": "",
"birth_date": "1989-09-27",
"phone": "13817459812",
"email": "lei.wu@xfinance.com",
"join_date": "2019-12-09",
"location": "北京",
"position": "研发平台主管",
"grade": "M3",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E10018",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6105",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 13:08",
"last_sync_at": "2026-05-06 13:08",
"role_codes": ["user", "approver", "auditor"],
},
{
"employee_no": "E11991",
"name": "赵明",
"gender": "",
"birth_date": "1994-06-09",
"phone": "13519913300",
"email": "ming.zhao@xfinance.com",
"join_date": "2023-11-18",
"location": "北京",
"position": "产品经理",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6112",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-02 11:32",
"last_sync_at": "2026-05-02 11:32",
"role_codes": ["user"],
},
{
"employee_no": "E12611",
"name": "彭一凡",
"gender": "",
"birth_date": "1995-02-03",
"phone": "13726114588",
"email": "yifan.peng@xfinance.com",
"join_date": "2022-04-18",
"location": "北京",
"position": "后端工程师",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6114",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 09:44",
"last_sync_at": "2026-05-06 09:44",
"role_codes": ["user"],
},
{
"employee_no": "E12618",
"name": "苏清禾",
"gender": "",
"birth_date": "1994-12-25",
"phone": "13626188763",
"email": "qinghe.su@xfinance.com",
"join_date": "2022-05-16",
"location": "北京",
"position": "数据工程师",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6116",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 10:26",
"last_sync_at": "2026-05-07 10:18",
"role_codes": ["user"],
},
{
"employee_no": "E12624",
"name": "沈知远",
"gender": "",
"birth_date": "1992-11-06",
"phone": "13926241855",
"email": "zhiyuan.shen@xfinance.com",
"join_date": "2021-11-22",
"location": "北京",
"position": "测试负责人",
"grade": "P6",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6119",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 13:12",
"last_sync_at": "2026-05-05 13:12",
"role_codes": ["user"],
},
{
"employee_no": "E11852",
"name": "周晓彤",
"gender": "",
"birth_date": "1997-05-27",
"phone": "13818529954",
"email": "xiaotong.zhou@xfinance.com",
"join_date": "2022-06-30",
"location": "南京",
"position": "行政采购专员",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7204",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 11:22",
"last_sync_at": "2026-05-05 11:22",
"role_codes": ["user"],
},
{
"employee_no": "E12653",
"name": "梁雨辰",
"gender": "",
"birth_date": "1991-08-30",
"phone": "13726539876",
"email": "yuchen.liang@xfinance.com",
"join_date": "2021-01-04",
"location": "南京",
"position": "行政运营经理",
"grade": "M1",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E10018",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7201",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 17:44",
"last_sync_at": "2026-05-06 17:44",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12661",
"name": "顾承宇",
"gender": "",
"birth_date": "1988-04-16",
"phone": "13926614528",
"email": "chengyu.gu@xfinance.com",
"join_date": "2020-02-03",
"location": "上海",
"position": "风控审计经理",
"grade": "M2",
"organization_unit_code": "AUDIT-RISK",
"manager_employee_no": "E10018",
"finance_owner_name": "集团财务",
"cost_center": "CC-8102",
"employment_status": "在职",
"sync_state": "待生效",
"spotlight": True,
"updated_at": "2026-05-07 09:52",
"last_sync_at": "2026-05-07 09:52",
"role_codes": ["auditor", "finance"],
"history": [
{
"action": "更新审计观察范围",
"owner": "系统管理员 · 张晓晴",
"occurred_at": "2026-05-07 09:52",
},
{
"action": "补充高风险费用抽样规则",
"owner": "审计管理员 · 王敏",
"occurred_at": "2026-05-06 18:30",
},
],
},
{
"employee_no": "E12679",
"name": "郑若彤",
"gender": "",
"birth_date": "1997-09-13",
"phone": "13626794520",
"email": "ruotong.zheng@xfinance.com",
"join_date": "2024-01-08",
"location": "上海",
"position": "审计专员",
"grade": "P4",
"organization_unit_code": "AUDIT-RISK",
"manager_employee_no": "E12661",
"finance_owner_name": "集团财务",
"cost_center": "CC-8105",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 08:58",
"last_sync_at": "2026-05-07 08:40",
"role_codes": ["auditor"],
},
{
"employee_no": "E12688",
"name": "方逸晨",
"gender": "",
"birth_date": "1995-01-20",
"phone": "13526881142",
"email": "yichen.fang@xfinance.com",
"join_date": "2023-08-14",
"location": "南京",
"position": "采购合规分析师",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7208",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 14:16",
"last_sync_at": "2026-05-03 14:16",
"role_codes": ["user", "finance"],
},
{
"employee_no": "E12067",
"name": "秦墨然",
"gender": "",
"birth_date": "1990-10-10",
"phone": "13820674519",
"email": "moran.qin@xfinance.com",
"join_date": "2020-07-20",
"location": "上海",
"position": "华东销售总监",
"grade": "M2",
"organization_unit_code": "SALES-EAST",
"manager_employee_no": "E10018",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4108",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 12:40",
"last_sync_at": "2026-05-06 12:40",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12703",
"name": "宋知夏",
"gender": "",
"birth_date": "1994-07-07",
"phone": "13727031129",
"email": "zhixia.song@xfinance.com",
"join_date": "2022-12-12",
"location": "上海",
"position": "重点客户经理",
"grade": "P5",
"organization_unit_code": "SALES-EAST",
"manager_employee_no": "E12067",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4111",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 10:58",
"last_sync_at": "2026-05-04 10:58",
"role_codes": ["user"],
},
{
"employee_no": "E12716",
"name": "杜嘉宁",
"gender": "",
"birth_date": "1999-11-16",
"phone": "13627161248",
"email": "jianing.du@xfinance.com",
"join_date": "2026-01-19",
"location": "上海",
"position": "销售代表",
"grade": "P3",
"organization_unit_code": "SALES-EAST",
"manager_employee_no": "E12067",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4114",
"employment_status": "试用中",
"sync_state": "待生效",
"spotlight": False,
"updated_at": "2026-05-05 12:26",
"last_sync_at": "2026-05-05 12:26",
"role_codes": ["user"],
},
{
"employee_no": "E12722",
"name": "邵宁远",
"gender": "",
"birth_date": "1998-12-01",
"phone": "13527221506",
"email": "ningyuan.shao@xfinance.com",
"join_date": "2026-02-08",
"location": "北京",
"position": "数据分析师",
"grade": "P4",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6122",
"employment_status": "试用中",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 09:06",
"last_sync_at": "2026-05-07 08:55",
"role_codes": ["user"],
},
{
"employee_no": "E12739",
"name": "林可昕",
"gender": "",
"birth_date": "1996-10-23",
"phone": "13827394510",
"email": "kexin.lin@xfinance.com",
"join_date": "2023-04-17",
"location": "上海",
"position": "费用核算专员",
"grade": "P4",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2118",
"employment_status": "停用",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-04-30 18:05",
"last_sync_at": "2026-04-30 18:05",
"role_codes": ["finance"],
},
{
"employee_no": "E12744",
"name": "赵予安",
"gender": "",
"birth_date": "1993-01-30",
"phone": "13727442139",
"email": "yuan.zhao@xfinance.com",
"join_date": "2021-10-11",
"location": "上海",
"position": "预算控制经理",
"grade": "M1",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "集团财务",
"cost_center": "CC-2120",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 15:34",
"last_sync_at": "2026-05-06 15:34",
"role_codes": ["finance", "approver"],
},
{
"employee_no": "E12750",
"name": "谢知行",
"gender": "",
"birth_date": "1995-09-14",
"phone": "13627501386",
"email": "zhixing.xie@xfinance.com",
"join_date": "2022-07-25",
"location": "深圳",
"position": "渠道销售经理",
"grade": "P5",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4116",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 09:48",
"last_sync_at": "2026-05-04 09:48",
"role_codes": ["user"],
},
{
"employee_no": "E12758",
"name": "顾南枝",
"gender": "",
"birth_date": "1994-04-12",
"phone": "13827584522",
"email": "nanzhi.gu@xfinance.com",
"join_date": "2022-05-09",
"location": "北京",
"position": "内容运营经理",
"grade": "P5",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5211",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 11:08",
"last_sync_at": "2026-05-07 10:50",
"role_codes": ["user"],
},
{
"employee_no": "E12763",
"name": "孟书言",
"gender": "",
"birth_date": "1992-02-09",
"phone": "13527633148",
"email": "shuyan.meng@xfinance.com",
"join_date": "2021-06-28",
"location": "北京",
"position": "架构工程师",
"grade": "P6",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6125",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 19:05",
"last_sync_at": "2026-05-06 19:05",
"role_codes": ["user"],
},
{
"employee_no": "E12771",
"name": "孔令谦",
"gender": "",
"birth_date": "1993-07-18",
"phone": "13627711572",
"email": "lingqian.kong@xfinance.com",
"join_date": "2021-09-13",
"location": "南京",
"position": "供应商管理专员",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7210",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-02 17:22",
"last_sync_at": "2026-05-02 17:22",
"role_codes": ["user"],
},
{
"employee_no": "E12782",
"name": "乔语岚",
"gender": "",
"birth_date": "1996-05-06",
"phone": "13727823045",
"email": "yulan.qiao@xfinance.com",
"join_date": "2023-03-06",
"location": "上海",
"position": "风控策略分析师",
"grade": "P4",
"organization_unit_code": "AUDIT-RISK",
"manager_employee_no": "E12661",
"finance_owner_name": "集团财务",
"cost_center": "CC-8108",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 13:18",
"last_sync_at": "2026-05-03 13:18",
"role_codes": ["auditor"],
},
{
"employee_no": "E12790",
"name": "邹闻韬",
"gender": "",
"birth_date": "1991-03-11",
"phone": "13827903167",
"email": "wentao.zou@xfinance.com",
"join_date": "2020-10-26",
"location": "上海",
"position": "合规产品负责人",
"grade": "P7",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6128",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 08:56",
"last_sync_at": "2026-05-06 08:56",
"role_codes": ["user", "auditor"],
},
]

View File

@@ -15,10 +15,13 @@ src/app/api/v1/endpoints/reimbursements.py
src/app/core/__init__.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/core/logging.py
src/app/db/__init__.py
src/app/db/base.py
src/app/db/base_class.py
src/app/db/session.py
src/app/middleware/__init__.py
src/app/middleware/logging.py
src/app/models/__init__.py
src/app/models/approval.py
src/app/models/employee.py

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
export MSYS_NO_PATHCONV=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -33,26 +35,31 @@ set +a
SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
SERVER_PORT="${SERVER_PORT:-8000}"
SERVER_RELOAD="${SERVER_RELOAD:-false}"
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
is_msys() {
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
needs_windows_python() {
is_msys || is_wsl
}
find_unix_python() {
if command -v python >/dev/null 2>&1; then
echo "python"
if command -v python3 >/dev/null 2>&1; then
echo "python3"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
echo "python3"
if command -v python >/dev/null 2>&1; then
echo "python"
return 0
fi
@@ -74,22 +81,6 @@ find_windows_python() {
}
venv_python_path() {
if [ "${VENV_LAYOUT:-auto}" = "windows" ]; then
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
echo "$VENV_DIR/Scripts/python.exe"
return 0
fi
return 1
fi
if [ "${VENV_LAYOUT:-auto}" = "unix" ]; then
if [ -x "$VENV_DIR/bin/python" ]; then
echo "$VENV_DIR/bin/python"
return 0
fi
return 1
fi
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
echo "$VENV_DIR/Scripts/python.exe"
return 0
@@ -152,30 +143,25 @@ ensure_pip() {
}
ensure_python_bootstrap() {
if is_wsl && is_windows_mount; then
if needs_windows_python; then
if find_windows_python >/dev/null 2>&1; then
PYTHON_BOOTSTRAP="$(find_windows_python)"
VENV_LAYOUT="windows"
info "Detected WSL on a Windows-mounted project"
info "Using Windows Python directly from bash"
info "Detected Windows bash environment — using Windows Python"
return 0
fi
if find_unix_python >/dev/null 2>&1; then
PYTHON_BOOTSTRAP="$(find_unix_python)"
VENV_LAYOUT="unix"
warn "Windows Python not found, falling back to WSL Python"
warn "Windows Python not found, falling back to system Python"
return 0
fi
error "Neither Windows Python nor WSL Python is available in PATH."
error "Python is not available in PATH."
fi
if ! PYTHON_BOOTSTRAP="$(find_unix_python)"; then
error "Python is not installed or not available in PATH. Install Python 3.11+ first so the script can create server/.venv automatically."
fi
VENV_LAYOUT="unix"
}
ensure_dependencies() {
@@ -210,7 +196,11 @@ start_server() {
info "Access: http://$SERVER_HOST:$SERVER_PORT"
echo ""
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
if [ "$SERVER_RELOAD" = "true" ]; then
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
fi
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
}
case "$MODE" in

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
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.services.employee import EmployeeService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_employee_directory_seeds_rich_employee_data() -> None:
with build_session() as db:
service = EmployeeService(db)
employees = service.list_employees()
meta = service.get_employee_meta()
assert len(employees) == 30
assert meta.totalEmployees == 30
assert any(item.status == "试用中" for item in employees)
assert any(item.status == "停用" for item in employees)
assert any("审批负责人" in item.roles for item in employees)
assert any(item.permissions for item in employees)
assert any(item.history for item in employees)
role_count = db.scalar(select(func.count()).select_from(Role))
org_count = db.scalar(select(func.count()).select_from(OrganizationUnit))
employee_count = db.scalar(select(func.count()).select_from(Employee))
history_count = db.scalar(select(func.count()).select_from(EmployeeChangeLog))
assert role_count == 6
assert org_count == 10
assert employee_count == 30
assert history_count and history_count >= 30
def test_employee_detail_contains_department_and_roles() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
detail = service.get_employee(employee.id)
assert detail is not None
assert detail.department
assert detail.manager
assert detail.organization is not None
assert detail.roles