Files
dukang/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts
T
jacy 7e12eb3ca6
CI / verify (pull_request) Waiting to run
v3.5.6版本迭代
2026-08-23 16:41:22 +08:00

1311 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Map<string, string>> {
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<string, string>(
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<DevPlanTaskDto> {
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<string, DevPlanTaskDto[]>();
const rows = await this.prisma.devPlanTask.findMany({
where: { supportTicketId: { in: ticketIds } },
orderBy: { createdAt: 'asc' },
});
const map = new Map<string, DevPlanTaskDto[]>();
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<string, unknown>).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<DevPlanVersionDto> {
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<string, unknown>).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<DevPlanSettingsDto> {
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,
};
}
}