R41: 完成员工批量导入
这个提交包含在:
+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`,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
const Fastify = require("fastify");
|
||||
const multipart = require("@fastify/multipart");
|
||||
const ExcelJS = require("exceljs");
|
||||
|
||||
const usersRoutes = require("../../../src/routes/users");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
function buildMultipartRequest({ filename, contentType, content }) {
|
||||
const boundary = "----codex-user-import-boundary";
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]);
|
||||
|
||||
return {
|
||||
payload: body,
|
||||
headers: {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildXlsxBuffer(rows) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet("users");
|
||||
worksheet.addRow([
|
||||
"employeeNo",
|
||||
"name",
|
||||
"phone",
|
||||
"email",
|
||||
"departmentName",
|
||||
"title",
|
||||
"directManagerEmployeeNo",
|
||||
"roles",
|
||||
"hireDate",
|
||||
]);
|
||||
|
||||
for (const row of rows) {
|
||||
worksheet.addRow([
|
||||
row.employeeNo ?? "",
|
||||
row.name ?? "",
|
||||
row.phone ?? "",
|
||||
row.email ?? "",
|
||||
row.departmentName ?? "",
|
||||
row.title ?? "",
|
||||
row.directManagerEmployeeNo ?? "",
|
||||
row.roles ?? "",
|
||||
row.hireDate ?? "",
|
||||
]);
|
||||
}
|
||||
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const existingEmployees = options.existingEmployees ?? [];
|
||||
const departments = options.departments ?? [];
|
||||
const auditCreate = vi.fn().mockResolvedValue({});
|
||||
const roleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||
let createIndex = 0;
|
||||
const employeeCreate = vi.fn(async ({ data }) => {
|
||||
createIndex += 1;
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${String(createIndex).padStart(12, "0")}`,
|
||||
employee_no: data.employee_no,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
status: data.status,
|
||||
department_id: data.department_id,
|
||||
title: data.title,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
};
|
||||
});
|
||||
const employeeFindMany = vi.fn(async () => existingEmployees);
|
||||
const departmentFindMany = vi.fn(async () => departments);
|
||||
|
||||
const tx = {
|
||||
department: { findMany: departmentFindMany },
|
||||
employee: {
|
||||
findMany: employeeFindMany,
|
||||
create: employeeCreate,
|
||||
update: employeeUpdate,
|
||||
},
|
||||
employeeRole: {
|
||||
createMany: roleCreateMany,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
};
|
||||
|
||||
app.decorate("config", { appTimezone: "Asia/Shanghai" });
|
||||
app.decorate("prisma", {
|
||||
...tx,
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = {
|
||||
id: "actor-1",
|
||||
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
permissionCodes: ["system.users"],
|
||||
};
|
||||
const permissions = request.employee.permissionCodes ?? [];
|
||||
if (!permissions.includes("*") && !permissions.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = {
|
||||
id: "actor-1",
|
||||
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
permissionCodes: ["system.users"],
|
||||
};
|
||||
});
|
||||
app.decorate("hasPermission", () => true);
|
||||
|
||||
await app.register(multipart, {
|
||||
attachFieldsToBody: false,
|
||||
limits: { fileSize: 1024 * 1024, files: 1 },
|
||||
});
|
||||
await app.register(usersRoutes, { prefix: "/api" });
|
||||
|
||||
return {
|
||||
app,
|
||||
auditCreate,
|
||||
departmentFindMany,
|
||||
employeeCreate,
|
||||
employeeFindMany,
|
||||
employeeUpdate,
|
||||
roleCreateMany,
|
||||
};
|
||||
}
|
||||
|
||||
describe("users import routes", () => {
|
||||
it("supports xlsx dry-run and allows manager references to later rows in the same file", async () => {
|
||||
const { app } = await buildApp({
|
||||
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
||||
});
|
||||
const fileBuffer = await buildXlsxBuffer([
|
||||
{
|
||||
employeeNo: "E003",
|
||||
name: "Carol",
|
||||
phone: "13800000003",
|
||||
email: "[email protected]",
|
||||
departmentName: "行政部",
|
||||
directManagerEmployeeNo: "E002",
|
||||
hireDate: "2026-03-28",
|
||||
},
|
||||
{
|
||||
employeeNo: "E002",
|
||||
name: "Bob",
|
||||
phone: "13800000002",
|
||||
email: "[email protected]",
|
||||
departmentName: "行政部",
|
||||
title: "经理",
|
||||
roles: "manager",
|
||||
hireDate: "2026-03-27",
|
||||
},
|
||||
]);
|
||||
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
filename: "users.xlsx",
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
content: fileBuffer,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users/import/dry-run",
|
||||
...multipartRequest,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json().data;
|
||||
expect(payload.summary).toEqual({
|
||||
totalRows: 2,
|
||||
validRows: 2,
|
||||
invalidRows: 0,
|
||||
});
|
||||
expect(payload.rows[0].normalized.title).toBe("员工");
|
||||
expect(payload.rows[0].normalized.directManagerEmployeeNo).toBe("E002");
|
||||
expect(payload.rows[0].errors).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects dry-run uploads with unsupported mime type", async () => {
|
||||
const { app } = await buildApp();
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
filename: "users.csv",
|
||||
contentType: "application/json",
|
||||
content: "employeeNo,name,phone,email,departmentName\nE001,Alice,13800000000,[email protected],行政部\n",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users/import/dry-run",
|
||||
...multipartRequest,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reports duplicate fields, archived departments and inactive managers in dry-run output", async () => {
|
||||
const { app } = await buildApp({
|
||||
departments: [{ id: "dept-archived", name: "归档部门", archived_at: new Date("2026-03-01T00:00:00.000Z") }],
|
||||
existingEmployees: [
|
||||
{
|
||||
id: "emp-existing",
|
||||
employee_no: "E001",
|
||||
phone: "13800000009",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "mgr-archived",
|
||||
employee_no: "M001",
|
||||
phone: "13800000008",
|
||||
email: "[email protected]",
|
||||
status: "archived",
|
||||
},
|
||||
],
|
||||
});
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
filename: "users.csv",
|
||||
contentType: "text/csv",
|
||||
content: [
|
||||
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
||||
"E001,Alice,13800000009,[email protected],归档部门,,M001,employee,2026-03-28",
|
||||
"E001,Bob,13800000007,[email protected],缺失部门,,,employee,2026-03-28",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users/import/dry-run",
|
||||
...multipartRequest,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json().data;
|
||||
expect(payload.summary.invalidRows).toBe(2);
|
||||
expect(payload.rows[0].errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ field: "employeeNo", message: "duplicate_existing" }),
|
||||
expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }),
|
||||
expect.objectContaining({ field: "departmentName", message: "archived" }),
|
||||
expect.objectContaining({ field: "directManagerEmployeeNo", message: "inactive" }),
|
||||
]),
|
||||
);
|
||||
expect(payload.rows[1].errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }),
|
||||
expect.objectContaining({ field: "departmentName", message: "not_found" }),
|
||||
]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("applies a valid csv import in one transaction and returns one-time passwords", async () => {
|
||||
const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({
|
||||
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
||||
});
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
filename: "users.csv",
|
||||
contentType: "text/csv",
|
||||
content: [
|
||||
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
||||
"E003,Carol,13800000003,[email protected],行政部,,E002,employee,2026-03-28",
|
||||
"E002,Bob,13800000002,[email protected],行政部,经理,,manager,2026-03-27",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users/import/apply",
|
||||
...multipartRequest,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json().data;
|
||||
expect(payload.summary).toEqual({
|
||||
totalRows: 2,
|
||||
validRows: 2,
|
||||
invalidRows: 0,
|
||||
});
|
||||
expect(payload.imported).toHaveLength(2);
|
||||
expect(payload.imported[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
employeeNo: "E003",
|
||||
email: "[email protected]",
|
||||
}),
|
||||
);
|
||||
expect(payload.imported[0].oneTimePassword).toMatch(/^ResetA1[0-9a-f]{12}$/);
|
||||
expect(employeeCreate).toHaveBeenCalledTimes(2);
|
||||
expect(roleCreateMany).toHaveBeenCalledTimes(2);
|
||||
expect(employeeUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "00000000-0000-4000-8000-000000000001" },
|
||||
data: expect.objectContaining({
|
||||
direct_manager_id: "00000000-0000-4000-8000-000000000002",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(auditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_USERS_IMPORTED",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("refuses apply when preview contains invalid rows and does not create employees", async () => {
|
||||
const { app, employeeCreate } = await buildApp();
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
filename: "users.csv",
|
||||
contentType: "text/csv",
|
||||
content: [
|
||||
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
||||
"E003,Carol,13800000003,[email protected],不存在部门,,,employee,2026-03-28",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users/import/apply",
|
||||
...multipartRequest,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(employeeCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户