R41: 完成员工批量导入

这个提交包含在:
2026-03-28 18:57:02 +08:00
父节点 2eff8e01a2
当前提交 3cf8b43f74
共修改 7 个文件,包含 1477 行新增和 63 行删除
+662 -43
查看文件
@@ -1,5 +1,8 @@
const crypto = require("node:crypto");
const path = require("node:path");
const { Readable } = require("node:stream");
const ExcelJS = require("exceljs");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
@@ -14,6 +17,7 @@ const {
maskBankAccount,
maskIdNumber,
} = require("../services/crypto");
const { currentDateString } = require("../utils/date");
const roleCodeSchema = z.enum([
"employee",
@@ -76,6 +80,17 @@ const sensitiveBodySchema = z
})
.strict();
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"];
const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"];
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
"text/csv",
"application/csv",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
const USER_IMPORT_MAX_ROWS = 500;
function parseUserId(params) {
const parsed = z.string().uuid().safeParse(params?.id);
if (!parsed.success) {
@@ -233,6 +248,496 @@ function parseSensitiveBody(body) {
return parsed.data;
}
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function generateOneTimePassword() {
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
const errors = validatePassword(password);
if (errors.length) {
throw buildValidationError({ password: errors });
}
return password;
}
function normalizeImportHeader(value) {
return String(value ?? "").replace(/^\ufeff/, "").trim();
}
function normalizeImportCell(value) {
if (value == null) {
return "";
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "object") {
if (Array.isArray(value.richText)) {
return value.richText.map((item) => item?.text ?? "").join("").trim();
}
if (typeof value.text === "string") {
return value.text.trim();
}
if (value.result != null) {
return normalizeImportCell(value.result);
}
}
return String(value).trim();
}
function normalizeImportRoles(rawValue) {
const raw = normalizeImportCell(rawValue);
if (!raw) {
return ["employee"];
}
const values = raw
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return values.length ? values : ["employee"];
}
function buildImportRowDraft(rawRow, defaultHireDate) {
const normalized = {
employeeNo: normalizeImportCell(rawRow.data.employeeNo),
name: normalizeImportCell(rawRow.data.name),
phone: normalizeImportCell(rawRow.data.phone),
email: normalizeImportCell(rawRow.data.email),
departmentName: normalizeImportCell(rawRow.data.departmentName),
title: normalizeImportCell(rawRow.data.title) || "员工",
directManagerEmployeeNo: normalizeImportCell(rawRow.data.directManagerEmployeeNo),
roles: normalizeImportRoles(rawRow.data.roles),
hireDate: normalizeImportCell(rawRow.data.hireDate) || defaultHireDate,
};
return {
rowNumber: rawRow.rowNumber,
employeeNo: normalized.employeeNo,
name: normalized.name,
normalized,
errors: [],
resolvedDepartmentId: null,
resolvedDirectManagerId: null,
resolvedImportedManagerEmployeeNo: null,
};
}
function pushImportRowError(row, field, message) {
const key = `${field}:${message}`;
if (row.errors.some((item) => `${item.field}:${item.message}` === key)) {
return;
}
row.errors.push({ field, message });
}
function markDuplicateImportValues(rows, key, field) {
const byValue = new Map();
for (const row of rows) {
const value = row.normalized[key];
if (!value) {
continue;
}
const bucket = byValue.get(value) ?? [];
bucket.push(row);
byValue.set(value, bucket);
}
for (const bucket of byValue.values()) {
if (bucket.length <= 1) {
continue;
}
for (const row of bucket) {
pushImportRowError(row, field, "duplicate_in_file");
}
}
}
function resolveWorksheetRows(worksheet) {
if (!worksheet) {
return { headers: [], rows: [] };
}
const headerRow = worksheet.getRow(1);
const headers = headerRow.values.slice(1).map((value) => normalizeImportHeader(value));
const rows = [];
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) {
return;
}
const data = {};
headers.forEach((header, index) => {
if (!header) {
return;
}
data[header] = normalizeImportCell(row.getCell(index + 1).value);
});
if (Object.values(data).some((value) => value !== "")) {
rows.push({ rowNumber, data });
}
});
return { headers, rows };
}
async function parseUserImportRowsFromBuffer(buffer, extension) {
const workbook = new ExcelJS.Workbook();
if (extension === ".csv") {
const worksheet = await workbook.csv.read(Readable.from([buffer]));
return resolveWorksheetRows(worksheet);
}
await workbook.xlsx.load(buffer);
return resolveWorksheetRows(workbook.worksheets[0] ?? null);
}
async function readUserImportPayload(request) {
let filePart = null;
try {
filePart = await request.file();
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
}
throw error;
}
if (!filePart) {
throw buildValidationError({ file: "missing" });
}
const extension = path.extname(filePart.filename ?? "").toLowerCase();
if (!USER_IMPORT_ALLOWED_EXTENSIONS.has(extension)) {
throw buildValidationError({ file: "unsupported_extension" });
}
if (!USER_IMPORT_ALLOWED_MIME_TYPES.has(filePart.mimetype)) {
throw buildValidationError({ file: "unsupported_mime_type" });
}
const buffer = await filePart.toBuffer();
const parsed = await parseUserImportRowsFromBuffer(buffer, extension);
const missingHeaders = USER_IMPORT_REQUIRED_HEADERS.filter((header) => !parsed.headers.includes(header));
if (missingHeaders.length) {
throw buildValidationError({ file: "missing_headers", missingHeaders });
}
if (!parsed.rows.length) {
throw buildValidationError({ file: "empty" });
}
if (parsed.rows.length > USER_IMPORT_MAX_ROWS) {
throw buildValidationError({ file: "row_limit_exceeded", limit: USER_IMPORT_MAX_ROWS });
}
return {
filename: filePart.filename ?? "users-import",
rows: parsed.rows,
};
}
function validateImportRowFields(row) {
const { normalized } = row;
if (!normalized.employeeNo) {
pushImportRowError(row, "employeeNo", "required");
} else if (normalized.employeeNo.length > 50) {
pushImportRowError(row, "employeeNo", "too_long");
}
if (!normalized.name) {
pushImportRowError(row, "name", "required");
} else if (normalized.name.length > 100) {
pushImportRowError(row, "name", "too_long");
}
if (!normalized.phone) {
pushImportRowError(row, "phone", "required");
} else if (normalized.phone.length < 6) {
pushImportRowError(row, "phone", "too_short");
} else if (normalized.phone.length > 30) {
pushImportRowError(row, "phone", "too_long");
}
if (!normalized.email) {
pushImportRowError(row, "email", "required");
} else {
const parsedEmail = z.string().trim().email().max(200).safeParse(normalized.email);
if (!parsedEmail.success) {
pushImportRowError(row, "email", "invalid");
}
}
if (!normalized.departmentName) {
pushImportRowError(row, "departmentName", "required");
} else if (normalized.departmentName.length > 100) {
pushImportRowError(row, "departmentName", "too_long");
}
if (!normalized.title) {
pushImportRowError(row, "title", "required");
} else if (normalized.title.length > 100) {
pushImportRowError(row, "title", "too_long");
}
if (normalized.directManagerEmployeeNo && normalized.directManagerEmployeeNo.length > 50) {
pushImportRowError(row, "directManagerEmployeeNo", "too_long");
}
const parsedHireDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).safeParse(normalized.hireDate);
if (!parsedHireDate.success) {
pushImportRowError(row, "hireDate", "invalid");
}
if (!normalized.roles.length) {
pushImportRowError(row, "roles", "required");
}
for (const roleCode of normalized.roles) {
if (!roleCodeSchema.safeParse(roleCode).success) {
pushImportRowError(row, "roles", `invalid_role:${roleCode}`);
}
}
}
function createDepartmentResolutionMap(departments) {
const map = new Map();
for (const item of departments) {
const bucket = map.get(item.name) ?? [];
bucket.push(item);
map.set(item.name, bucket);
}
return map;
}
function createEmployeeLookupMaps(employees) {
return {
byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])),
byPhone: new Map(employees.map((item) => [item.phone, item])),
byEmail: new Map(employees.map((item) => [item.email, item])),
};
}
async function buildUserImportPreview(prisma, rawRows, options = {}) {
const defaultHireDate = currentDateString(options.timeZone ?? "Asia/Shanghai");
const previewRows = rawRows.map((row) => buildImportRowDraft(row, defaultHireDate));
for (const row of previewRows) {
validateImportRowFields(row);
}
markDuplicateImportValues(previewRows, "employeeNo", "employeeNo");
markDuplicateImportValues(previewRows, "phone", "phone");
markDuplicateImportValues(previewRows, "email", "email");
const departmentNames = Array.from(
new Set(previewRows.map((row) => row.normalized.departmentName).filter(Boolean)),
);
const employeeNos = Array.from(
new Set(
previewRows.flatMap((row) =>
[row.normalized.employeeNo, row.normalized.directManagerEmployeeNo].filter(Boolean),
),
),
);
const phones = Array.from(new Set(previewRows.map((row) => row.normalized.phone).filter(Boolean)));
const emails = Array.from(new Set(previewRows.map((row) => row.normalized.email).filter(Boolean)));
const [departments, employees] = await Promise.all([
departmentNames.length
? prisma.department.findMany({
where: { name: { in: departmentNames } },
select: { id: true, name: true, archived_at: true },
})
: Promise.resolve([]),
employeeNos.length || phones.length || emails.length
? prisma.employee.findMany({
where: {
OR: [
...(employeeNos.length ? [{ employee_no: { in: employeeNos } }] : []),
...(phones.length ? [{ phone: { in: phones } }] : []),
...(emails.length ? [{ email: { in: emails } }] : []),
],
},
select: {
id: true,
employee_no: true,
phone: true,
email: true,
status: true,
},
})
: Promise.resolve([]),
]);
const departmentsByName = createDepartmentResolutionMap(departments);
const existingLookup = createEmployeeLookupMaps(employees);
for (const row of previewRows) {
const departmentMatches = departmentsByName.get(row.normalized.departmentName) ?? [];
const activeDepartmentMatches = departmentMatches.filter((item) => !item.archived_at);
if (!departmentMatches.length) {
pushImportRowError(row, "departmentName", "not_found");
} else if (!activeDepartmentMatches.length) {
pushImportRowError(row, "departmentName", "archived");
} else if (activeDepartmentMatches.length > 1) {
pushImportRowError(row, "departmentName", "ambiguous");
} else {
row.resolvedDepartmentId = activeDepartmentMatches[0].id;
}
const employeeNoConflict = existingLookup.byEmployeeNo.get(row.normalized.employeeNo);
if (employeeNoConflict) {
pushImportRowError(row, "employeeNo", "duplicate_existing");
}
const phoneConflict = existingLookup.byPhone.get(row.normalized.phone);
if (phoneConflict) {
pushImportRowError(row, "phone", "duplicate_existing");
}
const emailConflict = existingLookup.byEmail.get(row.normalized.email);
if (emailConflict) {
pushImportRowError(row, "email", "duplicate_existing");
}
if (!row.normalized.directManagerEmployeeNo) {
continue;
}
if (row.normalized.directManagerEmployeeNo === row.normalized.employeeNo) {
pushImportRowError(row, "directManagerEmployeeNo", "self_not_allowed");
continue;
}
const existingManager = existingLookup.byEmployeeNo.get(row.normalized.directManagerEmployeeNo);
if (existingManager) {
if (existingManager.status !== "active") {
pushImportRowError(row, "directManagerEmployeeNo", "inactive");
} else {
row.resolvedDirectManagerId = existingManager.id;
}
}
}
const importRowsByEmployeeNo = new Map(
previewRows
.filter((row) => row.normalized.employeeNo && !row.errors.some((item) => item.field === "employeeNo"))
.map((row) => [row.normalized.employeeNo, row]),
);
const resolvedRows = new Set();
const resolvingRows = new Set();
function resolveImportedManager(row) {
if (!row.normalized.directManagerEmployeeNo || row.resolvedDirectManagerId) {
resolvedRows.add(row);
return;
}
if (resolvedRows.has(row)) {
return;
}
if (resolvingRows.has(row)) {
pushImportRowError(row, "directManagerEmployeeNo", "cycle_not_allowed");
return;
}
const importedManager = importRowsByEmployeeNo.get(row.normalized.directManagerEmployeeNo);
if (!importedManager) {
pushImportRowError(row, "directManagerEmployeeNo", "not_found");
resolvedRows.add(row);
return;
}
resolvingRows.add(row);
resolveImportedManager(importedManager);
resolvingRows.delete(row);
if (importedManager.errors.length) {
pushImportRowError(row, "directManagerEmployeeNo", "referenced_row_invalid");
} else {
row.resolvedImportedManagerEmployeeNo = importedManager.normalized.employeeNo;
}
resolvedRows.add(row);
}
for (const row of previewRows) {
resolveImportedManager(row);
}
return {
summary: {
totalRows: previewRows.length,
validRows: previewRows.filter((row) => row.errors.length === 0).length,
invalidRows: previewRows.filter((row) => row.errors.length > 0).length,
},
rows: previewRows.map((row) => ({
rowNumber: row.rowNumber,
employeeNo: row.employeeNo,
name: row.name,
normalized: row.normalized,
errors: row.errors,
})),
validRows: previewRows.filter((row) => row.errors.length === 0),
};
}
async function createSystemUser(tx, options) {
const now = options.now ?? new Date();
const passwordHash = await hashPassword(options.password);
const employee = await tx.employee.create({
data: {
employee_no: options.employeeNo,
name: options.name,
phone: options.phone,
email: options.email,
department_id: options.departmentId,
title: options.title ?? "员工",
direct_manager_id: options.directManagerId ?? null,
hire_date: options.hireDate,
status: "active",
bank_account_encrypted: "",
bank_name: "",
password_hash: passwordHash,
must_change_password: true,
created_at: now,
created_by: options.actorId,
updated_at: now,
updated_by: options.actorId,
},
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
department_id: true,
title: true,
created_at: true,
updated_at: true,
},
});
await tx.employeeRole.createMany({
data: options.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
created_by: options.actorId,
})),
skipDuplicates: true,
});
return employee;
}
async function usersRoutes(fastify, opts = {}) {
const basePathRaw =
typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users";
@@ -412,55 +917,25 @@ async function usersRoutes(fastify, opts = {}) {
}
}
const passwordHash = await hashPassword(body.password);
const now = new Date();
let created = null;
try {
created = await fastify.prisma.$transaction(async (tx) => {
const employee = await tx.employee.create({
data: {
employee_no: body.employeeNo,
name: body.name,
phone: body.phone,
email: body.email,
department_id: departmentId,
title: body.title ?? "员工",
direct_manager_id: body.directManagerId ?? null,
hire_date: hireDate,
status: "active",
bank_account_encrypted: "",
bank_name: "",
password_hash: passwordHash,
must_change_password: true,
created_at: now,
created_by: request.employee.id,
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
department_id: true,
title: true,
created_at: true,
updated_at: true,
},
});
await tx.employeeRole.createMany({
data: body.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
created_by: request.employee.id,
})),
skipDuplicates: true,
const employee = await createSystemUser(tx, {
employeeNo: body.employeeNo,
name: body.name,
phone: body.phone,
email: body.email,
departmentId,
title: body.title ?? "员工",
directManagerId: body.directManagerId ?? null,
hireDate,
password: body.password,
roles: body.roles,
actorId: request.employee.id,
now,
});
await tx.auditLog.create({
@@ -503,6 +978,150 @@ async function usersRoutes(fastify, opts = {}) {
},
);
if (routeBasePath === "/users") {
fastify.post(
`${routeBasePath}/import/dry-run`,
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const { filename, rows } = await readUserImportPayload(request);
const preview = await buildUserImportPreview(fastify.prisma, rows, {
timeZone: fastify.config.appTimezone,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USERS_IMPORT_DRY_RUN",
targetType: "employee_import",
extra: {
sourceFilename: filename,
...preview.summary,
},
}),
});
return sendData(reply, request, {
sourceFilename: filename,
summary: preview.summary,
rows: preview.rows,
});
},
);
fastify.post(
`${routeBasePath}/import/apply`,
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const { filename, rows } = await readUserImportPayload(request);
let result = null;
try {
result = await fastify.prisma.$transaction(async (tx) => {
const preview = await buildUserImportPreview(tx, rows, {
timeZone: fastify.config.appTimezone,
});
if (preview.summary.invalidRows > 0) {
throw buildValidationError(
{
summary: preview.summary,
rows: preview.rows,
},
409,
);
}
const now = new Date();
const createdRows = [];
const createdByEmployeeNo = new Map();
for (const row of preview.validRows) {
const oneTimePassword = generateOneTimePassword();
const employee = await createSystemUser(tx, {
employeeNo: row.normalized.employeeNo,
name: row.normalized.name,
phone: row.normalized.phone,
email: row.normalized.email,
departmentId: row.resolvedDepartmentId,
title: row.normalized.title,
directManagerId: row.resolvedImportedManagerEmployeeNo ? null : row.resolvedDirectManagerId,
hireDate: new Date(`${row.normalized.hireDate}T00:00:00.000Z`),
password: oneTimePassword,
roles: row.normalized.roles,
actorId: request.employee.id,
now,
});
const item = {
employee,
row,
oneTimePassword,
};
createdRows.push(item);
createdByEmployeeNo.set(row.normalized.employeeNo, item);
}
for (const item of createdRows) {
if (!item.row.resolvedImportedManagerEmployeeNo) {
continue;
}
const manager = createdByEmployeeNo.get(item.row.resolvedImportedManagerEmployeeNo);
await tx.employee.update({
where: { id: item.employee.id },
data: {
direct_manager_id: manager.employee.id,
updated_at: now,
updated_by: request.employee.id,
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USERS_IMPORTED",
targetType: "employee_import",
extra: {
sourceFilename: filename,
importedCount: createdRows.length,
employeeNos: createdRows.map((item) => item.employee.employee_no),
},
}),
});
return {
sourceFilename: filename,
summary: preview.summary,
imported: createdRows.map((item) => ({
employeeId: item.employee.id,
employeeNo: item.employee.employee_no,
name: item.employee.name,
email: item.employee.email,
oneTimePassword: item.oneTimePassword,
})),
};
});
} catch (error) {
if (error?.code === "P2002") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: normalizeUniqueErrorDetails(error),
});
}
throw error;
}
return sendData(reply, request, result);
},
);
}
fastify.patch(
`${routeBasePath}/:id`,
{