b626db5d84
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
import { orderStatusLogWhere } from '../../common/event/event.helpers';
|
|
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
|
import { TradeService } from '../trade/trade.service';
|
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
|
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
|
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
|
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
|
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
|
import { AdminRedeemService } from './admin-redeem.service';
|
|
|
|
@Injectable()
|
|
export class AdminOrdersService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly tradeService: TradeService,
|
|
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
|
private readonly fulfillmentService: FulfillmentService,
|
|
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
|
private readonly adminRedeemService: AdminRedeemService,
|
|
) {}
|
|
|
|
async list(query: AdminOrdersQueryDto) {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 20;
|
|
const where: Prisma.OrderWhereInput = {};
|
|
|
|
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
|
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
|
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
|
if (query.userId) where.userId = BigInt(query.userId);
|
|
if (query.cityId) where.cityId = BigInt(query.cityId);
|
|
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
|
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
|
|
where.fulfillmentHold = true;
|
|
}
|
|
if (query.createdFrom || query.createdTo) {
|
|
where.createdAt = {};
|
|
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
|
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
|
|
}
|
|
if (query.excludeTest) where.isTest = false;
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.order.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: {
|
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
|
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
|
city: { select: { id: true, name: true, code: true } },
|
|
fulfillmentWarehouse: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
this.prisma.order.count({ where }),
|
|
]);
|
|
|
|
return serializeBigInt({ items, total, page, pageSize });
|
|
}
|
|
|
|
async getOrderTrack(id: bigint) {
|
|
const order = await this.prisma.order.findUnique({
|
|
where: { id },
|
|
select: { id: true },
|
|
});
|
|
if (!order) throw new NotFoundException('订单不存在');
|
|
return serializeBigInt(await this.fulfillmentService.getOrderTrack(id));
|
|
}
|
|
|
|
async detail(id: bigint) {
|
|
const order = await this.prisma.order.findUnique({
|
|
where: { id },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
userNo: true,
|
|
phone: true,
|
|
nickname: true,
|
|
deviceKey: true,
|
|
phoneVerifiedAt: true,
|
|
},
|
|
},
|
|
delivery: true,
|
|
benefitCoupon: {
|
|
select: {
|
|
id: true,
|
|
couponNo: true,
|
|
totalAmount: true,
|
|
usedAmount: true,
|
|
balance: true,
|
|
status: true,
|
|
},
|
|
},
|
|
city: { select: { id: true, name: true, code: true } },
|
|
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
|
|
imageResource: { select: { url: true } },
|
|
fulfillmentWarehouse: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
contactName: true,
|
|
contactPhone: true,
|
|
address: true,
|
|
lng: true,
|
|
lat: true,
|
|
fulfillmentMode: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!order) throw new NotFoundException('订单不存在');
|
|
const statusLogs = await this.prisma.commonEvent.findMany({
|
|
where: orderStatusLogWhere(id),
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
|
|
const coupon = order.benefitCoupon;
|
|
const redeemTrace = coupon
|
|
? await this.adminRedeemService.buildCouponRedeemTrace(coupon)
|
|
: { redeemSummary: null, redeemRecords: [] };
|
|
|
|
const { benefitCoupon: _coupon, ...orderRest } = order;
|
|
|
|
return serializeBigInt(
|
|
mapOrderCompat({
|
|
...orderRest,
|
|
statusLogs: mapStatusLogCompat(statusLogs),
|
|
benefitCoupons: coupon
|
|
? [
|
|
{
|
|
id: coupon.id,
|
|
couponNo: coupon.couponNo,
|
|
totalAmount: Number(coupon.totalAmount),
|
|
usedAmount: Number(coupon.usedAmount),
|
|
balance: Number(coupon.balance),
|
|
status: coupon.status,
|
|
},
|
|
]
|
|
: [],
|
|
redeemSummary: redeemTrace.redeemSummary,
|
|
redeemRecords: redeemTrace.redeemRecords,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async updateStatusDebug(id: bigint, status: string) {
|
|
const order = await this.prisma.order.findUnique({ where: { id } });
|
|
if (!order) throw new NotFoundException('订单不存在');
|
|
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
|
|
return this.detail(id);
|
|
}
|
|
|
|
async shipOrder(id: bigint, dto: AdminShipOrderDto) {
|
|
if (dto.provider !== 'XFX') {
|
|
throw new BadRequestException('暂仅支持小飞侠配送');
|
|
}
|
|
|
|
let order = await this.prisma.order.findUnique({
|
|
where: { id },
|
|
include: {
|
|
delivery: true,
|
|
fulfillmentWarehouse: true,
|
|
},
|
|
});
|
|
if (!order) throw new NotFoundException('订单不存在');
|
|
|
|
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
|
throw new BadRequestException('当前订单状态不可发货');
|
|
}
|
|
if (order.delivery?.trackingNo) {
|
|
throw new BadRequestException('该订单已有运单号,请勿重复发货');
|
|
}
|
|
|
|
if (dto.warehouseId) {
|
|
const warehouseId = BigInt(dto.warehouseId);
|
|
const warehouseRow = await this.prisma.cityWarehouse.findFirst({
|
|
where: { id: warehouseId, status: 'ACTIVE' },
|
|
});
|
|
if (!warehouseRow) {
|
|
throw new BadRequestException('仓库不存在或已停用');
|
|
}
|
|
if (order.fulfillmentWarehouseId !== warehouseId) {
|
|
await this.prisma.order.update({
|
|
where: { id },
|
|
data: { fulfillmentWarehouseId: warehouseId },
|
|
});
|
|
order = await this.prisma.order.findUniqueOrThrow({
|
|
where: { id },
|
|
include: { delivery: true, fulfillmentWarehouse: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
const warehouse = order.fulfillmentWarehouse;
|
|
if (!warehouse) {
|
|
throw new BadRequestException('请先选择履约仓库');
|
|
}
|
|
const providerId =
|
|
order.delivery?.fulfillmentProviderId ??
|
|
warehouse?.fulfillmentProviderId ??
|
|
null;
|
|
|
|
let xfxConfig;
|
|
if (providerId) {
|
|
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId);
|
|
} else {
|
|
xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
|
if (!xfxConfig) {
|
|
throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商');
|
|
}
|
|
}
|
|
|
|
const defaults = this.getShipDefaults(warehouse);
|
|
const shipmentDto: XiaofeixiaCreateShipmentDto = {
|
|
outNumber: order.orderNo,
|
|
fromName: dto.fromName || defaults.fromName,
|
|
fromMobile: dto.fromMobile || defaults.fromMobile,
|
|
fromAddress: dto.fromAddress || defaults.fromAddress,
|
|
fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail,
|
|
fromLng: dto.fromLng ?? defaults.fromLng,
|
|
fromLat: dto.fromLat ?? defaults.fromLat,
|
|
toName: order.receiverName,
|
|
toMobile: order.receiverPhone,
|
|
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
|
toAddressDetail: order.receiverAddress,
|
|
goodsName: order.productName,
|
|
goodsNum: order.quantity,
|
|
weight: dto.weight ?? defaults.weight,
|
|
payMode: dto.payMode || defaults.payMode,
|
|
remark: dto.remark || `HQ发货 ${order.orderNo}`,
|
|
};
|
|
|
|
const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig);
|
|
if (!result.ok || !result.data) {
|
|
throw new BadRequestException(result.error || '小飞侠创建运单失败');
|
|
}
|
|
|
|
const { providerShipmentId, trackingNumber } = result.data;
|
|
const now = new Date();
|
|
const resolvedProviderId = providerId;
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
if (order.delivery) {
|
|
await tx.orderDelivery.update({
|
|
where: { orderId: id },
|
|
data: {
|
|
provider: 'XFX',
|
|
fulfillmentProviderId: resolvedProviderId,
|
|
trackingNo: trackingNumber,
|
|
providerOrderNo: String(providerShipmentId),
|
|
shippingAt: now,
|
|
},
|
|
});
|
|
} else {
|
|
await tx.orderDelivery.create({
|
|
data: {
|
|
orderId: id,
|
|
provider: 'XFX',
|
|
fulfillmentProviderId: resolvedProviderId,
|
|
trackingNo: trackingNumber,
|
|
providerOrderNo: String(providerShipmentId),
|
|
shippingAt: now,
|
|
},
|
|
});
|
|
}
|
|
await tx.order.update({
|
|
where: { id },
|
|
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
|
});
|
|
});
|
|
|
|
await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
|
|
return this.detail(id);
|
|
}
|
|
|
|
getShipDefaults(warehouse?: {
|
|
contactName: string;
|
|
contactPhone: string;
|
|
address: string;
|
|
name: string;
|
|
lng: { toNumber?: () => number } | number | null;
|
|
lat: { toNumber?: () => number } | number | null;
|
|
} | null) {
|
|
const lng =
|
|
warehouse?.lng != null
|
|
? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng
|
|
? Number(warehouse.lng)
|
|
: Number(warehouse.lng)
|
|
: 113.665;
|
|
const lat =
|
|
warehouse?.lat != null
|
|
? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat
|
|
? Number(warehouse.lat)
|
|
: Number(warehouse.lat)
|
|
: 34.757;
|
|
return {
|
|
provider: 'XFX',
|
|
providerLabel: '小飞侠',
|
|
fromName: warehouse?.contactName || '杜康仓库',
|
|
fromMobile: warehouse?.contactPhone || '13800000000',
|
|
fromAddress: warehouse?.address || '河南省郑州市金水区',
|
|
fromAddressDetail: warehouse?.name || '杜康酒业仓',
|
|
fromLng: lng,
|
|
fromLat: lat,
|
|
weight: 2,
|
|
payMode: '1',
|
|
};
|
|
}
|
|
|
|
/** 总部传统快递填单(同城无仓 / 跨城) */
|
|
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
|
|
await this.fulfillmentService.shipHqLogistics(id, dto);
|
|
return this.detail(id);
|
|
}
|
|
|
|
async batchDeleteOrders(ids: bigint[]) {
|
|
const uniqueIds = [...new Set(ids)];
|
|
if (!uniqueIds.length) {
|
|
return { ok: true, deleted: 0, message: '未选择订单' };
|
|
}
|
|
|
|
const orders = await this.prisma.order.findMany({
|
|
where: { id: { in: uniqueIds } },
|
|
select: { id: true, orderNo: true },
|
|
});
|
|
if (!orders.length) throw new NotFoundException('订单不存在');
|
|
|
|
const orderIds = orders.map((o) => o.id);
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await this.deleteOrdersInTx(tx, orderIds);
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
deleted: orderIds.length,
|
|
orderNos: orders.map((o) => o.orderNo),
|
|
message: '订单及关联业务数据已删除,状态流转等业务日志已保留',
|
|
};
|
|
}
|
|
|
|
async deleteOrder(id: bigint) {
|
|
const order = await this.prisma.order.findUnique({
|
|
where: { id },
|
|
select: { id: true, orderNo: true },
|
|
});
|
|
if (!order) throw new NotFoundException('订单不存在');
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await this.deleteOrdersInTx(tx, [id]);
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
deleted: 1,
|
|
orderNo: order.orderNo,
|
|
message: `订单 ${order.orderNo} 及关联业务数据已删除`,
|
|
};
|
|
}
|
|
|
|
private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) {
|
|
if (!orderIds.length) return;
|
|
|
|
const couponIds = (
|
|
await tx.benefitCoupon.findMany({
|
|
where: { orderId: { in: orderIds } },
|
|
select: { id: true },
|
|
})
|
|
).map((c) => c.id);
|
|
|
|
if (couponIds.length) {
|
|
const redeemIds = (
|
|
await tx.redeemRecord.findMany({
|
|
where: {
|
|
OR: [
|
|
{ couponId: { in: couponIds } },
|
|
{ allocations: { some: { couponId: { in: couponIds } } } },
|
|
],
|
|
},
|
|
select: { id: true },
|
|
})
|
|
).map((r) => r.id);
|
|
|
|
if (redeemIds.length) {
|
|
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
|
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
|
await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
|
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
|
}
|
|
|
|
await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
|
|
}
|
|
|
|
await tx.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
|
|
await tx.wineryBillItem.deleteMany({ where: { orderId: { in: orderIds } } });
|
|
await tx.logisticsBillItem.deleteMany({ where: { orderId: { in: orderIds } } });
|
|
|
|
await tx.order.updateMany({
|
|
where: { originOrderId: { in: orderIds } },
|
|
data: { originOrderId: null },
|
|
});
|
|
await tx.commonTicket.deleteMany({
|
|
where: { refType: 'ORDER', refId: { in: orderIds } },
|
|
});
|
|
await tx.order.deleteMany({ where: { id: { in: orderIds } } });
|
|
}
|
|
}
|