完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -1,10 +1,31 @@
|
||||
const Fastify = require("fastify");
|
||||
const multipart = require("@fastify/multipart");
|
||||
const ExcelJS = require("exceljs");
|
||||
const JSZip = require("jszip");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const ts = require("../../../../client/node_modules/typescript");
|
||||
|
||||
const usersRoutes = require("../../../src/routes/users");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
// Exercise the actual response/logging callback without importing app/config,
|
||||
// opening a database connection or starting background jobs.
|
||||
function productionErrorHandler(logError) {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, "../../../src/app.js"), "utf8");
|
||||
const ast = ts.createSourceFile("app.js", source, ts.ScriptTarget.Latest, true);
|
||||
const handlers = [];
|
||||
function visit(node) {
|
||||
if (ts.isCallExpression(node) && node.expression.getText(ast) === "app.setErrorHandler") {
|
||||
handlers.push(node.arguments[0].getText(ast));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
expect(handlers).toHaveLength(1);
|
||||
return new Function("AppError", "app", `return (${handlers[0]});`)(AppError, { log: { error: logError } });
|
||||
}
|
||||
|
||||
function buildMultipartRequest({ filename, contentType, content }) {
|
||||
const boundary = "----codex-user-import-boundary";
|
||||
const head = Buffer.from(
|
||||
@@ -122,8 +143,13 @@ async function buildApp(options = {}) {
|
||||
|
||||
await app.register(multipart, {
|
||||
attachFieldsToBody: false,
|
||||
limits: { fileSize: 1024 * 1024, files: 1 },
|
||||
limits: { fileSize: options.fileSize ?? 1024 * 1024, files: 1 },
|
||||
});
|
||||
if (options.fileReader) {
|
||||
app.addHook("preHandler", async (request) => { request.file = options.fileReader; });
|
||||
}
|
||||
app.setErrorHandler(options.logError ? productionErrorHandler(options.logError) :
|
||||
(error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
||||
await app.register(usersRoutes, { prefix: "/api" });
|
||||
|
||||
return {
|
||||
@@ -440,3 +466,296 @@ describe("users import routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("user import calendar boundary", () => {
|
||||
async function calendarUpload(format, dates) {
|
||||
const rows = dates.map((hireDate, index) => ({ employeeNo: `000${index}`, name: `Synthetic ${index}`, email: `calendar${index}@example.test`, departmentName: "合成部门", title: "员工", roles: "employee", hireDate }));
|
||||
const content = format === "xlsx" ? await buildXlsxBuffer(rows) : ["employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", ...rows.map((r) => [r.employeeNo, r.name, "", r.email, r.departmentName, r.title, "", r.roles, r.hireDate].join(","))].join("\n");
|
||||
return buildMultipartRequest({ filename: `calendar.${format}`, contentType: format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv", content });
|
||||
}
|
||||
|
||||
it.each(["csv", "xlsx"].flatMap((format) => ["dry-run", "apply"].map((action) => [format, action])))(
|
||||
"%s %s keeps row errors and rejects a mixed invalid batch without writes", async (format, action) => {
|
||||
const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${action}`, ...await calendarUpload(format, ["2024-02-29", "2026-02-29", "2026-04-31"]) });
|
||||
expect(response.statusCode).toBe(action === "apply" ? 409 : 200);
|
||||
const result = action === "apply" ? response.json().error.details : response.json().data;
|
||||
expect(result.summary).toEqual({ totalRows: 3, validRows: 1, invalidRows: 2 });
|
||||
expect(result.rows[0].errors).toEqual([]);
|
||||
for (const row of result.rows.slice(1)) expect(row.errors).toContainEqual({ field: "hireDate", message: "invalid" });
|
||||
for (const write of [employeeCreate, employeeUpdate, roleCreateMany]) expect(write).not.toHaveBeenCalled();
|
||||
if (action === "apply") {
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
expect(result.reasonCode).toBe("user.import_rows_invalid");
|
||||
}
|
||||
else expect(auditCreate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: "SYSTEM_USERS_IMPORT_DRY_RUN" }) }));
|
||||
} finally { await app.close(); }
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["csv", "xlsx"])("%s applies leap day, month end and blank date using the existing default", async (format) => {
|
||||
const { app, employeeCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
||||
const { currentDateString } = require("../../../src/utils/date");
|
||||
try {
|
||||
const before = currentDateString("Asia/Shanghai");
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...await calendarUpload(format, ["2024-02-29", "2026-04-30", ""]) });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeCreate).toHaveBeenCalledTimes(3);
|
||||
expect(employeeCreate.mock.calls[0][0].data.employee_no).toBe("0000");
|
||||
const dates = employeeCreate.mock.calls.map(([{ data }]) => data.hire_date.toISOString().slice(0, 10));
|
||||
expect(dates.slice(0, 2)).toEqual(["2024-02-29", "2026-04-30"]);
|
||||
expect([before, currentDateString("Asia/Shanghai")]).toContain(dates[2]);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("user import file reasons", () => {
|
||||
const header = "employeeNo,name,email,departmentName\n";
|
||||
const row = "S001,合成员工,[email protected],合成部门\n";
|
||||
const cases = [
|
||||
["file_too_large", "user.import_file_too_large", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(20) }, { fileSize: 64 }, {}],
|
||||
["missing", "user.import_file_missing", null, {}, {}],
|
||||
["unsupported_extension", "user.import_extension_unsupported", { filename: "users.txt", contentType: "text/csv", content: header + row }, {}, {}],
|
||||
["unsupported_mime_type", "user.import_mime_unsupported", { filename: "users.csv", contentType: "application/json", content: header + row }, {}, {}],
|
||||
["missing_headers", "user.import_headers_missing", { filename: "users.csv", contentType: "text/csv", content: "employeeNo,name\nS001,合成员工\n" }, {}, { missingHeaders: ["email", "departmentName"] }],
|
||||
["empty", "user.import_file_empty", { filename: "users.csv", contentType: "text/csv", content: header }, {}, {}],
|
||||
["row_limit_exceeded", "user.import_row_limit_exceeded", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(501) }, {}, { limit: 500 }],
|
||||
];
|
||||
it.each(cases.flatMap((item) => ["dry-run", "apply"].map((operation) => [operation, ...item])))("explains %s %s before business access", async (operation, file, reasonCode, upload, options, extra) => {
|
||||
const { app, ...spies } = await buildApp(options);
|
||||
try {
|
||||
const request = upload ? buildMultipartRequest(upload) : {
|
||||
payload: "--synthetic-empty--\r\n",
|
||||
headers: { "content-type": "multipart/form-data; boundary=synthetic-empty" },
|
||||
};
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...request });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toEqual({ code: "VALID_001", details: { file, reasonCode, ...extra } });
|
||||
for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled();
|
||||
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("allows 500 rows in preview and keeps the original preview audit", async () => {
|
||||
const { app, employeeCreate, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
||||
try {
|
||||
const rows = Array.from({ length: 500 }, (_, index) => `S${index},合成员工${index},synthetic${index}@example.invalid,合成部门\n`).join("");
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", ...buildMultipartRequest({ filename: "users.csv", contentType: "text/csv", content: header + rows }) });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.rows).toHaveLength(500);
|
||||
expect(employeeCreate).not.toHaveBeenCalled();
|
||||
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(auditCreate).toHaveBeenCalledOnce();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("keeps permission rejection before import parsing", async () => {
|
||||
const { app, ...spies } = await buildApp({ actor: { id: "synthetic-denied", permissionCodes: [] } });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...buildMultipartRequest({ filename: "users.txt", contentType: "text/plain", content: "synthetic" }) });
|
||||
expect(response.statusCode).toBe(403);
|
||||
for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("user import parser boundary", () => {
|
||||
const marker = "SYNTHETIC_CELL_CANARY";
|
||||
const header = "employeeNo,name,email,departmentName\n";
|
||||
const mime = (format) => format === "csv" ? "text/csv" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
let malformed;
|
||||
|
||||
beforeAll(async () => {
|
||||
const xlsx = await buildXlsxBuffer([]);
|
||||
malformed = {
|
||||
"unclosed-csv": ["csv", Buffer.from(`${header}\"${marker}`)],
|
||||
"bad-quote-csv": ["csv", Buffer.from(`${header}\"${marker}\"x,synthetic`)],
|
||||
"non-zip-xlsx": ["xlsx", Buffer.from(marker)],
|
||||
"truncated-xlsx": ["xlsx", xlsx.subarray(0, Math.floor(xlsx.length / 2))],
|
||||
};
|
||||
for (const [label, xml] of [
|
||||
["closing-tag", `<worksheet><${marker}></wrong></worksheet>`],
|
||||
["entity", `<worksheet><sheetData>&${marker};</sheetData></worksheet>`],
|
||||
["attribute", `<worksheet ${marker}=unquoted></worksheet>`],
|
||||
]) {
|
||||
const zip = await JSZip.loadAsync(xlsx);
|
||||
zip.file("xl/worksheets/sheet1.xml", xml);
|
||||
malformed[`${label}-xlsx`] = ["xlsx", await zip.generateAsync({ type: "nodebuffer" })];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
function expectNoBusinessAccess(app, spies) {
|
||||
for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled();
|
||||
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
for (const operation of ["dry-run", "apply"]) {
|
||||
it.each(["unclosed-csv", "bad-quote-csv", "non-zip-xlsx", "truncated-xlsx", "closing-tag-xlsx", "entity-xlsx", "attribute-xlsx"])(`${operation} explains %s without logging cells or accessing business data`, async (label) => {
|
||||
const logError = vi.fn();
|
||||
const { app, ...spies } = await buildApp({ logError });
|
||||
try {
|
||||
const [format, content] = malformed[label];
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
||||
...buildMultipartRequest({ filename: `${marker}.${format}`, contentType: mime(format), content }) });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toEqual({ code: "VALID_001", message: "参数校验失败",
|
||||
details: { file: "invalid_format", reasonCode: "user.import_file_invalid" } });
|
||||
expect(response.body).not.toContain(marker);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expectNoBusinessAccess(app, spies);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each(["csv", "xlsx"])(`${operation} preserves 500 and discards unknown %s parser error content`, async (format) => {
|
||||
const parserClass = require(`exceljs/lib/${format}/${format}`);
|
||||
const original = Object.assign(new Error(`Internal csv xlsx ${marker}`), {
|
||||
cause: new Error(marker), payload: marker, statusCode: 400, code: "FST_REQ_FILE_TOO_LARGE",
|
||||
});
|
||||
vi.spyOn(parserClass.prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(original);
|
||||
const logError = vi.fn();
|
||||
const { app, ...spies } = await buildApp({ logError });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
||||
...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) });
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toEqual({ code: "SYS_001", message: "服务器内部错误", details: {} });
|
||||
expect(response.body).not.toContain(marker);
|
||||
expect(logError).toHaveBeenCalledOnce();
|
||||
const logged = logError.mock.calls[0][0].err;
|
||||
expect(logged).not.toBe(original);
|
||||
expect(logged.message).toBe("User import parser failed");
|
||||
expect(Object.getOwnPropertyNames(logged).sort()).toEqual(["message", "stack"]);
|
||||
expect(logged.stack).not.toContain(marker);
|
||||
expectNoBusinessAccess(app, spies);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it(`${operation} rejects permission before attempting even a malformed parse`, async () => {
|
||||
const read = vi.spyOn(require("exceljs/lib/csv/csv").prototype, "read");
|
||||
const logError = vi.fn();
|
||||
const { app, ...spies } = await buildApp({ logError, actor: { id: "synthetic-denied", permissionCodes: [] } });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
||||
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header}\"${marker}` }) });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expectNoBusinessAccess(app, spies);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
["csv", new TypeError('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')],
|
||||
["xlsx", new TypeError("Corrupted zip: can't find end of central directory")],
|
||||
["csv", new Error("1:45: undefined entity.")],
|
||||
["xlsx", new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')],
|
||||
["xlsx", Object.assign(new Error("Corrupted zip: can't find end of central directory"), { code: "EIO" })],
|
||||
["csv", new Error("Parse Error: expected: ',' OR new line got: 'x'! at 'synthetic")],
|
||||
["csv", { message: 'Parse Error: missing closing: \'"\' in line: at \'synthetic\'' }],
|
||||
])("does not classify a different error type or parser's message as %s input failure", async (format, error) => {
|
||||
vi.spyOn(require(`exceljs/lib/${format}/${format}`).prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(error);
|
||||
const logError = vi.fn();
|
||||
const { app, ...spies } = await buildApp({ logError });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run",
|
||||
...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) });
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(logError.mock.calls[0][0].err.message).toBe("User import parser failed");
|
||||
expectNoBusinessAccess(app, spies);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each(["file", "toBuffer"])("keeps %s read failures outside the parser classification", async (stage) => {
|
||||
const original = new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\'');
|
||||
const fileReader = stage === "file" ? vi.fn().mockRejectedValue(original) : vi.fn().mockResolvedValue({
|
||||
filename: "synthetic.csv", mimetype: "text/csv", toBuffer: vi.fn().mockRejectedValue(original),
|
||||
});
|
||||
const logError = vi.fn();
|
||||
const { app, ...spies } = await buildApp({ logError, fileReader });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run" });
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(logError.mock.calls[0][0].err).toBe(original);
|
||||
expectNoBusinessAccess(app, spies);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it("keeps quoted commas, escaped quotes and line breaks as CSV text", async () => {
|
||||
const { app, employeeCreate, auditCreate } = await buildApp();
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run",
|
||||
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"),
|
||||
content: `${header}0001,"合成,\"\"姓名\"\"\n第二行",[email protected],合成部门\n` }) });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const row = response.json().data.rows[0];
|
||||
expect(row.normalized.employeeNo).toBe("0001");
|
||||
expect(row.normalized.name).toBe('合成,"姓名"\n第二行');
|
||||
expect(employeeCreate).not.toHaveBeenCalled();
|
||||
expect(auditCreate).toHaveBeenCalledOnce();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each(["dry-run", "apply"])("keeps %s database failures outside format classification", async (operation) => {
|
||||
const original = new Error("Corrupted zip: can't find end of central directory");
|
||||
const logError = vi.fn();
|
||||
const { app, departmentFindMany, employeeCreate, auditCreate } = await buildApp({ logError });
|
||||
departmentFindMany.mockRejectedValueOnce(original);
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
||||
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"),
|
||||
content: `${header}S001,合成员工,[email protected],合成部门\n` }) });
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(logError.mock.calls[0][0].err).toBe(original);
|
||||
expect(employeeCreate).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
// Apply intentionally performs preview reads inside its transaction.
|
||||
expect(app.prisma.$transaction).toHaveBeenCalledTimes(operation === "apply" ? 1 : 0);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each(["transaction", "hash"])("does not translate an apply %s failure into a file error", async (stage) => {
|
||||
const original = new Error("Corrupted zip: can't find end of central directory");
|
||||
const logError = vi.fn();
|
||||
const { app, employeeCreate, auditCreate } = await buildApp({ logError,
|
||||
departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
||||
if (stage === "transaction") app.prisma.$transaction.mockRejectedValueOnce(original);
|
||||
else vi.spyOn(require("bcryptjs"), "hash").mockRejectedValueOnce(original);
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/users/import/apply",
|
||||
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"),
|
||||
content: `${header.trim()},title,roles\nS001,合成员工,[email protected],合成部门,合成岗位,employee\n` }) });
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(app.prisma.$transaction).toHaveBeenCalledOnce();
|
||||
expect(logError.mock.calls[0][0].err).toBe(original);
|
||||
expect(employeeCreate).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
it.each([["dry-run", 500, 200], ["dry-run", 501, 400], ["apply", 501, 400]])("preserves XLSX %s row limit at %s", async (operation, count, status) => {
|
||||
const content = await buildXlsxBuffer(Array.from({ length: count }, (_, index) => ({
|
||||
employeeNo: `S${index}`, name: "合成员工", email: `synthetic${index}@example.invalid`, departmentName: "合成部门",
|
||||
})));
|
||||
const logError = vi.fn();
|
||||
const { app, ...spies } = await buildApp({ logError });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
||||
...buildMultipartRequest({ filename: "synthetic.xlsx", contentType: mime("xlsx"), content }) });
|
||||
expect(response.statusCode).toBe(status);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
if (count === 501) {
|
||||
expect(response.json().error.details).toEqual({ file: "row_limit_exceeded", limit: 500, reasonCode: "user.import_row_limit_exceeded" });
|
||||
expectNoBusinessAccess(app, spies);
|
||||
} else {
|
||||
expect(response.json().data.rows).toHaveLength(500);
|
||||
expect(spies.auditCreate).toHaveBeenCalledOnce();
|
||||
expect(spies.employeeCreate).not.toHaveBeenCalled();
|
||||
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户