import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { BenefitService } from '../benefit/benefit.service'; import { TradeService } from '../trade/trade.service'; import { TicketService } from '../common/ticket.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { PartnerCityService } from '../city-scope/partner-city.service'; import type { TicketListQueryDto } from '../common/dto/common-query.dto'; export type CollabPhase = 'AWAITING_PICKUP' | 'AWAITING_RESHIP' | 'HQ_DIRECT'; export type TicketCollabExtra = { evidenceUrls?: string[]; warehouseId?: string; warehouseName?: string; warehousePartnerAccountId?: string; storePartnerAccountId?: string; collabPhase?: CollabPhase; collabLogs?: { at: string; actorType: string; actorId: string; action: string }[]; }; const COLLAB_TYPES = ['RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'] as const; @Injectable() export class AdminTicketsService { constructor( private readonly prisma: PrismaService, private readonly ticketService: TicketService, private readonly tradeService: TradeService, private readonly benefitService: BenefitService, private readonly partnerCityService: PartnerCityService, ) {} list(query: TicketListQueryDto) { return this.ticketService.list(query); } detail(id: bigint) { return this.ticketService.detail(id); } async createByHq(body: { ticketType: string; orderNo: string; remark?: string; evidenceUrls?: string[]; }) { const orderNo = body.orderNo?.trim(); if (!orderNo) throw new BadRequestException('请填写订单号'); const order = await this.prisma.order.findFirst({ where: { orderNo } }); if (!order) throw new NotFoundException('订单不存在'); if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') { throw new BadRequestException('当前订单不可创建工单'); } if (['REFUNDING', 'REFUNDED'].includes(order.status) && body.ticketType !== 'ALERT') { throw new BadRequestException('订单已在退款流程中'); } const pending = await this.prisma.commonTicket.findFirst({ where: { ticketType: body.ticketType as never, refType: 'ORDER', refId: order.id, status: { in: ['PENDING', 'OPEN'] }, }, }); if (pending) throw new BadRequestException('该类型工单已在处理中'); const evidenceUrls = (body.evidenceUrls ?? []).filter((u) => typeof u === 'string' && u.trim()); return this.ticketService.create({ ticketType: body.ticketType, refType: 'ORDER', refId: order.id.toString(), remark: body.remark ?? '', extraJson: evidenceUrls.length ? { evidenceUrls } : undefined, }); } private parseExtra(raw: unknown): TicketCollabExtra { if (!raw || typeof raw !== 'object') return {}; return raw as TicketCollabExtra; } private appendLog( extra: TicketCollabExtra, actorType: string, actorId: string, action: string, ): TicketCollabExtra { const logs = [...(extra.collabLogs ?? [])]; logs.push({ at: new Date().toISOString(), actorType, actorId, action }); return { ...extra, collabLogs: logs }; } /** 解析订单负责仓:优先履约仓,否则城内首个 ACTIVE 仓 */ async resolveWarehouseForOrder(orderId: bigint) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, select: { id: true, cityId: true, fulfillmentWarehouseId: true, partnerAccountIdAtPay: true, orderNo: true, receiverAddress: true, receiverName: true, receiverPhone: true, productName: true, }, }); if (!order) throw new NotFoundException('关联订单不存在'); let warehouse = order.fulfillmentWarehouseId ? await this.prisma.cityWarehouse.findUnique({ where: { id: order.fulfillmentWarehouseId } }) : null; if (!warehouse) { warehouse = await this.prisma.cityWarehouse.findFirst({ where: { cityId: order.cityId, status: 'ACTIVE' }, orderBy: { id: 'asc' }, }); } return { order, warehouse }; } private async executeRefund(orderId: bigint, remark: string) { await this.prisma.order.update({ where: { id: orderId }, data: { status: 'REFUNDED', payStatus: 'REFUNDED' }, }); await this.benefitService.voidCouponsOnRefund(orderId); await this.prisma.logThirdParty.create({ data: { provider: 'WECHAT_REFUND', scene: 'ORDER_REFUND', refType: 'ORDER', refId: orderId, status: 'SUCCESS', amount: 0, }, }); await this.prisma.commonEvent.create({ data: { eventType: 'ORDER_STATUS', refType: 'ORDER', refId: orderId, actorType: 'HQ', status: 'REFUNDED', remark, }, }); } private async executeReship(orderId: bigint) { await this.tradeService.applyStatusTransition(orderId, 'PENDING_SHIP', 'PENDING_SHIP', 'HQ'); } async approve(id: bigint, remark?: string, actorId = '0') { const ticket = await this.prisma.commonTicket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('工单不存在'); if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') { throw new BadRequestException('工单状态不可审批'); } if (ticket.refType !== 'ORDER') { throw new BadRequestException('仅支持订单售后工单'); } const existingExtra = this.parseExtra(ticket.extraJson); // 仅退款:立即退款 if (ticket.ticketType === 'REFUND') { await this.executeRefund(ticket.refId, remark ?? '仅退款工单审批通过'); return this.ticketService.updateExtraJson( id, this.appendLog(existingExtra, 'HQ', actorId, 'APPROVE_REFUND'), 'RESOLVED', remark ?? '审批通过', ); } if (!(COLLAB_TYPES as readonly string[]).includes(ticket.ticketType)) { return this.ticketService.updateStatus(id, { status: 'RESOLVED', remark: remark ?? '审批通过', }); } const { order, warehouse } = await this.resolveWarehouseForOrder(ticket.refId); const warehousePartnerAccountId = warehouse?.partnerAccountId?.toString() || (warehouse ? ( await this.prisma.partnerAccount.findFirst({ where: { managedWarehouseId: warehouse.id }, select: { id: true }, }) )?.id.toString() : undefined); let collabPhase: CollabPhase = warehousePartnerAccountId ? 'AWAITING_PICKUP' : 'HQ_DIRECT'; if (ticket.ticketType === 'RESHIPMENT' && warehousePartnerAccountId) { collabPhase = 'AWAITING_RESHIP'; } if (!warehouse) { collabPhase = 'HQ_DIRECT'; } const storePartnerAccountId = ticket.ticketType === 'RETURN_REFUND' && order.partnerAccountIdAtPay ? order.partnerAccountIdAtPay.toString() : undefined; let extra: TicketCollabExtra = { ...existingExtra, warehouseId: warehouse?.id.toString(), warehouseName: warehouse?.name, warehousePartnerAccountId, storePartnerAccountId, collabPhase, }; extra = this.appendLog(extra, 'HQ', actorId, `APPROVE_COLLAB:${collabPhase}`); await this.prisma.commonEvent.create({ data: { eventType: 'TICKET_COLLAB', refType: 'TICKET', refId: id, actorType: 'HQ', status: 'COLLABORATING', remark: remark ?? `工单进入协同 ${collabPhase}`, }, }); return this.ticketService.updateExtraJson(id, extra as Record, 'COLLABORATING', remark ?? '审批通过,待协同'); } reject(id: bigint, remark?: string) { return this.ticketService.updateStatus(id, { status: 'REJECTED', remark: remark ?? '审批驳回', }); } /** 完成协同:取回确认(退货类)或补发确认 */ async completeCollab( id: bigint, opts: { actorType: 'HQ' | 'PARTNER'; actorId: string; mode: 'pickup' | 'reship' | 'auto'; }, ) { const ticket = await this.prisma.commonTicket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('工单不存在'); if (ticket.status !== 'COLLABORATING') { throw new BadRequestException('工单不在协同中'); } if (ticket.refType !== 'ORDER') throw new BadRequestException('仅支持订单售后工单'); let extra = this.parseExtra(ticket.extraJson); const phase = extra.collabPhase ?? 'HQ_DIRECT'; if (opts.actorType === 'PARTNER') { // 仅管仓合伙人可操作协同节点;归属合伙人只读 if (extra.warehousePartnerAccountId !== opts.actorId) { throw new BadRequestException('无权操作此工单'); } } const mode = opts.mode === 'auto' ? ticket.ticketType === 'RESHIPMENT' ? 'reship' : 'pickup' : opts.mode; if (mode === 'reship' && ticket.ticketType !== 'RESHIPMENT') { throw new BadRequestException('当前工单不是补发类型'); } if (mode === 'pickup' && !['DAMAGE_RETURN', 'RETURN_REFUND'].includes(ticket.ticketType)) { throw new BadRequestException('当前工单不需要取回确认'); } if (ticket.ticketType === 'RESHIPMENT') { await this.executeReship(ticket.refId); extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_RESHIP'); } else if (ticket.ticketType === 'DAMAGE_RETURN' || ticket.ticketType === 'RETURN_REFUND') { await this.executeRefund(ticket.refId, `${ticket.ticketType} 协同取回后完成退款`); extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_PICKUP_REFUND'); } else { throw new BadRequestException('工单类型不支持协同完成'); } extra = { ...extra, collabPhase: undefined }; await this.prisma.commonEvent.create({ data: { eventType: 'TICKET_COLLAB', refType: 'TICKET', refId: id, actorType: opts.actorType, status: 'RESOLVED', remark: `协同完成 phaseWas=${phase} mode=${mode}`, }, }); return this.ticketService.updateExtraJson(id, extra as Record, 'RESOLVED', '协同完成'); } async completeCollabByHq(id: bigint, actorId: string) { return this.completeCollab(id, { actorType: 'HQ', actorId, mode: 'auto' }); } async listPartnerTickets(partnerAccountId: bigint, page = 1, pageSize = 20) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const pid = primary.id.toString(); const warehouses = await this.prisma.cityWarehouse.findMany({ where: { OR: [{ partnerAccountId: primary.id }, { managedBy: { id: primary.id } }], }, select: { id: true }, }); const warehouseIds = new Set(warehouses.map((w) => w.id.toString())); const tickets = await this.prisma.commonTicket.findMany({ where: { status: 'COLLABORATING', ticketType: { in: [...COLLAB_TYPES] }, refType: 'ORDER', }, orderBy: { createdAt: 'desc' }, }); const filtered = tickets.filter((t) => { const extra = this.parseExtra(t.extraJson); if (extra.warehousePartnerAccountId === pid) return true; if (extra.storePartnerAccountId === pid) return true; if (extra.warehouseId && warehouseIds.has(extra.warehouseId)) return true; return false; }); const pageItems = filtered.slice((page - 1) * pageSize, page * pageSize); const orderIds = pageItems.map((t) => t.refId); const orders = await this.prisma.order.findMany({ where: { id: { in: orderIds } }, select: { id: true, orderNo: true, productName: true, receiverAddress: true, receiverName: true, receiverPhone: true, payAmount: true, }, }); const orderMap = new Map(orders.map((o) => [o.id.toString(), o])); return serializeBigInt({ items: pageItems.map((t) => { const order = orderMap.get(t.refId.toString()); const extra = this.parseExtra(t.extraJson); return { ...t, extraJson: extra, orderNo: order?.orderNo, productName: order?.productName, receiverAddress: order?.receiverAddress, receiverName: order?.receiverName, receiverPhone: order?.receiverPhone, payAmount: order?.payAmount, }; }), total: filtered.length, page, pageSize, }); } async getPartnerTicket(partnerAccountId: bigint, ticketId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const pid = primary.id.toString(); const ticket = await this.prisma.commonTicket.findUnique({ where: { id: ticketId } }); if (!ticket || ticket.refType !== 'ORDER') throw new NotFoundException('工单不存在或无权查看'); const extra = this.parseExtra(ticket.extraJson); const warehouses = await this.prisma.cityWarehouse.findMany({ where: { OR: [{ partnerAccountId: primary.id }, { managedBy: { id: primary.id } }], }, select: { id: true }, }); const warehouseIds = new Set(warehouses.map((w) => w.id.toString())); const allowed = extra.warehousePartnerAccountId === pid || extra.storePartnerAccountId === pid || (extra.warehouseId != null && warehouseIds.has(extra.warehouseId)); if (!allowed) throw new NotFoundException('工单不存在或无权查看'); const order = await this.prisma.order.findUnique({ where: { id: ticket.refId }, select: { orderNo: true, productName: true, receiverAddress: true, receiverName: true, receiverPhone: true, payAmount: true, }, }); return serializeBigInt({ ...ticket, extraJson: extra, orderNo: order?.orderNo, productName: order?.productName, receiverAddress: order?.receiverAddress, receiverName: order?.receiverName, receiverPhone: order?.receiverPhone, payAmount: order?.payAmount, }); } async partnerConfirmPickup(partnerAccountId: bigint, ticketId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); return this.completeCollab(ticketId, { actorType: 'PARTNER', actorId: primary.id.toString(), mode: 'pickup', }); } async partnerConfirmReship(partnerAccountId: bigint, ticketId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); return this.completeCollab(ticketId, { actorType: 'PARTNER', actorId: primary.id.toString(), mode: 'reship', }); } async listPartnerReshipments(partnerAccountId: bigint) { return this.listPartnerTickets(partnerAccountId, 1, 100); } }