import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { DevPlanTaskStatus, DevPlanTaskType, DevPlanVersionStatus, Prisma, } from '@prisma/client'; import type { CreateDevPlanTaskInput, DevPlanSettingsDto, DevPlanTaskDto, DevPlanVersionDto, } from '@dukang/shared-types'; import { taskCompletedAtOnStatus, versionStatusTimestamps } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import type { CreateDevPlanTaskDto, CreateDevPlanVersionDto, DevPlanTaskDispatchDto, DevPlanTaskBatchUpdateDto, DevPlanTaskListQueryDto, UpdateDevPlanSettingsDto, UpdateDevPlanTaskDto, UpdateDevPlanVersionDto, } from './dto/dev-plan.dto'; import { buildDevPlanDocx, buildDevPlanExportFilename, buildDevPlanMarkdown, buildDevPlanPdf, buildDevPlanXlsx, devPlanExportMimeType, mapTaskToExportRow, type DevPlanExportFormat, } from './admin-dev-plan-export.util'; function parseAttachmentUrls(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((u) => String(u).trim()).filter(Boolean); } function attachmentUrlsInput(urls?: string[]): Prisma.InputJsonValue | undefined { const list = (urls ?? []).map((u) => u.trim()).filter(Boolean); return list.length ? (list as unknown as Prisma.InputJsonValue) : undefined; } function generateTaskNo() { const d = new Date(); const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; return `DPT-${ymd}-${String(Math.floor(Math.random() * 900) + 100)}`; } @Injectable() export class DevPlanService { constructor( private readonly prisma: PrismaService, private readonly wecomPush: WecomMessagePushService, ) {} private async loadHqNames(ids: bigint[]): Promise> { const uniq = [...new Set(ids.map(String))].map(BigInt); if (!uniq.length) return new Map(); const rows = await this.prisma.hqAccount.findMany({ where: { id: { in: uniq } }, select: { id: true, name: true }, }); return new Map(rows.map((r) => [String(r.id), r.name])); } private mapTask( row: { id: bigint; taskNo: string; content: string; type: DevPlanTaskType; status: DevPlanTaskStatus; creatorHqAccountId: bigint; supportTicketId: bigint | null; lastDispatchedAt: Date | null; createdAt: Date; completedAt: Date | null; attachmentUrls?: unknown; }, extras?: { creatorName?: string | null; supportTicketNo?: string | null }, ): DevPlanTaskDto { return { id: String(row.id), taskNo: row.taskNo, content: row.content, type: row.type, status: row.status, creatorHqAccountId: String(row.creatorHqAccountId), creatorName: extras?.creatorName ?? null, supportTicketId: row.supportTicketId != null ? String(row.supportTicketId) : null, supportTicketNo: extras?.supportTicketNo ?? null, lastDispatchedAt: row.lastDispatchedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), completedAt: row.completedAt?.toISOString() ?? null, attachmentUrls: parseAttachmentUrls(row.attachmentUrls), }; } private mapVersion( row: { id: bigint; versionNo: string; status: DevPlanVersionStatus; assigneeHqAccountId: bigint | null; devStartedAt: Date | null; devCompletedAt: Date | null; releasedAt: Date | null; durationMinutes: number | null; createdAt: Date; }, extras?: { assigneeName?: string | null; content?: string | null; tasks?: DevPlanTaskDto[]; taskIds?: string[] }, ): DevPlanVersionDto { return { id: String(row.id), versionNo: row.versionNo, content: extras?.content ?? null, status: row.status, assigneeHqAccountId: row.assigneeHqAccountId != null ? String(row.assigneeHqAccountId) : null, assigneeName: extras?.assigneeName ?? null, createdAt: row.createdAt.toISOString(), devStartedAt: row.devStartedAt?.toISOString() ?? null, devCompletedAt: row.devCompletedAt?.toISOString() ?? null, releasedAt: row.releasedAt?.toISOString() ?? null, durationMinutes: row.durationMinutes, tasks: extras?.tasks, taskIds: extras?.taskIds, }; } async listTasks(query: DevPlanTaskListQueryDto) { const page = Number(query.page) || 1; const pageSize = Number(query.pageSize) || 20; const where: Prisma.DevPlanTaskWhereInput = {}; if (query.status) where.status = query.status as DevPlanTaskStatus; if (query.type) where.type = query.type as DevPlanTaskType; if (query.keyword?.trim()) { where.OR = [ { taskNo: { contains: query.keyword.trim() } }, { content: { contains: query.keyword.trim() } }, ]; } const [rows, total] = await Promise.all([ this.prisma.devPlanTask.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.devPlanTask.count({ where }), ]); const creatorIds = rows.map((r) => r.creatorHqAccountId); const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null); const [names, tickets] = await Promise.all([ this.loadHqNames(creatorIds), ticketIds.length ? this.prisma.commonSupportTicket.findMany({ where: { id: { in: ticketIds } }, select: { id: true, ticketNo: true }, }) : Promise.resolve([]), ]); const ticketMap = new Map( tickets.map((t) => [String(t.id), t.ticketNo] as [string, string]), ); const items = rows.map((r) => this.mapTask(r, { creatorName: names.get(String(r.creatorHqAccountId)) ?? null, supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null, }), ); return serializeBigInt({ items, total, page, pageSize }); } async getTask(id: bigint): Promise { const row = await this.prisma.devPlanTask.findUnique({ where: { id } }); if (!row) throw new NotFoundException('开发任务不存在'); const names = await this.loadHqNames([row.creatorHqAccountId]); let supportTicketNo: string | null = null; if (row.supportTicketId) { const t = await this.prisma.commonSupportTicket.findUnique({ where: { id: row.supportTicketId }, select: { ticketNo: true }, }); supportTicketNo = t?.ticketNo ?? null; } return this.mapTask(row, { creatorName: names.get(String(row.creatorHqAccountId)) ?? null, supportTicketNo, }); } async createTask(dto: CreateDevPlanTaskDto, creatorHqAccountId: bigint) { const row = await this.prisma.devPlanTask.create({ data: { taskNo: generateTaskNo(), content: dto.content.trim(), type: dto.type as DevPlanTaskType, creatorHqAccountId, supportTicketId: dto.supportTicketId ? BigInt(dto.supportTicketId) : null, attachmentUrls: attachmentUrlsInput(dto.attachmentUrls), }, }); return this.getTask(row.id); } async createTasksFromTicket( ticketId: bigint, tasks: CreateDevPlanTaskInput[], creatorHqAccountId: bigint, ) { if (!tasks.length) throw new BadRequestException('至少创建 1 条开发任务'); const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id: ticketId }, select: { attachmentUrls: true }, }); const ticketUrls = parseAttachmentUrls(ticket?.attachmentUrls); const created = await this.prisma.$transaction( tasks.map((t) => this.prisma.devPlanTask.create({ data: { taskNo: generateTaskNo(), content: t.content.trim(), type: t.type as DevPlanTaskType, creatorHqAccountId, supportTicketId: ticketId, attachmentUrls: attachmentUrlsInput( t.attachmentUrls?.length ? t.attachmentUrls : ticketUrls, ), }, }), ), ); return Promise.all(created.map((r) => this.getTask(r.id))); } async updateTask(id: bigint, dto: UpdateDevPlanTaskDto) { const existing = await this.prisma.devPlanTask.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('开发任务不存在'); const data: Prisma.DevPlanTaskUpdateInput = {}; if (dto.content != null) data.content = dto.content.trim(); if (dto.type != null) data.type = dto.type as DevPlanTaskType; if (dto.status != null) { data.status = dto.status as DevPlanTaskStatus; const completed = taskCompletedAtOnStatus( existing.status as 'TODO' | 'DEVELOPED' | 'RELEASED', dto.status as 'TODO' | 'DEVELOPED' | 'RELEASED', existing.completedAt, ); if (completed) data.completedAt = completed; } if (dto.attachmentUrls !== undefined) { data.attachmentUrls = attachmentUrlsInput(dto.attachmentUrls); } await this.prisma.devPlanTask.update({ where: { id }, data }); return this.getTask(id); } async deleteTask(id: bigint) { const existing = await this.prisma.devPlanTask.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('开发任务不存在'); await this.prisma.devPlanVersionTask.deleteMany({ where: { taskId: id } }); await this.prisma.devPlanTask.delete({ where: { id } }); return { ok: true }; } async listLinkedTasksByTicketIds(ticketIds: bigint[]) { if (!ticketIds.length) return new Map(); const rows = await this.prisma.devPlanTask.findMany({ where: { supportTicketId: { in: ticketIds } }, orderBy: { createdAt: 'asc' }, }); const map = new Map(); for (const r of rows) { const key = String(r.supportTicketId); const list = map.get(key) ?? []; list.push(this.mapTask(r)); map.set(key, list); } return map; } async listVersions(query: { status?: string; page?: number; pageSize?: number }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.DevPlanVersionWhereInput = {}; if (query.status) where.status = query.status as DevPlanVersionStatus; const [rows, total] = await Promise.all([ this.prisma.devPlanVersion.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.devPlanVersion.count({ where }), ]); const items = rows.map((r) => this.mapVersion(r, { content: (r as Record).content as string | null, }), ); return serializeBigInt({ items, total, page, pageSize }); } private async loadVersionTasks(versionId: bigint): Promise<{ tasks: DevPlanTaskDto[]; taskIds: string[] }> { const links = await this.prisma.devPlanVersionTask.findMany({ where: { versionId }, orderBy: { sortOrder: 'asc' }, include: { task: true }, }); const creatorIds = links.map((l) => l.task.creatorHqAccountId); const names = await this.loadHqNames(creatorIds); const tasks = links.map((l) => this.mapTask(l.task, { creatorName: names.get(String(l.task.creatorHqAccountId)) ?? null }), ); return { tasks, taskIds: tasks.map((t) => t.id) }; } async getVersion(id: bigint): Promise { const row = await this.prisma.devPlanVersion.findUnique({ where: { id } }); if (!row) throw new NotFoundException('开发版本不存在'); const { tasks, taskIds } = await this.loadVersionTasks(id); return this.mapVersion(row, { content: (row as Record).content as string | null, tasks, taskIds }); } private async replaceVersionTasks(versionId: bigint, taskIds: string[]) { await this.prisma.devPlanVersionTask.deleteMany({ where: { versionId } }); if (!taskIds.length) return; const ids = taskIds.map(BigInt); const found = await this.prisma.devPlanTask.findMany({ where: { id: { in: ids } }, select: { id: true } }); if (found.length !== ids.length) throw new BadRequestException('部分任务不存在'); await this.prisma.devPlanVersionTask.createMany({ data: ids.map((taskId, i) => ({ versionId, taskId, sortOrder: i })), }); } async createVersion(dto: CreateDevPlanVersionDto) { const existing = await this.prisma.devPlanVersion.findUnique({ where: { versionNo: dto.versionNo.trim() }, }); if (existing) throw new BadRequestException('版本号已存在'); const status = (dto.status ?? 'PENDING') as DevPlanVersionStatus; const ts = versionStatusTimestamps('PENDING', status, {}); const row = await this.prisma.devPlanVersion.create({ data: { versionNo: dto.versionNo.trim(), status, ...ts, }, }); if (dto.content?.trim()) { await this.prisma.$executeRaw`UPDATE dev_plan_version SET content = ${dto.content.trim()} WHERE id = ${row.id}`; } if (dto.taskIds?.length) { await this.replaceVersionTasks(row.id, dto.taskIds); } if (status === 'RELEASED') { await this.cascadeVersionReleased(row.id, row.versionNo); } return this.getVersion(row.id); } async updateVersion(id: bigint, dto: UpdateDevPlanVersionDto) { const existing = await this.prisma.devPlanVersion.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('开发版本不存在'); const data: Prisma.DevPlanVersionUpdateInput = {}; if (dto.versionNo != null) { const dup = await this.prisma.devPlanVersion.findFirst({ where: { versionNo: dto.versionNo.trim(), NOT: { id } }, }); if (dup) throw new BadRequestException('版本号已存在'); data.versionNo = dto.versionNo.trim(); } if (dto.content !== undefined) { await this.prisma.$executeRaw`UPDATE dev_plan_version SET content = ${dto.content?.trim() || null} WHERE id = ${id}`; } if (dto.status != null) { data.status = dto.status as DevPlanVersionStatus; Object.assign( data, versionStatusTimestamps( existing.status as 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED', dto.status as 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED', { devStartedAt: existing.devStartedAt, devCompletedAt: existing.devCompletedAt, releasedAt: existing.releasedAt, }), ); } await this.prisma.devPlanVersion.update({ where: { id }, data }); if ( dto.status === 'RELEASED' && existing.status !== 'RELEASED' ) { const versionNo = (dto.versionNo ?? existing.versionNo).trim(); await this.cascadeVersionReleased(id, versionNo); } if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds); return this.getVersion(id); } async deleteVersion(id: bigint) { const existing = await this.prisma.devPlanVersion.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('开发版本不存在'); await this.prisma.devPlanVersionTask.deleteMany({ where: { versionId: id } }); await this.prisma.devPlanVersion.delete({ where: { id } }); return { ok: true }; } async replaceVersionTasksApi(versionId: bigint, taskIds: string[]) { const existing = await this.prisma.devPlanVersion.findUnique({ where: { id: versionId } }); if (!existing) throw new NotFoundException('开发版本不存在'); await this.replaceVersionTasks(versionId, taskIds); return this.getVersion(versionId); } private async cascadeVersionReleased(versionId: bigint, versionNo: string) { const links = await this.prisma.devPlanVersionTask.findMany({ where: { versionId }, select: { taskId: true }, }); if (!links.length) return; const taskIds = links.map((l) => l.taskId); const now = new Date(); await this.prisma.$transaction(async (tx) => { await tx.devPlanTask.updateMany({ where: { id: { in: taskIds }, status: { not: 'RELEASED' } }, data: { status: 'RELEASED', completedAt: now }, }); const tasks = await tx.devPlanTask.findMany({ where: { id: { in: taskIds }, supportTicketId: { not: null } }, select: { supportTicketId: true }, }); const ticketIds = [ ...new Set( tasks .map((t) => t.supportTicketId) .filter((id): id is bigint => id != null), ), ]; if (!ticketIds.length) return; await tx.commonSupportTicket.updateMany({ where: { id: { in: ticketIds }, status: { not: 'REJECTED' } }, data: { status: 'PUBLISHED', releasedVersionNo: versionNo, publishedAt: now, }, }); }); } private async addVersionTasks(versionId: bigint, taskIds: string[]) { if (!taskIds.length) return; const ids = taskIds.map(BigInt); const found = await this.prisma.devPlanTask.findMany({ where: { id: { in: ids } }, select: { id: true } }); if (found.length !== ids.length) throw new BadRequestException('部分任务不存在'); const existingLinks = await this.prisma.devPlanVersionTask.findMany({ where: { versionId, taskId: { in: ids } }, select: { taskId: true }, }); const existingIds = new Set(existingLinks.map((l) => String(l.taskId))); const newIds = ids.filter((id) => !existingIds.has(String(id))); if (!newIds.length) return; const maxSort = await this.prisma.devPlanVersionTask.findFirst({ where: { versionId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true }, }); let nextSort = (maxSort?.sortOrder ?? -1) + 1; await this.prisma.devPlanVersionTask.createMany({ data: newIds.map((taskId) => ({ versionId, taskId, sortOrder: nextSort++ })), }); } async addVersionTasksApi(versionId: bigint, taskIds: string[]) { const existing = await this.prisma.devPlanVersion.findUnique({ where: { id: versionId } }); if (!existing) throw new NotFoundException('开发版本不存在'); await this.addVersionTasks(versionId, taskIds); return this.getVersion(versionId); } private async ensureSettingsRow() { let row = await this.prisma.devPlanSettings.findFirst(); if (!row) { row = await this.prisma.devPlanSettings.create({ data: {} }); } return row; } mapSettings(row: { reviewAssistantLlmConfigId: bigint | null; reviewAssistantKnowledgeBaseId: bigint | null; reviewAssistantPrompt: string | null; }): DevPlanSettingsDto { return { reviewAssistantLlmConfigId: row.reviewAssistantLlmConfigId != null ? String(row.reviewAssistantLlmConfigId) : null, reviewAssistantKnowledgeBaseId: row.reviewAssistantKnowledgeBaseId != null ? String(row.reviewAssistantKnowledgeBaseId) : null, reviewAssistantPrompt: row.reviewAssistantPrompt, }; } async getSettings(): Promise { const row = await this.ensureSettingsRow(); return this.mapSettings(row); } async getSettingsRaw() { return this.ensureSettingsRow(); } async updateSettings(dto: UpdateDevPlanSettingsDto) { const row = await this.ensureSettingsRow(); const data: Prisma.DevPlanSettingsUpdateInput = {}; if (dto.reviewAssistantLlmConfigId !== undefined) { data.reviewAssistantLlmConfigId = dto.reviewAssistantLlmConfigId ? BigInt(dto.reviewAssistantLlmConfigId) : null; } if (dto.reviewAssistantKnowledgeBaseId !== undefined) { data.reviewAssistantKnowledgeBaseId = dto.reviewAssistantKnowledgeBaseId ? BigInt(dto.reviewAssistantKnowledgeBaseId) : null; } if (dto.reviewAssistantPrompt !== undefined) data.reviewAssistantPrompt = dto.reviewAssistantPrompt; const updated = await this.prisma.devPlanSettings.update({ where: { id: row.id }, data }); return this.mapSettings(updated); } async dispatchTasks(dto: DevPlanTaskDispatchDto, operatorHqAccountId: bigint) { if (!dto.taskIds.length) throw new BadRequestException('请至少选择 1 条任务'); const taskIds = dto.taskIds.map(BigInt); const tasks = await this.prisma.devPlanTask.findMany({ where: { id: { in: taskIds } }, include: { supportTicket: { select: { ticketNo: true } } }, }); if (tasks.length !== taskIds.length) throw new BadRequestException('部分任务不存在'); const lines = tasks.map( (t, i) => `${i + 1}. **${t.taskNo}** [${t.type}] ${t.content.slice(0, 120)}${ t.supportTicket ? `\n 工单:${t.supportTicket.ticketNo}` : '' }`, ); const defaultSupplement = '请按以下任务进入开发流程'; const supplement = dto.supplement?.trim() || defaultSupplement; const markdown = `${supplement}\n\n**任务清单(${tasks.length})**\n${lines.join('\n')}`; await this.wecomPush.dispatchMarkdownOrThrow('dev_plan.task_dispatch', markdown); const now = new Date(); await this.prisma.$transaction([ this.prisma.devPlanTask.updateMany({ where: { id: { in: taskIds } }, data: { lastDispatchedAt: now }, }), this.prisma.devPlanTaskDispatch.create({ data: { taskIdsJson: JSON.stringify(dto.taskIds), supplement: dto.supplement?.trim() || null, operatorHqAccountId, }, }), ]); return { ok: true, dispatched: tasks.length }; } private async appendVersionTasks(versionId: bigint, taskIds: string[]) { const version = await this.prisma.devPlanVersion.findUnique({ where: { id: versionId } }); if (!version) throw new NotFoundException('版本不存在'); const ids = taskIds.map(BigInt); const existing = await this.prisma.devPlanVersionTask.findMany({ where: { versionId, taskId: { in: ids } }, select: { taskId: true }, }); const linked = new Set(existing.map((row) => row.taskId.toString())); const toAdd = ids.filter((id) => !linked.has(id.toString())); if (!toAdd.length) return; const maxSort = await this.prisma.devPlanVersionTask.aggregate({ where: { versionId }, _max: { sortOrder: true }, }); let sort = (maxSort._max.sortOrder ?? -1) + 1; await this.prisma.devPlanVersionTask.createMany({ data: toAdd.map((taskId, index) => ({ versionId, taskId, sortOrder: sort + index })), }); } async batchUpdateTasks(dto: DevPlanTaskBatchUpdateDto) { if (!dto.taskIds.length) throw new BadRequestException('请至少选择 1 条任务'); if (!dto.status && !dto.versionId) { throw new BadRequestException('请至少指定状态或关联版本'); } const taskIds = dto.taskIds.map(BigInt); const tasks = await this.prisma.devPlanTask.findMany({ where: { id: { in: taskIds } } }); if (tasks.length !== taskIds.length) throw new BadRequestException('部分任务不存在'); if (dto.status) { for (const task of tasks) { await this.updateTask(task.id, { status: dto.status }); } } if (dto.versionId) { await this.appendVersionTasks(BigInt(dto.versionId), dto.taskIds); } return { updated: taskIds.length }; } private buildTaskExportWhere(query: { status?: string; type?: string; keyword?: string; }): Prisma.DevPlanTaskWhereInput { const where: Prisma.DevPlanTaskWhereInput = {}; if (query.status) where.status = query.status as DevPlanTaskStatus; if (query.type) where.type = query.type as DevPlanTaskType; if (query.keyword?.trim()) { where.OR = [ { taskNo: { contains: query.keyword.trim() } }, { content: { contains: query.keyword.trim() } }, ]; } return where; } async exportTasks(dto: { scope: 'filter' | 'selected'; format: DevPlanExportFormat; ids?: string[]; status?: string; type?: string; keyword?: string; }) { let rows: Array<{ id: bigint; taskNo: string; content: string; type: DevPlanTaskType; status: DevPlanTaskStatus; creatorHqAccountId: bigint; supportTicketId: bigint | null; attachmentUrls: unknown; createdAt: Date; completedAt: Date | null; }>; if (dto.scope === 'selected') { if (!dto.ids?.length) throw new BadRequestException('请先勾选要导出的任务'); const ids = dto.ids.map(BigInt); rows = await this.prisma.devPlanTask.findMany({ where: { id: { in: ids } }, orderBy: { createdAt: 'desc' }, }); if (rows.length !== ids.length) throw new BadRequestException('部分任务不存在'); } else { rows = await this.prisma.devPlanTask.findMany({ where: this.buildTaskExportWhere(dto), orderBy: { createdAt: 'desc' }, }); } if (!rows.length) throw new BadRequestException('没有可导出的任务'); const creatorIds = rows.map((r) => r.creatorHqAccountId); const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null); const [names, tickets] = await Promise.all([ this.loadHqNames(creatorIds), ticketIds.length ? this.prisma.commonSupportTicket.findMany({ where: { id: { in: ticketIds } }, select: { id: true, ticketNo: true }, }) : Promise.resolve([]), ]); const ticketMap = new Map(tickets.map((t) => [String(t.id), t.ticketNo] as [string, string])); const exportRows = rows.map((r) => mapTaskToExportRow({ taskNo: r.taskNo, type: r.type, status: r.status, content: r.content, supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null, creatorName: names.get(String(r.creatorHqAccountId)) ?? null, createdAt: r.createdAt.toISOString(), completedAt: r.completedAt?.toISOString() ?? null, attachmentUrls: parseAttachmentUrls(r.attachmentUrls), }), ); let buffer: Buffer; switch (dto.format) { case 'markdown': buffer = buildDevPlanMarkdown(exportRows); break; case 'docx': buffer = await buildDevPlanDocx(exportRows); break; case 'xlsx': buffer = await buildDevPlanXlsx(exportRows); break; case 'pdf': buffer = await buildDevPlanPdf(exportRows); break; default: throw new BadRequestException('不支持的导出格式'); } return { filename: buildDevPlanExportFilename(dto.format, exportRows.length), mimeType: devPlanExportMimeType(dto.format), contentBase64: buffer.toString('base64'), count: exportRows.length, }; } }