@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
@@ -29,7 +30,7 @@ import { IpGeoService } from '../../common/geo/ip-geo.service';
|
||||
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { mapOrderCompat, mapStatusLogCompat, mapUserOrderWithInvoice } from '../../common/compat/v31-compat';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
@@ -57,6 +58,9 @@ export class TradeService {
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
private readonly logger = new Logger(TradeService.name);
|
||||
|
||||
|
||||
async preview(
|
||||
userId: bigint,
|
||||
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
|
||||
@@ -753,14 +757,23 @@ export class TradeService {
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { benefitCoupon: true, imageResource: true },
|
||||
include: {
|
||||
benefitCoupon: true,
|
||||
imageResource: true,
|
||||
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
|
||||
return {
|
||||
list: serializeBigInt(list.map(mapUserOrderWithInvoice)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOrder(userId: bigint, orderId: bigint) {
|
||||
@@ -772,6 +785,7 @@ export class TradeService {
|
||||
imageResource: true,
|
||||
product: true,
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -779,7 +793,10 @@ export class TradeService {
|
||||
where: orderStatusLogWhere(orderId),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
|
||||
const mapped = mapUserOrderWithInvoice({
|
||||
...order,
|
||||
statusLogs: mapStatusLogCompat(statusLogs),
|
||||
});
|
||||
return serializeBigInt({
|
||||
...mapped,
|
||||
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
|
||||
@@ -987,14 +1004,15 @@ export class TradeService {
|
||||
userId: bigint,
|
||||
orderId: bigint,
|
||||
body: {
|
||||
titleType: string;
|
||||
invoiceKind: string;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
titleId?: string;
|
||||
titleType?: string;
|
||||
invoiceKind?: string;
|
||||
titleName?: string;
|
||||
taxNo?: string | null;
|
||||
addressPhone?: string | null;
|
||||
bankAccount?: string | null;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
remark?: string;
|
||||
},
|
||||
) {
|
||||
@@ -1009,14 +1027,49 @@ export class TradeService {
|
||||
});
|
||||
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
|
||||
|
||||
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
||||
let resolved = {
|
||||
titleType: body.titleType || '',
|
||||
titleName: body.titleName || '',
|
||||
taxNo: body.taxNo ?? null,
|
||||
addressPhone: body.addressPhone ?? null,
|
||||
bankAccount: body.bankAccount ?? null,
|
||||
email: body.email || '',
|
||||
phone: body.phone || '',
|
||||
};
|
||||
if (body.titleId) {
|
||||
const title = await this.prisma.userInvoiceTitle.findFirst({
|
||||
where: { id: BigInt(body.titleId), userId },
|
||||
});
|
||||
if (!title) throw new BadRequestException('发票抬头不存在');
|
||||
resolved = {
|
||||
titleType: title.titleType,
|
||||
titleName: title.titleName,
|
||||
taxNo: title.taxNo,
|
||||
addressPhone: title.addressPhone,
|
||||
bankAccount: title.bankAccount,
|
||||
email: title.email || body.email || '',
|
||||
phone: title.phone || body.phone || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.titleName.trim()) {
|
||||
throw new BadRequestException('请填写抬头名称');
|
||||
}
|
||||
if (!resolved.email.trim()) {
|
||||
throw new BadRequestException('请填写接收邮箱');
|
||||
}
|
||||
if (!resolved.phone.trim()) {
|
||||
throw new BadRequestException('请填写联系电话');
|
||||
}
|
||||
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
|
||||
throw new BadRequestException('企业抬头须填写税号');
|
||||
}
|
||||
if (body.invoiceKind === 'SPECIAL') {
|
||||
if (body.titleType !== 'ENTERPRISE') {
|
||||
const invoiceKind = body.invoiceKind || 'NORMAL';
|
||||
if (invoiceKind === 'SPECIAL') {
|
||||
if (resolved.titleType !== 'ENTERPRISE') {
|
||||
throw new BadRequestException('专用发票仅支持企业抬头');
|
||||
}
|
||||
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
|
||||
if (!resolved.taxNo?.trim() || !resolved.addressPhone?.trim() || !resolved.bankAccount?.trim()) {
|
||||
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||
}
|
||||
}
|
||||
@@ -1026,20 +1079,29 @@ export class TradeService {
|
||||
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(),
|
||||
titleType: resolved.titleType as never,
|
||||
invoiceKind: invoiceKind as never,
|
||||
titleName: resolved.titleName.trim(),
|
||||
taxNo: resolved.taxNo?.trim() || null,
|
||||
addressPhone: resolved.addressPhone?.trim() || null,
|
||||
bankAccount: resolved.bankAccount?.trim() || null,
|
||||
email: resolved.email.trim(),
|
||||
phone: resolved.phone.trim(),
|
||||
remark: body.remark?.trim() || null,
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
|
||||
}
|
||||
|
||||
/** v3.5.1 #3:查询某订单是否已申请发票 */
|
||||
async getOrderInvoiceStatus(userId: bigint, orderId: bigint) {
|
||||
const inv = await this.prisma.userInvoice.findFirst({
|
||||
where: { orderId, userId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
return { exists: !!inv, status: inv?.status ?? null };
|
||||
}
|
||||
|
||||
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
|
||||
const where = { userId };
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -1096,14 +1158,15 @@ export class TradeService {
|
||||
async adminCreateInvoice(
|
||||
body: {
|
||||
orderNo: string;
|
||||
titleType: string;
|
||||
invoiceKind: string;
|
||||
titleName: string;
|
||||
titleId?: string;
|
||||
titleType?: string;
|
||||
invoiceKind?: string;
|
||||
titleName?: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
remark?: string;
|
||||
},
|
||||
) {
|
||||
@@ -1819,6 +1882,59 @@ export class TradeService {
|
||||
return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.5.1 #8:订单 30 分钟未支付自动取消。
|
||||
* 下单时已写入 payExpireAt = now+30min;此处将已过期且仍为待支付的订单翻为 CANCELLED。
|
||||
* 建单/取消均无库存占用或权益发放,故仅翻状态 + 写状态日志,不回滚权益券。
|
||||
*/
|
||||
async cancelExpiredPendingOrders(limit = 200): Promise<number> {
|
||||
const now = new Date();
|
||||
const expired = await this.prisma.order.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAY',
|
||||
payStatus: 'UNPAID',
|
||||
payExpireAt: { lt: now },
|
||||
isTest: false,
|
||||
},
|
||||
take: limit,
|
||||
select: { id: true },
|
||||
orderBy: { payExpireAt: 'asc' },
|
||||
});
|
||||
if (expired.length === 0) return 0;
|
||||
|
||||
let cancelled = 0;
|
||||
for (const o of expired) {
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.order.updateMany({
|
||||
where: { id: o.id, status: 'PENDING_PAY' },
|
||||
data: { status: 'CANCELLED', cancelledAt: now },
|
||||
});
|
||||
if (updated.count === 0) return; // 已被并发处理
|
||||
await tx.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: o.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'CANCELLED',
|
||||
operator: 'SYSTEM_AUTO_EXPIRE',
|
||||
remark: '30 分钟未支付自动取消',
|
||||
}),
|
||||
});
|
||||
});
|
||||
cancelled++;
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`cancelExpiredPendingOrders failed for order ${o.id}`,
|
||||
e instanceof Error ? e.stack : e,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cancelled > 0) {
|
||||
this.logger.log(`Auto-cancelled ${cancelled} expired pending orders`);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
||||
async getHqProxyOrderOptions() {
|
||||
const [products, promoCodes] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user