fix;提交、
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAfterSaleTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||
ticketType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
evidenceUrls?: string[];
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsString()
|
||||
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
||||
titleType: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['NORMAL', 'SPECIAL'])
|
||||
invoiceKind: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
titleName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
taxNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
addressPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
bankAccount?: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class IssueInvoiceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fileUrl: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class RejectInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PartnerProxyOrderSendSmsDto,
|
||||
} from './dto/partner-proxy-order.dto';
|
||||
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
||||
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
|
||||
|
||||
@Controller('trade/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -75,6 +76,64 @@ export class TradeController {
|
||||
) {
|
||||
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
|
||||
}
|
||||
|
||||
@Post(':id/after-sale-tickets')
|
||||
createAfterSale(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: CreateAfterSaleTicketDto,
|
||||
) {
|
||||
return this.tradeService.createAfterSaleTicket(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post(':id/invoices')
|
||||
createInvoice(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: CreateInvoiceDto,
|
||||
) {
|
||||
return this.tradeService.createInvoice(user.actorId, BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('trade/after-sale-tickets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeAfterSaleTicketController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.tradeService.listAfterSaleTickets(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getAfterSaleTicket(user.actorId, BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('trade/invoices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeInvoiceController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.tradeService.listInvoices(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getInvoice(user.actorId, BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/orders')
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
PartnerOrderController,
|
||||
PartnerProxyOrderController,
|
||||
PartnerReshipmentController,
|
||||
TradeAfterSaleTicketController,
|
||||
TradeInvoiceController,
|
||||
} from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@@ -30,6 +32,8 @@ import { TradeService } from './trade.service';
|
||||
],
|
||||
controllers: [
|
||||
TradeController,
|
||||
TradeAfterSaleTicketController,
|
||||
TradeInvoiceController,
|
||||
PartnerOrderController,
|
||||
PartnerProxyOrderController,
|
||||
PartnerReshipmentController,
|
||||
|
||||
@@ -478,23 +478,293 @@ export class TradeService {
|
||||
}
|
||||
|
||||
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单不可申请退款');
|
||||
}
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
||||
});
|
||||
return this.ticketService.create({
|
||||
return this.createAfterSaleTicket(userId, orderId, {
|
||||
ticketType: 'REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId.toString(),
|
||||
remark: remark ?? '用户申请退款',
|
||||
});
|
||||
}
|
||||
|
||||
async createAfterSaleTicket(
|
||||
userId: bigint,
|
||||
orderId: bigint,
|
||||
body: { ticketType: string; remark?: string; evidenceUrls?: string[] },
|
||||
) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
|
||||
throw new BadRequestException('当前订单不可申请售后');
|
||||
}
|
||||
if (['REFUNDING', 'REFUNDED'].includes(order.status)) {
|
||||
throw new BadRequestException('订单已在退款流程中');
|
||||
}
|
||||
|
||||
const pending = await this.prisma.commonTicket.findFirst({
|
||||
where: {
|
||||
ticketType: body.ticketType as never,
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
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: orderId.toString(),
|
||||
remark: body.remark ?? '',
|
||||
extraJson: evidenceUrls.length ? { evidenceUrls } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async listAfterSaleTickets(userId: bigint, page = 1, pageSize = 20) {
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, orderNo: true },
|
||||
});
|
||||
const orderIds = orders.map((o) => o.id);
|
||||
const orderNoMap = new Map(orders.map((o) => [o.id.toString(), o.orderNo]));
|
||||
if (!orderIds.length) {
|
||||
return serializeBigInt({ items: [], total: 0, page, pageSize });
|
||||
}
|
||||
|
||||
const where = {
|
||||
refType: 'ORDER',
|
||||
refId: { in: orderIds },
|
||||
ticketType: { in: ['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'] as never[] },
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonTicket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonTicket.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((t) => ({
|
||||
...t,
|
||||
orderNo: orderNoMap.get(t.refId.toString()) ?? null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async getAfterSaleTicket(userId: bigint, ticketId: bigint) {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket || ticket.refType !== 'ORDER') throw new NotFoundException('工单不存在');
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: ticket.refId, userId },
|
||||
select: { id: true, orderNo: true, status: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('工单不存在');
|
||||
return serializeBigInt({ ...ticket, orderNo: order.orderNo, orderStatus: order.status });
|
||||
}
|
||||
|
||||
private generateInvoiceNo() {
|
||||
return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
async createInvoice(
|
||||
userId: bigint,
|
||||
orderId: bigint,
|
||||
body: {
|
||||
titleType: string;
|
||||
invoiceKind: string;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
remark?: string;
|
||||
},
|
||||
) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (order.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('仅已完成订单可申请发票');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.userInvoice.findFirst({
|
||||
where: { orderId, status: { in: ['PENDING', 'ISSUED'] } },
|
||||
});
|
||||
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
|
||||
|
||||
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
||||
throw new BadRequestException('企业抬头须填写税号');
|
||||
}
|
||||
if (body.invoiceKind === 'SPECIAL') {
|
||||
if (body.titleType !== 'ENTERPRISE') {
|
||||
throw new BadRequestException('专用发票仅支持企业抬头');
|
||||
}
|
||||
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
|
||||
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||
}
|
||||
}
|
||||
|
||||
const invoice = await this.prisma.userInvoice.create({
|
||||
data: {
|
||||
invoiceNo: this.generateInvoiceNo(),
|
||||
orderId,
|
||||
userId,
|
||||
titleType: body.titleType as never,
|
||||
invoiceKind: body.invoiceKind as never,
|
||||
titleName: body.titleName.trim(),
|
||||
taxNo: body.taxNo?.trim() || null,
|
||||
addressPhone: body.addressPhone?.trim() || null,
|
||||
bankAccount: body.bankAccount?.trim() || null,
|
||||
email: body.email.trim(),
|
||||
phone: body.phone.trim(),
|
||||
remark: body.remark?.trim() || null,
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
|
||||
}
|
||||
|
||||
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
|
||||
const where = { userId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.userInvoice.findMany({
|
||||
where,
|
||||
include: { order: { select: { orderNo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.userInvoice.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((inv) => ({
|
||||
...inv,
|
||||
orderNo: inv.order.orderNo,
|
||||
order: undefined,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async getInvoice(userId: bigint, invoiceId: bigint) {
|
||||
const invoice = await this.prisma.userInvoice.findFirst({
|
||||
where: { id: invoiceId, userId },
|
||||
include: { order: { select: { orderNo: true, payAmount: true } } },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('发票不存在');
|
||||
return serializeBigInt({
|
||||
...invoice,
|
||||
orderNo: invoice.order.orderNo,
|
||||
payAmount: invoice.order.payAmount,
|
||||
order: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 工作日差(粗略:排除周六日) */
|
||||
private businessDaysSince(from: Date, to = new Date()): number {
|
||||
let days = 0;
|
||||
const cur = new Date(from);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const end = new Date(to);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
while (cur < end) {
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
const w = cur.getDay();
|
||||
if (w !== 0 && w !== 6) days += 1;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: { status?: never } = {};
|
||||
if (query.status) where.status = query.status as never;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.userInvoice.findMany({
|
||||
where,
|
||||
include: { order: { select: { orderNo: true, payAmount: true } }, user: { select: { phone: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.userInvoice.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((inv) => ({
|
||||
...inv,
|
||||
orderNo: inv.order.orderNo,
|
||||
payAmount: inv.order.payAmount,
|
||||
userPhone: inv.user.phone,
|
||||
overdue: inv.status === 'PENDING' && this.businessDaysSince(inv.createdAt) > 2,
|
||||
order: undefined,
|
||||
user: undefined,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async adminGetInvoice(id: bigint) {
|
||||
const invoice = await this.prisma.userInvoice.findUnique({
|
||||
where: { id },
|
||||
include: { order: { select: { orderNo: true, payAmount: true, productName: true } }, user: { select: { phone: true, nickname: true } } },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('发票不存在');
|
||||
return serializeBigInt({
|
||||
...invoice,
|
||||
orderNo: invoice.order.orderNo,
|
||||
payAmount: invoice.order.payAmount,
|
||||
productName: invoice.order.productName,
|
||||
userPhone: invoice.user.phone,
|
||||
overdue: invoice.status === 'PENDING' && this.businessDaysSince(invoice.createdAt) > 2,
|
||||
});
|
||||
}
|
||||
|
||||
async adminIssueInvoice(
|
||||
id: bigint,
|
||||
operatorId: bigint,
|
||||
body: { fileUrl: string; resourceId?: string; remark?: string },
|
||||
) {
|
||||
const invoice = await this.prisma.userInvoice.findUnique({ where: { id } });
|
||||
if (!invoice) throw new NotFoundException('发票不存在');
|
||||
if (invoice.status !== 'PENDING') throw new BadRequestException('当前状态不可开票');
|
||||
const updated = await this.prisma.userInvoice.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ISSUED',
|
||||
fileUrl: body.fileUrl,
|
||||
resourceId: body.resourceId ? BigInt(body.resourceId) : null,
|
||||
issuedAt: new Date(),
|
||||
operatorId,
|
||||
remark: body.remark ?? invoice.remark,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async adminRejectInvoice(id: bigint, operatorId: bigint, remark?: string) {
|
||||
const invoice = await this.prisma.userInvoice.findUnique({ where: { id } });
|
||||
if (!invoice) throw new NotFoundException('发票不存在');
|
||||
if (invoice.status !== 'PENDING') throw new BadRequestException('当前状态不可驳回');
|
||||
const updated = await this.prisma.userInvoice.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
operatorId,
|
||||
remark: remark ?? '驳回',
|
||||
issuedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
|
||||
Reference in New Issue
Block a user