fix;提交、

This commit is contained in:
ljy
2026-07-19 22:16:55 +08:00
parent 76270b20bf
commit 792b543ba8
34 changed files with 2310 additions and 398 deletions
@@ -0,0 +1,48 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { TradeService } from '../trade/trade.service';
import { IssueInvoiceDto, RejectInvoiceDto } from '../trade/dto/after-sale.dto';
@Controller('admin/invoices')
@UseGuards(HqAuthGuard)
export class AdminInvoicesController {
constructor(private readonly tradeService: TradeService) {}
@Get()
list(
@Query('status') status?: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.tradeService.adminListInvoices({
status,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.tradeService.adminGetInvoice(BigInt(id));
}
@Post(':id/issue')
issue(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: IssueInvoiceDto,
) {
return this.tradeService.adminIssueInvoice(BigInt(id), user.actorId, body);
}
@Post(':id/reject')
reject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: RejectInvoiceDto,
) {
return this.tradeService.adminRejectInvoice(BigInt(id), user.actorId, body.remark);
}
}
@@ -1,5 +1,9 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminTicketsService } from './admin-tickets.service';
@@ -27,8 +31,12 @@ export class AdminTicketsController {
refIdParam: 'id',
includeBody: true,
})
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
return this.service.approve(BigInt(id), body.remark);
approve(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { remark?: string },
) {
return this.service.approve(BigInt(id), body.remark, user.actorId.toString());
}
@Post(':id/reject')
@@ -41,4 +49,52 @@ export class AdminTicketsController {
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
return this.service.reject(BigInt(id), body.remark);
}
@Post(':id/complete-collab')
@HqOperation({
action: HqOperationAction.TICKET_APPROVE,
refType: 'TICKET',
refIdParam: 'id',
includeBody: true,
})
completeCollab(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.completeCollabByHq(BigInt(id), user.actorId.toString());
}
}
@Controller('partner/tickets')
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
@RequirePartnerPermissions('warehouse:manage', 'order:view')
export class PartnerTicketsController {
constructor(private readonly service: AdminTicketsService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listPartnerTickets(
user.actorId,
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
);
}
@Get(':id')
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.getPartnerTicket(user.actorId, BigInt(id));
}
@Post(':id/confirm-pickup')
@RequirePartnerPermissions('warehouse:manage')
confirmPickup(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.partnerConfirmPickup(user.actorId, BigInt(id));
}
@Post(':id/confirm-reship')
@RequirePartnerPermissions('warehouse:manage')
confirmReship(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.partnerConfirmReship(user.actorId, BigInt(id));
}
}
@@ -1,4 +1,5 @@
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';
@@ -7,6 +8,20 @@ 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(
@@ -25,55 +40,161 @@ export class AdminTicketsService {
return this.ticketService.detail(id);
}
async approve(id: bigint, remark?: string) {
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.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
const orderId = ticket.refId;
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: remark ?? '退款工单审批通过',
},
});
if (ticket.refType !== 'ORDER') {
throw new BadRequestException('仅支持订单售后工单');
}
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
await this.tradeService.applyStatusTransition(
ticket.refId,
'PENDING_SHIP',
'PENDING_SHIP',
'HQ',
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 ?? '审批通过',
);
}
return this.ticketService.updateStatus(id, {
status: 'RESOLVED',
remark: 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) {
@@ -83,21 +204,203 @@ export class AdminTicketsService {
});
}
async listPartnerReshipments(partnerAccountId: bigint) {
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(partnerAccountId);
const orders = await this.prisma.order.findMany({
where: orderWhere,
/** 完成协同:取回确认(退货类)或补发确认 */
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 orderIds = orders.map((o) => o.id);
const warehouseIds = new Set(warehouses.map((w) => w.id.toString()));
const tickets = await this.prisma.commonTicket.findMany({
where: {
ticketType: 'RESHIPMENT',
status: 'COLLABORATING',
ticketType: { in: [...COLLAB_TYPES] },
refType: 'ORDER',
refId: { in: orderIds },
},
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(tickets);
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);
}
}
@@ -34,8 +34,9 @@ import { AdminHqLogsController } from './admin-hq-logs.controller';
import { AdminHqLogsService } from './admin-hq-logs.service';
import { AdminOssLogsController } from './admin-oss-logs.controller';
import { AdminOssLogsService } from './admin-oss-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { AdminInvoicesController } from './admin-invoices.controller';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
@@ -84,6 +85,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminHqLogsController,
AdminOssLogsController,
AdminTicketsController,
AdminInvoicesController,
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,