R41: 完成员工批量导入
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户