feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力
关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
@@ -0,0 +1,86 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const ID_SCHEMA = z.string().uuid();
|
||||
const RUN_TYPE_SCHEMA = z.enum(["employees", "departments", "budgets", "masterdata_validation"]);
|
||||
|
||||
function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); }
|
||||
function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; }
|
||||
async function ensureFeatureEnabled(fastify) {
|
||||
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false });
|
||||
if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
function parseCsv(content) {
|
||||
const rows = String(content ?? "").trim().split(/\r?\n/).filter(Boolean);
|
||||
if (!rows.length) return { headers: [], records: [] };
|
||||
const headers = rows[0].split(",").map((item) => item.trim());
|
||||
const records = rows.slice(1).map((line) => {
|
||||
const cells = line.split(",");
|
||||
const record = {};
|
||||
headers.forEach((header, index) => { record[header] = (cells[index] ?? "").trim(); });
|
||||
return record;
|
||||
});
|
||||
return { headers, records };
|
||||
}
|
||||
function requiredHeaders(runType) {
|
||||
if (runType === "employees") return ["employee_no", "name", "department_name"];
|
||||
if (runType === "departments") return ["name"];
|
||||
if (runType === "budgets") return ["code", "name"];
|
||||
return ["type", "code"];
|
||||
}
|
||||
function mapRun(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
runType: item.run_type,
|
||||
status: item.status,
|
||||
sourceFilename: item.source_filename,
|
||||
summaryJson: item.summary_json ?? null,
|
||||
errorJson: item.error_json ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
createdBy: item.created_by ?? null,
|
||||
};
|
||||
}
|
||||
async function migrationRunsRoutes(fastify) {
|
||||
fastify.post("/migration-runs/dry-run", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const part = await request.file();
|
||||
if (!part) throw buildValidationError({ file: "required" });
|
||||
const runType = RUN_TYPE_SCHEMA.safeParse(part.fields?.runType?.value ?? request.query?.runType ?? request.body?.runType);
|
||||
if (!runType.success) throw buildValidationError({ runType: "invalid" });
|
||||
const content = await part.toBuffer();
|
||||
const parsed = parseCsv(content.toString("utf8"));
|
||||
const required = requiredHeaders(runType.data);
|
||||
const missingHeaders = required.filter((header) => !parsed.headers.includes(header));
|
||||
const summaryJson = { rowCount: parsed.records.length, headers: parsed.headers, requiredHeaders: required };
|
||||
const errorJson = missingHeaders.length ? { missingHeaders } : null;
|
||||
const status = missingHeaders.length || parsed.records.length === 0 ? "dry_run_failed" : "dry_run_ok";
|
||||
const created = await fastify.prisma.migrationRun.create({
|
||||
data: {
|
||||
run_type: runType.data,
|
||||
status,
|
||||
source_filename: part.filename,
|
||||
summary_json: summaryJson,
|
||||
error_json: errorJson,
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapRun(created) } });
|
||||
});
|
||||
|
||||
fastify.get("/migration-runs", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const items = await fastify.prisma.migrationRun.findMany({ orderBy: [{ created_at: "desc" }, { id: "desc" }] });
|
||||
return sendData(reply, request, { items: items.map(mapRun) });
|
||||
});
|
||||
|
||||
fastify.get("/migration-runs/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const run = await fastify.prisma.migrationRun.findUnique({ where: { id: parseId(request.params?.id) } });
|
||||
if (!run) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
return sendData(reply, request, { item: mapRun(run) });
|
||||
});
|
||||
}
|
||||
module.exports = migrationRunsRoutes;
|
||||
在新工单中引用
屏蔽一个用户