1311 lines
27 KiB
TypeScript
1311 lines
27 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
|
||
}
|
||
|
||
|