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`,
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户