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;