Files
X-Financial/server/src/app/services/employee_spreadsheet.py
caoxiaozhu 0e861d8fa6 feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
2026-05-26 09:15:14 +08:00

369 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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。"),
("部门编码", "可选,须与系统组织编码一致,例如 FINANCE-DEPT。"),
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
("财务归口", "可选。"),
("成本中心", "可选。"),
("在职状态*", "必填:在职、试用中、停用。"),
("角色编码", "可选,多个角色用英文逗号分隔,例如 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)