feat: v3.4.12 ticket iteration
Refund rollback, winery T+3, multi withdraw, mini-user store detail, dev plan batch edit and WeCom dispatch, support ticket edit/attachments/batch status, package imageUrl. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types';
|
||||
import { validateSupportTicketStatusTransition } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
@@ -16,8 +17,22 @@ import type {
|
||||
RejectSupportTicketDto,
|
||||
SupportTicketListQueryDto,
|
||||
SupportTicketRemarkDto,
|
||||
UpdateSupportTicketDto,
|
||||
} from './dto/support-ticket.dto';
|
||||
|
||||
function normalizeAttachmentUrls(raw: unknown): string[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const urls = raw.map((u) => String(u || '').trim()).filter(Boolean);
|
||||
return urls.length ? urls : null;
|
||||
}
|
||||
|
||||
function mapSupportTicketRow<T extends { attachmentUrls?: unknown }>(ticket: T) {
|
||||
return {
|
||||
...ticket,
|
||||
attachmentUrls: normalizeAttachmentUrls(ticket.attachmentUrls),
|
||||
};
|
||||
}
|
||||
|
||||
function generateSupportTicketNo() {
|
||||
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
@@ -43,6 +58,9 @@ export class SupportTicketService {
|
||||
title: dto.title.trim(),
|
||||
content: dto.content?.trim() || null,
|
||||
remark: dto.remark?.trim() || null,
|
||||
attachmentUrls: dto.attachmentUrls?.length
|
||||
? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
creatorId: creator.id,
|
||||
creatorName: creator.name,
|
||||
},
|
||||
@@ -73,7 +91,80 @@ export class SupportTicketService {
|
||||
].join('\n'),
|
||||
)
|
||||
.catch(() => {});
|
||||
return serializeBigInt(ticket);
|
||||
return serializeBigInt(mapSupportTicketRow(ticket));
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateSupportTicketDto) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待评审工单可编辑');
|
||||
}
|
||||
const data: Prisma.CommonSupportTicketUpdateInput = {};
|
||||
if (dto.ticketType != null) data.ticketType = dto.ticketType as SupportTicketType;
|
||||
if (dto.title != null) data.title = dto.title.trim();
|
||||
if (dto.content !== undefined) data.content = dto.content?.trim() || null;
|
||||
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
||||
if (dto.attachmentUrls !== undefined) {
|
||||
const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean);
|
||||
data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull;
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data });
|
||||
return serializeBigInt(mapSupportTicketRow(updated));
|
||||
}
|
||||
|
||||
async batchUpdateStatus(
|
||||
ticketIds: bigint[],
|
||||
status: SupportTicketStatus,
|
||||
ctx: {
|
||||
isSuperAdmin: boolean;
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
reviewer?: { id: bigint; name: string };
|
||||
},
|
||||
) {
|
||||
const results: Array<{ ticketId: string; ok: boolean; message?: string }> = [];
|
||||
for (const id of ticketIds) {
|
||||
try {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
|
||||
const linkedTaskCount = linkedMap.get(String(id))?.length ?? 0;
|
||||
const guard = validateSupportTicketStatusTransition(
|
||||
ticket.status as 'PENDING_REVIEW' | 'REJECTED' | 'DEVELOPING' | 'TESTING' | 'PASSED',
|
||||
status as 'PENDING_REVIEW' | 'REJECTED' | 'DEVELOPING' | 'TESTING' | 'PASSED',
|
||||
{
|
||||
linkedTaskCount,
|
||||
isSuperAdmin: ctx.isSuperAdmin,
|
||||
rejectReason: ctx.rejectReason,
|
||||
},
|
||||
);
|
||||
if (!guard.ok) {
|
||||
results.push({ ticketId: String(id), ok: false, message: guard.message });
|
||||
continue;
|
||||
}
|
||||
const data: Prisma.CommonSupportTicketUpdateInput = { status };
|
||||
if (ctx.note?.trim()) data.remark = ctx.note.trim();
|
||||
if (status === 'REJECTED') {
|
||||
data.rejectReason = ctx.rejectReason!.trim();
|
||||
data.completedAt = new Date();
|
||||
if (ctx.reviewer) {
|
||||
data.reviewerId = ctx.reviewer.id;
|
||||
data.reviewerName = ctx.reviewer.name;
|
||||
data.reviewedAt = new Date();
|
||||
}
|
||||
}
|
||||
if (status === 'PASSED') data.completedAt = new Date();
|
||||
await this.prisma.commonSupportTicket.update({ where: { id }, data });
|
||||
results.push({ ticketId: String(id), ok: true });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
ticketId: String(id),
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
const successCount = results.filter((r) => r.ok).length;
|
||||
return { results, successCount, failCount: results.length - successCount };
|
||||
}
|
||||
|
||||
async list(query: SupportTicketListQueryDto) {
|
||||
@@ -98,7 +189,7 @@ export class SupportTicketService {
|
||||
]);
|
||||
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds(items.map((i) => i.id));
|
||||
const enriched = items.map((ticket) => ({
|
||||
...ticket,
|
||||
...mapSupportTicketRow(ticket),
|
||||
linkedTasks: (linkedMap.get(String(ticket.id)) ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
taskNo: t.taskNo,
|
||||
@@ -114,7 +205,7 @@ export class SupportTicketService {
|
||||
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
|
||||
return serializeBigInt({
|
||||
...ticket,
|
||||
...mapSupportTicketRow(ticket),
|
||||
linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
taskNo: t.taskNo,
|
||||
@@ -189,6 +280,8 @@ export class SupportTicketService {
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
tasks?: CreateDevPlanTaskFromTicketInput[];
|
||||
dispatchToWecom?: boolean;
|
||||
dispatchSupplement?: string;
|
||||
},
|
||||
) {
|
||||
if (input.decision === 'REJECT') {
|
||||
@@ -202,7 +295,7 @@ export class SupportTicketService {
|
||||
throw new BadRequestException('仅待评审工单可审批');
|
||||
}
|
||||
|
||||
await this.devPlan.createTasksFromTicket(id, input.tasks, reviewer.id);
|
||||
const createdTasks = await this.devPlan.createTasksFromTicket(id, input.tasks, reviewer.id);
|
||||
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
@@ -214,9 +307,31 @@ export class SupportTicketService {
|
||||
remark: input.note?.trim() || ticket.remark,
|
||||
},
|
||||
});
|
||||
|
||||
if (input.dispatchToWecom) {
|
||||
try {
|
||||
await this.devPlan.dispatchTasks(
|
||||
{
|
||||
taskIds: createdTasks.map((t) => t.id),
|
||||
supplement: input.dispatchSupplement,
|
||||
},
|
||||
reviewer.id,
|
||||
);
|
||||
} catch (err) {
|
||||
this.alert.notify({
|
||||
level: 'P2',
|
||||
category: 'ops',
|
||||
title: '审批后企微派发失败',
|
||||
detail: `工单 ${ticket.ticketNo}\n${err instanceof Error ? err.message : String(err)}`,
|
||||
dedupeKey: `support_review_dispatch_fail|${ticket.ticketNo}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
|
||||
return serializeBigInt({
|
||||
...updated,
|
||||
...mapSupportTicketRow(updated),
|
||||
linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
taskNo: t.taskNo,
|
||||
|
||||
Reference in New Issue
Block a user