Files
dukang/server/dukang-api/src/modules/ops/admin-orders.service.ts
T
jacy cc0c0a6ef8 feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:12:36 +08:00

625 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { maskContactPhone } from '@dukang/domain';
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 { AdminOrdersExportDto, 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 { buildXfxGoodsPayload } from '../fulfillment/xfx-goods.util';
import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util';
import { AdminRedeemService } from './admin-redeem.service';
import {
buildExportFilename,
buildOrdersPdf,
buildOrdersXlsx,
mapOrderToExportRow,
} from './admin-order-export.util';
const ORDER_EXPORT_MAX = 5000;
type OrderFilterInput = Pick<
AdminOrdersQueryDto,
| 'orderNo'
| 'status'
| 'orderType'
| 'userId'
| 'cityId'
| 'receiverPhone'
| 'fulfillmentHold'
| 'createdFrom'
| 'createdTo'
| 'excludeTest'
| 'deliveryType'
>;
@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,
) {}
/** v3.5.1 #1:发布会大屏,仅当日已付款订单;按付款时间倒序 */
async listBigScreen(limit?: string) {
const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000);
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const orders = await this.prisma.order.findMany({
where: {
payStatus: 'PAID',
paidAt: { gte: startOfToday },
},
orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }],
take,
include: { user: { select: { phone: true } } },
});
return {
items: orders.map((o) => {
const spec = o.productSpec ? ` ${o.productSpec}` : '';
const phone = o.user?.phone || o.receiverPhone;
const paidAt = o.paidAt?.toISOString() ?? o.createdAt.toISOString();
return {
id: o.id.toString(),
orderNo: o.orderNo,
payAmount: Number(o.payAmount),
items: `${o.productName}${spec} × ${o.quantity}瓶`,
createdAt: paidAt,
paidAt,
userPhoneMasked: phone ? maskContactPhone(phone) : null,
};
}),
};
}
async list(query: AdminOrdersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildOrderWhere(query);
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,
logisticsCompany: true,
manualQueryUrl: true,
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
},
},
city: { select: { id: true, name: true, code: true } },
fulfillmentWarehouse: { select: { id: true, name: true } },
benefitCoupon: {
select: { couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true },
},
},
}),
this.prisma.order.count({ where }),
]);
return serializeBigInt({
items: items.map((row) => this.withDeliveryLogisticsFee(row)),
total,
page,
pageSize,
});
}
async previewExport(dto: AdminOrdersExportDto) {
const count = await this.countExportOrders(dto);
return { count, max: ORDER_EXPORT_MAX, exceeds: count > ORDER_EXPORT_MAX };
}
async exportOrders(dto: AdminOrdersExportDto) {
const count = await this.countExportOrders(dto);
if (count > ORDER_EXPORT_MAX) {
throw new BadRequestException(`超过 ${ORDER_EXPORT_MAX} 条,请缩小日期或筛选条件`);
}
if (count === 0) {
throw new BadRequestException('没有可导出的订单');
}
const orders = await this.prisma.order.findMany({
where: this.buildExportWhere(dto),
orderBy: { createdAt: 'desc' },
take: ORDER_EXPORT_MAX,
include: {
city: { select: { name: true } },
delivery: { select: { trackingNo: true } },
benefitCoupon: {
select: { totalAmount: true, usedAmount: true, balance: true },
},
},
});
const rows = orders.map((order) => mapOrderToExportRow(order));
const buffer =
dto.format === 'pdf' ? await buildOrdersPdf(rows) : await buildOrdersXlsx(rows);
const filename = buildExportFilename(dto.format, rows.length);
const mimeType =
dto.format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
return {
filename,
mimeType,
contentBase64: buffer.toString('base64'),
count: rows.length,
};
}
private buildOrderWhere(query: OrderFilterInput): Prisma.OrderWhereInput {
const where: Prisma.OrderWhereInput = {};
if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status?.length) {
where.status = { in: query.status as Prisma.EnumOrderStatusFilter['in'] };
}
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.deliveryType) {
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
}
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 = this.endOfDay(query.createdTo);
}
if (query.excludeTest) where.isTest = false;
return where;
}
private buildExportWhere(dto: AdminOrdersExportDto): Prisma.OrderWhereInput {
if (dto.scope === 'selected') {
const ids = (dto.ids ?? []).map((id) => BigInt(id));
if (!ids.length) {
throw new BadRequestException('请先勾选要导出的订单');
}
return { id: { in: ids } };
}
const normalized = this.normalizeExportFilter(dto);
return this.buildOrderWhere(normalized);
}
private async countExportOrders(dto: AdminOrdersExportDto): Promise<number> {
return this.prisma.order.count({ where: this.buildExportWhere(dto) });
}
private normalizeExportFilter(dto: AdminOrdersExportDto): OrderFilterInput {
const createdFrom = dto.createdFrom;
const createdTo = dto.createdTo;
if (!createdFrom && !createdTo) {
const from = new Date();
from.setDate(from.getDate() - 30);
from.setHours(0, 0, 0, 0);
return {
...dto,
createdFrom: from.toISOString().slice(0, 10),
createdTo: new Date().toISOString().slice(0, 10),
};
}
return { ...dto, createdFrom, createdTo };
}
private endOfDay(dateStr: string): Date {
const end = new Date(dateStr);
end.setHours(23, 59, 59, 999);
return end;
}
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: {
include: {
fulfillmentProvider: { select: { code: true, pricingRulesJson: 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 withFee = this.withDeliveryLogisticsFee(order);
const { benefitCoupon: _coupon, ...orderRest } = withFee;
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,
product: { select: { spec: 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, product: { select: { spec: 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 { goodsName, goodsNum } = buildXfxGoodsPayload({
productName: order.productName,
productSpec: order.productSpec,
physicalSpec: order.product?.spec,
quantity: order.quantity,
bottlesPerUnit: order.bottlesPerUnit,
});
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,
goodsNum,
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);
}
private withDeliveryLogisticsFee<
T extends {
quantity: number;
bottlesPerUnit: number;
deliveryType: string;
delivery?: {
provider: string;
fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null;
} | null;
},
>(order: T): T {
if (!order.delivery) return order;
const fp = order.delivery.fulfillmentProvider;
const logisticsFee = calcDeliveryFreightAmount({
quantity: order.quantity,
bottlesPerUnit: order.bottlesPerUnit,
deliveryType: order.deliveryType,
provider: order.delivery.provider,
providerCode: fp?.code,
pricing: this.fulfillmentProviderService.parsePricingRules(fp?.pricingRulesJson ?? null),
});
const { fulfillmentProvider: _fp, ...deliveryRest } = order.delivery;
return {
...order,
delivery: { ...deliveryRest, logisticsFee },
};
}
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 } } });
}
}