82c447b193
酒厂银行账户
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
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<string, unknown>, '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<string, unknown>, '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);
|
|
}
|
|
}
|