feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
+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;
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户