feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)

这个提交包含在:
2026-02-10 11:28:28 +08:00
父节点 bec21e1b38
当前提交 6bc09f557b
共修改 33 个文件,包含 3467 行新增和 13 行删除
+171 -1
查看文件
@@ -41,6 +41,14 @@ const AUDIT_EXPORT_QUERY_SCHEMA = z.object({
limit: z.coerce.number().int().min(1).max(50000).optional(),
});
const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
departmentId: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
function csvCell(value) {
if (value == null) return "";
@@ -113,6 +121,127 @@ function buildViewAllScopeWhere(request) {
};
}
function toDateRange(query) {
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return { dateFrom, dateTo };
}
function buildDashboardWhere(request, query) {
const { dateFrom, dateTo } = toDateRange(query);
const dateWhere =
dateFrom || dateTo
? {
reimbursement_date: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {};
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {
...dateWhere,
...departmentWhere,
};
}
return {
...dateWhere,
...departmentWhere,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
"formNumber",
"status",
"departmentId",
"reimbursementDate",
"totalAmount",
"ownerName",
"submitterName",
"createdAt",
"id",
]);
const batchSize = 500;
let remaining = limit;
let cursorCreatedAt = null;
let cursorId = null;
while (remaining > 0) {
const take = Math.min(batchSize, remaining);
const rows = await prisma.reimbursementForm.findMany({
where: {
...where,
...(cursorCreatedAt
? {
OR: [
{ created_at: { lt: cursorCreatedAt } },
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
],
}
: {}),
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take,
select: {
id: true,
form_number: true,
status: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
created_at: true,
owner: { select: { name: true } },
submitter: { select: { name: true } },
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow([
item.form_number ?? "",
item.status,
item.department_id,
item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
item.owner?.name ?? "",
item.submitter?.name ?? "",
item.created_at?.toISOString?.() ?? "",
item.id,
]);
remaining -= 1;
if (remaining <= 0) break;
}
const last = rows[rows.length - 1];
cursorCreatedAt = last.created_at;
cursorId = last.id;
}
}
function exportFilename(prefix, format) {
const now = new Date();
const stamp = now.toISOString().slice(0, 10);
@@ -731,7 +860,48 @@ async function exportsRoutes(fastify) {
return reply.send(stream);
},
);
fastify.get(
"/exports/dashboard/reimbursements",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(DASHBOARD_EXPORT_QUERY_SCHEMA, request.query);
const where = buildDashboardWhere(request, query);
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
const total = await fastify.prisma.reimbursementForm.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_DASHBOARD",
targetType: "export",
extra: {
format,
limit,
matchedTotal: total,
dateFrom: query.dateFrom ?? null,
dateTo: query.dateTo ?? null,
departmentId: query.departmentId ?? null,
},
}),
});
const filename = exportFilename("dashboard-reimbursements", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildReimbursementsXlsx(fastify.prisma, where, limit);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamDashboardReimbursementsCsv(fastify.prisma, where, limit));
return reply.send(stream);
},
);
}
module.exports = exportsRoutes;