feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点 - 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力 - 报销单提交补充身份回填、部门信息透传及预审结果展示优化 - 认证流程增加部门信息(departmentName)并在schema中同步扩展 - 用户Agent服务增加部门关联与报销单回填逻辑 - 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能 - 前端审批中心、审计、差旅报销等视图交互与样式优化 - 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
@@ -2,12 +2,19 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
|
||||
from app.schemas.employee import (
|
||||
EmployeeCreate,
|
||||
EmployeeImportResultRead,
|
||||
EmployeeMetaRead,
|
||||
EmployeeRead,
|
||||
EmployeeUpdate,
|
||||
)
|
||||
from app.services.employee import EmployeeService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -44,6 +51,67 @@ def list_employees(
|
||||
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/import-template",
|
||||
summary="下载员工导入模板",
|
||||
description="下载固定格式的员工 Excel 导入模板。",
|
||||
)
|
||||
def download_employee_import_template(db: DbSession) -> Response:
|
||||
content = EmployeeService(db).build_import_template()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出员工 Excel",
|
||||
description="按筛选条件导出员工目录 Excel 文件。",
|
||||
)
|
||||
def export_employees(
|
||||
db: DbSession,
|
||||
status_filter: Annotated[
|
||||
str | None,
|
||||
Query(alias="status", description="员工状态筛选值。"),
|
||||
] = None,
|
||||
keyword: Annotated[
|
||||
str | None,
|
||||
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
|
||||
] = None,
|
||||
) -> Response:
|
||||
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import",
|
||||
response_model=EmployeeImportResultRead,
|
||||
summary="导入员工 Excel",
|
||||
description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
|
||||
)
|
||||
async def import_employees(
|
||||
db: DbSession,
|
||||
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
|
||||
) -> EmployeeImportResultRead:
|
||||
filename = (file.filename or "").lower()
|
||||
if not filename.endswith(".xlsx"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前仅支持上传 .xlsx 格式的员工表格。",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
return EmployeeService(db).import_employees(content)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=EmployeeRead,
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemActionResponse,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
ReimbursementCreate,
|
||||
ReimbursementRead,
|
||||
)
|
||||
@@ -415,11 +416,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除个人报销草稿",
|
||||
description="删除当前登录用户可见的草稿报销单。",
|
||||
@router.post(
|
||||
"/claims/{claim_id}/return",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="退回报销单",
|
||||
description="财务人员或高级管理人员可将可见报销单退回到待补充状态。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -427,7 +428,40 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "仅草稿状态允许删除。",
|
||||
"description": "当前用户或单据状态不允许退回。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def return_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimReturnPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.return_claim(claim_id, current_user, reason=payload.reason)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "报销单不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许删除。",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -442,7 +476,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
|
||||
return ExpenseClaimActionResponse(
|
||||
message=f"{claim.claim_no} 草稿已删除。",
|
||||
message=f"{claim.claim_no} 报销单已删除。",
|
||||
claim_id=claim.id,
|
||||
status="deleted",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
@@ -16,6 +18,12 @@ class AuthUserRead(BaseModel):
|
||||
departmentName: str = ""
|
||||
position: str = ""
|
||||
grade: str = ""
|
||||
employeeNo: str = ""
|
||||
managerName: str = ""
|
||||
location: str = ""
|
||||
costCenter: str = ""
|
||||
financeOwnerName: str = ""
|
||||
riskProfile: dict[str, Any] = Field(default_factory=dict)
|
||||
roleCodes: list[str] = Field(default_factory=list)
|
||||
email: EmailStr | str
|
||||
avatar: str
|
||||
|
||||
@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
|
||||
totalEmployees: int
|
||||
statusSummary: list[EmployeeStatusSummaryRead]
|
||||
roleOptions: list[EmployeeRoleOptionRead]
|
||||
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeRead(BaseModel):
|
||||
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
|
||||
position: str
|
||||
grade: str
|
||||
manager: str
|
||||
managerEmployeeNo: str | None = None
|
||||
financeOwner: str
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
roleCodes: list[str] = Field(default_factory=list)
|
||||
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
|
||||
return _parse_optional_date(self.join_date, "入职日期")
|
||||
|
||||
|
||||
class EmployeeImportErrorRead(BaseModel):
|
||||
row: int
|
||||
column: str
|
||||
employeeNo: str = ""
|
||||
message: str
|
||||
|
||||
|
||||
class EmployeeImportSummaryRead(BaseModel):
|
||||
totalRows: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errorCount: int = 0
|
||||
|
||||
|
||||
class EmployeeImportResultRead(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
summary: EmployeeImportSummaryRead
|
||||
errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
|
||||
importedAt: str | None = None
|
||||
|
||||
|
||||
class EmployeeUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
gender: str | None = Field(default=None, max_length=20)
|
||||
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
|
||||
grade: str | None = Field(default=None, min_length=1, max_length=20)
|
||||
cost_center: str | None = Field(default=None, max_length=50)
|
||||
finance_owner_name: str | None = Field(default=None, max_length=100)
|
||||
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] | None = None
|
||||
password: str | None = Field(default=None, min_length=5, max_length=128)
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ class ExpenseClaimActionResponse(BaseModel):
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ExpenseClaimReturnPayload(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||
message: str
|
||||
claim_id: str
|
||||
|
||||
@@ -85,6 +85,8 @@ class UserAgentReviewRiskBrief(BaseModel):
|
||||
title: str = Field(description="风险或注意事项标题。")
|
||||
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
||||
content: str = Field(description="面向用户展示的摘要说明。")
|
||||
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
|
||||
suggestion: str = Field(default="", description="面向用户的处理建议。")
|
||||
|
||||
|
||||
class UserAgentReviewSlotCard(BaseModel):
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import verify_password
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||
@@ -34,6 +37,12 @@ class AuthenticatedUser:
|
||||
department: str
|
||||
position: str
|
||||
grade: str
|
||||
employee_no: str
|
||||
manager_name: str
|
||||
location: str
|
||||
cost_center: str
|
||||
finance_owner_name: str
|
||||
risk_profile: dict[str, Any]
|
||||
role_codes: list[str]
|
||||
email: str
|
||||
avatar: str
|
||||
@@ -82,6 +91,12 @@ class AuthService:
|
||||
department="",
|
||||
position="系统管理员",
|
||||
grade="",
|
||||
employee_no="",
|
||||
manager_name="",
|
||||
location="",
|
||||
cost_center="",
|
||||
finance_owner_name="",
|
||||
risk_profile={},
|
||||
role_codes=["manager"],
|
||||
email=admin_email or f"{admin_username}@local",
|
||||
avatar=display_name[:1].upper(),
|
||||
@@ -96,7 +111,11 @@ class AuthService:
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
@@ -117,20 +136,75 @@ class AuthService:
|
||||
)
|
||||
role_codes = [role.role_code for role in sorted_roles]
|
||||
primary_role_code = role_codes[0] if role_codes else "user"
|
||||
department = employee.organization_unit.name if employee.organization_unit is not None else ""
|
||||
manager_name = self._resolve_manager_name(employee)
|
||||
|
||||
return AuthenticatedUser(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||
department=employee.organization_unit.name if employee.organization_unit is not None else "",
|
||||
department=department,
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
employee_no=employee.employee_no,
|
||||
manager_name=manager_name,
|
||||
location=employee.location or "",
|
||||
cost_center=employee.cost_center or "",
|
||||
finance_owner_name=employee.finance_owner_name or "",
|
||||
risk_profile=self._build_risk_profile(employee),
|
||||
role_codes=role_codes or ["user"],
|
||||
email=employee.email,
|
||||
avatar=(employee.name or "?")[:1].upper(),
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_manager_name(employee: Employee) -> str:
|
||||
if employee.manager is not None and employee.manager.name:
|
||||
return str(employee.manager.name).strip()
|
||||
if employee.organization_unit is not None and employee.organization_unit.manager_name:
|
||||
return str(employee.organization_unit.manager_name).strip()
|
||||
return ""
|
||||
|
||||
def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
|
||||
since = datetime.now(UTC) - timedelta(days=90)
|
||||
identity_values = [
|
||||
str(employee.name or "").strip(),
|
||||
str(employee.email or "").strip(),
|
||||
str(employee.employee_no or "").strip(),
|
||||
]
|
||||
name_candidates = [item for item in dict.fromkeys(identity_values) if item]
|
||||
conditions = [ExpenseClaim.employee_id == employee.id]
|
||||
if name_candidates:
|
||||
conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
|
||||
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(or_(*conditions), ExpenseClaim.occurred_at >= since)
|
||||
.order_by(ExpenseClaim.occurred_at.desc())
|
||||
.limit(30)
|
||||
)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
recent_risk_flags: list[str] = []
|
||||
for claim in claims:
|
||||
for flag in claim.risk_flags_json or []:
|
||||
normalized = str(flag or "").strip()
|
||||
if normalized and normalized not in recent_risk_flags:
|
||||
recent_risk_flags.append(normalized)
|
||||
if len(recent_risk_flags) >= 6:
|
||||
break
|
||||
if len(recent_risk_flags) >= 6:
|
||||
break
|
||||
|
||||
return {
|
||||
"windowDays": 90,
|
||||
"totalClaimCount": len(claims),
|
||||
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
||||
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
||||
"recentRiskFlags": recent_risk_flags,
|
||||
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
||||
return AuthUserRead(
|
||||
@@ -141,6 +215,12 @@ class AuthService:
|
||||
departmentName=user.department,
|
||||
position=user.position,
|
||||
grade=user.grade,
|
||||
employeeNo=user.employee_no,
|
||||
managerName=user.manager_name,
|
||||
location=user.location,
|
||||
costCenter=user.cost_center,
|
||||
financeOwnerName=user.finance_owner_name,
|
||||
riskProfile=user.risk_profile,
|
||||
roleCodes=user.role_codes,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
EMPLOYEE_PROFILE_REPAIRS = [
|
||||
{
|
||||
"employee_no": "E90919",
|
||||
"name": "曹笑竹",
|
||||
"email": "caoxiaozhu@xf.com",
|
||||
"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": "已同步",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
]
|
||||
|
||||
EMPLOYEE_DEFINITIONS = [
|
||||
{
|
||||
"employee_no": "E10018",
|
||||
|
||||
368
server/src/app/services/employee_spreadsheet.py
Normal file
368
server/src/app/services/employee_spreadsheet.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from email.utils import parseaddr
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
EMPLOYEE_SHEET_NAME = "员工目录"
|
||||
INSTRUCTION_SHEET_NAME = "填表说明"
|
||||
|
||||
EMPLOYEE_HEADERS: tuple[str, ...] = (
|
||||
"员工编号*",
|
||||
"姓名*",
|
||||
"邮箱*",
|
||||
"性别",
|
||||
"出生日期",
|
||||
"手机号",
|
||||
"入职日期",
|
||||
"办公地点",
|
||||
"岗位*",
|
||||
"职级*",
|
||||
"部门编码",
|
||||
"直属上级工号",
|
||||
"财务归口",
|
||||
"成本中心",
|
||||
"在职状态*",
|
||||
"角色编码",
|
||||
)
|
||||
|
||||
HEADER_TO_FIELD: dict[str, str] = {
|
||||
"员工编号*": "employee_no",
|
||||
"姓名*": "name",
|
||||
"邮箱*": "email",
|
||||
"性别": "gender",
|
||||
"出生日期": "birth_date",
|
||||
"手机号": "phone",
|
||||
"入职日期": "join_date",
|
||||
"办公地点": "location",
|
||||
"岗位*": "position",
|
||||
"职级*": "grade",
|
||||
"部门编码": "organization_unit_code",
|
||||
"直属上级工号": "manager_employee_no",
|
||||
"财务归口": "finance_owner_name",
|
||||
"成本中心": "cost_center",
|
||||
"在职状态*": "employment_status",
|
||||
"角色编码": "role_codes",
|
||||
}
|
||||
|
||||
VALID_EMPLOYMENT_STATUSES = {"在职", "试用中", "停用"}
|
||||
DEFAULT_ROLE_CODES = ("user",)
|
||||
MAX_IMPORT_ROWS = 2000
|
||||
MAX_IMPORT_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmployeeImportRow:
|
||||
row_number: int
|
||||
employee_no: str
|
||||
name: str
|
||||
email: str
|
||||
gender: str | None
|
||||
birth_date: date | None
|
||||
phone: str | None
|
||||
join_date: date | None
|
||||
location: str | None
|
||||
position: str
|
||||
grade: str
|
||||
organization_unit_code: str | None
|
||||
manager_employee_no: str | None
|
||||
finance_owner_name: str | None
|
||||
cost_center: str | None
|
||||
employment_status: str
|
||||
role_codes: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmployeeSpreadsheetError:
|
||||
row: int
|
||||
column: str
|
||||
employee_no: str
|
||||
message: str
|
||||
|
||||
|
||||
def build_import_template_bytes() -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = EMPLOYEE_SHEET_NAME
|
||||
sheet.append(list(EMPLOYEE_HEADERS))
|
||||
|
||||
instructions = workbook.create_sheet(INSTRUCTION_SHEET_NAME)
|
||||
instructions.append(["字段", "说明"])
|
||||
instruction_rows = [
|
||||
("员工编号*", "必填,全局唯一,导入时用于判断新建或覆盖。"),
|
||||
("姓名*", "必填。"),
|
||||
("邮箱*", "必填,全局唯一。"),
|
||||
("性别", "可选:男、女,留空表示不填写。"),
|
||||
("出生日期", "可选,格式 YYYY-MM-DD。"),
|
||||
("手机号", "可选。"),
|
||||
("入职日期", "可选,格式 YYYY-MM-DD。"),
|
||||
("办公地点", "可选。"),
|
||||
("岗位*", "必填。"),
|
||||
("职级*", "必填,例如 P3、P5。"),
|
||||
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
|
||||
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
|
||||
("财务归口", "可选。"),
|
||||
("成本中心", "可选。"),
|
||||
("在职状态*", "必填:在职、试用中、停用。"),
|
||||
("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance;留空默认为 user。"),
|
||||
("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
|
||||
]
|
||||
for row in instruction_rows:
|
||||
instructions.append(list(row))
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def build_export_workbook_bytes(rows: list[list[Any]]) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = EMPLOYEE_SHEET_NAME
|
||||
sheet.append(list(EMPLOYEE_HEADERS))
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def parse_employee_workbook(content: bytes) -> tuple[list[EmployeeImportRow], list[EmployeeSpreadsheetError]]:
|
||||
errors: list[EmployeeSpreadsheetError] = []
|
||||
|
||||
if not content:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="上传文件不能为空。",
|
||||
)
|
||||
]
|
||||
|
||||
if len(content) > MAX_IMPORT_BYTES:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message=f"文件大小不能超过 {MAX_IMPORT_BYTES // (1024 * 1024)}MB。",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
workbook = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="无法解析 Excel 文件,请使用系统提供的 .xlsx 模板。",
|
||||
)
|
||||
]
|
||||
|
||||
if EMPLOYEE_SHEET_NAME not in workbook.sheetnames:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="工作表",
|
||||
employee_no="",
|
||||
message=f"缺少工作表“{EMPLOYEE_SHEET_NAME}”。",
|
||||
)
|
||||
]
|
||||
|
||||
worksheet = workbook[EMPLOYEE_SHEET_NAME]
|
||||
raw_rows = list(worksheet.iter_rows(values_only=True))
|
||||
if not raw_rows:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="Excel 中没有可导入的数据行。",
|
||||
)
|
||||
]
|
||||
|
||||
header_row = [_normalize_cell(value) for value in raw_rows[0]]
|
||||
if list(header_row) != list(EMPLOYEE_HEADERS):
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=1,
|
||||
column="表头",
|
||||
employee_no="",
|
||||
message="表头与员工导入模板不一致,请下载最新模板后重试。",
|
||||
)
|
||||
]
|
||||
|
||||
parsed_rows: list[EmployeeImportRow] = []
|
||||
for index, raw_row in enumerate(raw_rows[1:], start=2):
|
||||
if index - 1 > MAX_IMPORT_ROWS:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=index,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message=f"单次最多导入 {MAX_IMPORT_ROWS} 行数据。",
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
if _is_empty_data_row(raw_row):
|
||||
continue
|
||||
|
||||
row_errors, parsed = _parse_data_row(index, raw_row)
|
||||
errors.extend(row_errors)
|
||||
if parsed is not None:
|
||||
parsed_rows.append(parsed)
|
||||
|
||||
if not parsed_rows and not errors:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="Excel 中没有可导入的数据行。",
|
||||
)
|
||||
)
|
||||
|
||||
return parsed_rows, errors
|
||||
|
||||
|
||||
def _parse_data_row(
|
||||
row_number: int,
|
||||
raw_row: tuple[Any, ...],
|
||||
) -> tuple[list[EmployeeSpreadsheetError], EmployeeImportRow | None]:
|
||||
errors: list[EmployeeSpreadsheetError] = []
|
||||
values = {
|
||||
HEADER_TO_FIELD[header]: _normalize_cell(raw_row[index] if index < len(raw_row) else "")
|
||||
for index, header in enumerate(EMPLOYEE_HEADERS)
|
||||
}
|
||||
employee_no = values["employee_no"]
|
||||
|
||||
def add_error(column: str, message: str) -> None:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row_number,
|
||||
column=column,
|
||||
employee_no=employee_no,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
if not employee_no:
|
||||
add_error("员工编号*", "员工编号不能为空。")
|
||||
|
||||
name = values["name"]
|
||||
if not name:
|
||||
add_error("姓名*", "姓名不能为空。")
|
||||
|
||||
email = values["email"].lower() if values["email"] else ""
|
||||
if not email:
|
||||
add_error("邮箱*", "邮箱不能为空。")
|
||||
elif not _is_valid_email(email):
|
||||
add_error("邮箱*", "邮箱格式不正确。")
|
||||
|
||||
position = values["position"]
|
||||
if not position:
|
||||
add_error("岗位*", "岗位不能为空。")
|
||||
|
||||
grade = values["grade"]
|
||||
if not grade:
|
||||
add_error("职级*", "职级不能为空。")
|
||||
|
||||
employment_status = values["employment_status"]
|
||||
if not employment_status:
|
||||
add_error("在职状态*", "在职状态不能为空。")
|
||||
elif employment_status not in VALID_EMPLOYMENT_STATUSES:
|
||||
add_error("在职状态*", "在职状态必须为:在职、试用中、停用。")
|
||||
|
||||
gender = values["gender"] or None
|
||||
if gender and gender not in {"男", "女"}:
|
||||
add_error("性别", "性别只能填写:男、女,或留空。")
|
||||
|
||||
birth_date, birth_error = _parse_optional_date(values["birth_date"], "出生日期")
|
||||
if birth_error:
|
||||
add_error("出生日期", birth_error)
|
||||
|
||||
join_date, join_error = _parse_optional_date(values["join_date"], "入职日期")
|
||||
if join_error:
|
||||
add_error("入职日期", join_error)
|
||||
|
||||
role_codes = _parse_role_codes(values["role_codes"])
|
||||
if values["role_codes"] and not role_codes:
|
||||
add_error("角色编码", "角色编码不能为空片段,多个角色请用英文逗号分隔。")
|
||||
|
||||
if errors:
|
||||
return errors, None
|
||||
|
||||
return (
|
||||
[],
|
||||
EmployeeImportRow(
|
||||
row_number=row_number,
|
||||
employee_no=employee_no,
|
||||
name=name,
|
||||
email=email,
|
||||
gender=gender,
|
||||
birth_date=birth_date,
|
||||
phone=values["phone"] or None,
|
||||
join_date=join_date,
|
||||
location=values["location"] or None,
|
||||
position=position,
|
||||
grade=grade,
|
||||
organization_unit_code=values["organization_unit_code"] or None,
|
||||
manager_employee_no=values["manager_employee_no"] or None,
|
||||
finance_owner_name=values["finance_owner_name"] or None,
|
||||
cost_center=values["cost_center"] or None,
|
||||
employment_status=employment_status,
|
||||
role_codes=role_codes or list(DEFAULT_ROLE_CODES),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_role_codes(value: str) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
codes = [item.strip() for item in value.replace(",", ",").split(",")]
|
||||
return list(dict.fromkeys(code for code in codes if code))
|
||||
|
||||
|
||||
def _parse_optional_date(value: str, label: str) -> tuple[date | None, str | None]:
|
||||
if not value:
|
||||
return None, None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.date(), None
|
||||
|
||||
if isinstance(value, date):
|
||||
return value, None
|
||||
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return datetime.strptime(text, "%Y-%m-%d").date(), None
|
||||
except ValueError:
|
||||
return None, f"{label}格式必须为 YYYY-MM-DD。"
|
||||
|
||||
|
||||
def _is_valid_email(value: str) -> bool:
|
||||
_, address = parseaddr(value)
|
||||
return bool(address) and "@" in address
|
||||
|
||||
|
||||
def _normalize_cell(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d")
|
||||
if isinstance(value, date):
|
||||
return value.strftime("%Y-%m-%d")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _is_empty_data_row(raw_row: tuple[Any, ...]) -> bool:
|
||||
return not any(_normalize_cell(value) for value in raw_row)
|
||||
@@ -54,7 +54,7 @@ EXPENSE_TYPE_LABELS = {
|
||||
"welfare": "福利",
|
||||
}
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance"}
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES = {
|
||||
@@ -814,17 +814,19 @@ class ExpenseClaimService:
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
|
||||
self._delete_claim_attachment_root(claim.id)
|
||||
self.db.delete(claim)
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._has_privileged_claim_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
|
||||
self._delete_claim_attachment_root(claim.id)
|
||||
self.db.delete(claim)
|
||||
self.db.commit()
|
||||
|
||||
self.audit_service.log_action(
|
||||
@@ -835,10 +837,60 @@ class ExpenseClaimService:
|
||||
before_json=before_json,
|
||||
after_json=None,
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
def upsert_draft_from_ontology(
|
||||
|
||||
return claim
|
||||
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._has_privileged_claim_access(current_user):
|
||||
raise ValueError("只有财务人员或高级管理人员可以退回报销单。")
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = current_user.name or current_user.username
|
||||
return_reason = str(reason or "").strip()
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"severity": "medium",
|
||||
"label": "人工退回",
|
||||
"message": return_reason or f"{operator} 已退回该报销单,请申请人补充后重新提交。",
|
||||
"operator": operator,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待补充"
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.return",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
|
||||
@@ -530,8 +530,16 @@ class UserAgentService:
|
||||
"entry_source": payload.context_json.get("entry_source"),
|
||||
"user_name": payload.context_json.get("name"),
|
||||
"user_role": payload.context_json.get("role"),
|
||||
"user_department": payload.context_json.get("department_name")
|
||||
or payload.context_json.get("department"),
|
||||
"user_position": payload.context_json.get("position"),
|
||||
"user_grade": payload.context_json.get("grade"),
|
||||
"employee_no": payload.context_json.get("employee_no"),
|
||||
"manager_name": payload.context_json.get("manager_name"),
|
||||
"employee_location": payload.context_json.get("employee_location"),
|
||||
"cost_center": payload.context_json.get("cost_center"),
|
||||
"finance_owner_name": payload.context_json.get("finance_owner_name"),
|
||||
"employee_risk_profile": payload.context_json.get("employee_risk_profile", {}),
|
||||
"user_role_codes": payload.context_json.get("role_codes", []),
|
||||
"is_admin": bool(payload.context_json.get("is_admin")),
|
||||
"request_context": payload.context_json.get("request_context"),
|
||||
@@ -2178,18 +2186,36 @@ class UserAgentService:
|
||||
title="AI预审未通过",
|
||||
level="high",
|
||||
content=reason,
|
||||
detail=(
|
||||
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
|
||||
"否则审批人无法判断成本归属、业务真实性或票据有效性。"
|
||||
),
|
||||
suggestion="按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。",
|
||||
)
|
||||
)
|
||||
|
||||
employee_name = self._collect_entity_values(payload).get("employee_name") or str(
|
||||
payload.context_json.get("name") or ""
|
||||
).strip()
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
employee_name = (
|
||||
str(employee.name).strip()
|
||||
if employee is not None and employee.name
|
||||
else self._collect_entity_values(payload).get("employee_name")
|
||||
or str(payload.context_json.get("name") or "").strip()
|
||||
)
|
||||
if employee_name:
|
||||
since = datetime.now(UTC) - timedelta(days=90)
|
||||
stmt = select(ExpenseClaim).where(
|
||||
ExpenseClaim.employee_name == employee_name,
|
||||
ExpenseClaim.occurred_at >= since,
|
||||
)
|
||||
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
|
||||
if employee is not None:
|
||||
employee_identifiers = {
|
||||
str(employee.name or "").strip(),
|
||||
str(employee.email or "").strip(),
|
||||
str(employee.employee_no or "").strip(),
|
||||
}
|
||||
employee_identifiers.discard("")
|
||||
claim_identity_conditions = [
|
||||
ExpenseClaim.employee_id == employee.id,
|
||||
ExpenseClaim.employee_name.in_(list(employee_identifiers)),
|
||||
]
|
||||
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
if recent_claims:
|
||||
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
|
||||
@@ -2202,6 +2228,11 @@ class UserAgentService:
|
||||
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
|
||||
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
|
||||
),
|
||||
detail=(
|
||||
"该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
|
||||
"历史风险或异常重复报销倾向,不会单独阻断审批。"
|
||||
),
|
||||
suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
|
||||
)
|
||||
)
|
||||
current_amount = self._resolve_amount_value(payload)
|
||||
@@ -2220,6 +2251,11 @@ class UserAgentService:
|
||||
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
|
||||
"提交前建议核对是否为重复报销或拆分不当。"
|
||||
),
|
||||
detail=(
|
||||
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
|
||||
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
|
||||
),
|
||||
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2229,6 +2265,8 @@ class UserAgentService:
|
||||
title="制度注意事项",
|
||||
level="info",
|
||||
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
|
||||
detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
|
||||
suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2239,6 +2277,8 @@ class UserAgentService:
|
||||
title="票据识别提醒",
|
||||
level="warning",
|
||||
content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。",
|
||||
detail="票据 OCR 识别存在字段缺失、置信度偏低或类型判断不稳定时,会生成该提醒。",
|
||||
suggestion="打开票据明细逐张核对日期、金额、商户和票据类型,必要时更正后再提交。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2248,6 +2288,8 @@ class UserAgentService:
|
||||
title="建议拆单",
|
||||
level="high",
|
||||
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
|
||||
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
|
||||
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2676,6 +2718,7 @@ class UserAgentService:
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
||||
.where(
|
||||
or_(
|
||||
Employee.name.in_(normalized),
|
||||
|
||||
Reference in New Issue
Block a user