feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_XFX_LOGISTICS_PRICING } from '@dukang/shared-types';
|
||||
import { calcDeliveryFreightAmount } from './delivery-freight.util';
|
||||
|
||||
describe('calcDeliveryFreightAmount', () => {
|
||||
it('瓶装按小飞侠默认计价:2瓶6元', () => {
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 1,
|
||||
deliveryType: 'LOCAL',
|
||||
provider: 'XFX',
|
||||
}),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('箱装先换算瓶数:1箱6瓶=14元', () => {
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 1,
|
||||
bottlesPerUnit: 6,
|
||||
deliveryType: 'LOCAL',
|
||||
provider: 'XFX',
|
||||
}),
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it('现场提货不计运费', () => {
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 1,
|
||||
deliveryType: 'ON_SITE_PICKUP',
|
||||
provider: 'XFX',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('无承运商计价时返回 null;有自定义规则则用之', () => {
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 1,
|
||||
deliveryType: 'CROSS_CITY',
|
||||
provider: 'LOGISTICS',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 1,
|
||||
deliveryType: 'CROSS_CITY',
|
||||
provider: 'LOGISTICS',
|
||||
pricing: { ...DEFAULT_XFX_LOGISTICS_PRICING },
|
||||
}),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('同城即使 provider=MANUAL 也按小飞侠默认计价', () => {
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 1,
|
||||
deliveryType: 'LOCAL',
|
||||
provider: 'MANUAL',
|
||||
}),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('城市小飞侠编码 ZZXFX 使用默认计价', () => {
|
||||
expect(
|
||||
calcDeliveryFreightAmount({
|
||||
quantity: 4,
|
||||
bottlesPerUnit: 1,
|
||||
deliveryType: 'LOCAL',
|
||||
provider: 'MANUAL',
|
||||
providerCode: 'ZZXFX',
|
||||
}),
|
||||
).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { calcLogisticsFeeByBottles, toBottleQuantity, type LogisticsPricingRule } from '@dukang/domain';
|
||||
import { DEFAULT_XFX_LOGISTICS_PRICING, isXfxProviderCode } from '@dukang/shared-types';
|
||||
|
||||
export type DeliveryFreightInput = {
|
||||
quantity: number;
|
||||
bottlesPerUnit?: number | null;
|
||||
deliveryType?: string | null;
|
||||
provider?: string | null;
|
||||
providerCode?: string | null;
|
||||
pricing?: LogisticsPricingRule | null;
|
||||
};
|
||||
|
||||
/** 当次应付物流费:按瓶当量 + 承运商计价;现场提货 / 无规则返回 null */
|
||||
export function calcDeliveryFreightAmount(input: DeliveryFreightInput): number | null {
|
||||
if (input.deliveryType === 'ON_SITE_PICKUP') return null;
|
||||
const bottles = toBottleQuantity(
|
||||
input.quantity,
|
||||
input.bottlesPerUnit && input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1,
|
||||
);
|
||||
if (bottles <= 0) return null;
|
||||
const code = (input.providerCode || input.provider || '').trim();
|
||||
const useDefaultXfx =
|
||||
isXfxProviderCode(code) || (input.deliveryType === 'LOCAL' && code.toUpperCase() !== 'LOGISTICS');
|
||||
const rule = input.pricing ?? (useDefaultXfx ? { ...DEFAULT_XFX_LOGISTICS_PRICING } : null);
|
||||
if (!rule) return null;
|
||||
try {
|
||||
return calcLogisticsFeeByBottles(bottles, rule);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -90,22 +90,21 @@ export class FulfillmentProviderService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
|
||||
/** 取第一个启用且凭证完整的小飞侠承运商配置(含 ZZXFX 等城市编码) */
|
||||
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
|
||||
const row = await this.prisma.fulfillmentProvider.findFirst({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
type: 'API',
|
||||
code: { in: ['XFX', 'XIAOFEIXIA'] },
|
||||
},
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
where: { status: 'ACTIVE', type: 'API' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (!row?.configJson) return null;
|
||||
try {
|
||||
return await this.resolveXiaofeixiaConfig(row.id);
|
||||
} catch {
|
||||
return null;
|
||||
for (const row of rows) {
|
||||
if (!isXfxProviderCode(row.code) || !row.configJson) continue;
|
||||
try {
|
||||
return await this.resolveXiaofeixiaConfig(row.id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async create(input: CreateFulfillmentProviderInput) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
XFX_AUTO_DISPATCH_MAX_BOXES,
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
toBottleQuantity,
|
||||
} from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { CourierService } from '../../integrations/courier/courier.service';
|
||||
@@ -15,6 +16,9 @@ import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||
import { buildXfxGoodsPayload } from './xfx-goods.util';
|
||||
|
||||
type OrderForXfxDispatch = Order & { product?: { spec: string } | null };
|
||||
|
||||
export type ManualShipInput = {
|
||||
logisticsCompany: string;
|
||||
@@ -42,7 +46,7 @@ export class FulfillmentService {
|
||||
async dispatchAfterPay(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
include: { delivery: true, product: { select: { spec: true } } },
|
||||
});
|
||||
if (!order || order.payStatus !== 'PAID') return;
|
||||
|
||||
@@ -77,7 +81,10 @@ export class FulfillmentService {
|
||||
}
|
||||
|
||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
||||
const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
|
||||
const bottleQty = toBottleQuantity(
|
||||
order.quantity,
|
||||
order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1,
|
||||
);
|
||||
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
||||
const boxes = calcOrderBoxCount(bottleQty);
|
||||
this.logger.warn(
|
||||
@@ -118,7 +125,7 @@ export class FulfillmentService {
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
async dispatchApiAuto(order: OrderForXfxDispatch, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
if (!isXfxProviderCode(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
@@ -137,6 +144,13 @@ export class FulfillmentService {
|
||||
|
||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
|
||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
|
||||
const { goodsName, goodsNum } = buildXfxGoodsPayload({
|
||||
productName: order.productName,
|
||||
productSpec: order.productSpec,
|
||||
physicalSpec: order.product?.spec,
|
||||
quantity: order.quantity,
|
||||
bottlesPerUnit: order.bottlesPerUnit,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.courier.createShipment(
|
||||
@@ -155,8 +169,8 @@ export class FulfillmentService {
|
||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
|
||||
goodsName,
|
||||
goodsNum,
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
@@ -252,8 +266,12 @@ export class FulfillmentService {
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
delivery: {
|
||||
include: { signPhotoResource: true },
|
||||
include: {
|
||||
signPhotoResource: true,
|
||||
fulfillmentProvider: { select: { id: true, code: true } },
|
||||
},
|
||||
},
|
||||
fulfillmentWarehouse: { select: { fulfillmentProviderId: true } },
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
@@ -264,35 +282,44 @@ export class FulfillmentService {
|
||||
provider: order?.delivery?.provider,
|
||||
trackingNo: order?.delivery?.trackingNo ?? null,
|
||||
logisticsCompany: order?.delivery?.logisticsCompany ?? null,
|
||||
queryError: null as string | null,
|
||||
};
|
||||
if (!order?.delivery) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const providerCode = order.delivery.fulfillmentProvider?.code || String(order.delivery.provider || '');
|
||||
const isXfx =
|
||||
order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || ''));
|
||||
const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo);
|
||||
order.delivery.provider === 'XFX' ||
|
||||
isXfxProviderCode(providerCode) ||
|
||||
order.deliveryType === 'LOCAL';
|
||||
const canQueryCourier = isXfx && !!(order.delivery.trackingNo || order.orderNo);
|
||||
|
||||
if (canQueryCourier) {
|
||||
try {
|
||||
const options = order.delivery.fulfillmentProviderId
|
||||
? {
|
||||
xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
|
||||
order.delivery.fulfillmentProviderId,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
const xiaofeixia = await this.resolveTrackXiaofeixiaConfig({
|
||||
delivery: order.delivery,
|
||||
fulfillmentWarehouse: order.fulfillmentWarehouse,
|
||||
});
|
||||
const options = xiaofeixia ? { xiaofeixia } : undefined;
|
||||
const shipmentQuery = {
|
||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||
outNumber: order.orderNo,
|
||||
};
|
||||
|
||||
const [nodes, signPhotoDataUris] = await Promise.all([
|
||||
this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]),
|
||||
const [trackResult, signPhotoDataUris] = await Promise.all([
|
||||
this.courier
|
||||
.getTrack(shipmentQuery, options)
|
||||
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
||||
.catch((err: unknown) => ({
|
||||
nodes: [] as TrackNode[],
|
||||
error: err instanceof Error ? err.message : '查询路由失败',
|
||||
})),
|
||||
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
|
||||
]);
|
||||
|
||||
base.nodes = this.sortTrackNodesOldestFirst(nodes);
|
||||
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
||||
base.queryError = trackResult.error;
|
||||
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
|
||||
|
||||
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
||||
@@ -309,8 +336,8 @@ export class FulfillmentService {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
} catch (err) {
|
||||
base.queryError = err instanceof Error ? err.message : '查询路由失败';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +354,28 @@ export class FulfillmentService {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveTrackXiaofeixiaConfig(order: {
|
||||
delivery: { fulfillmentProviderId: bigint | null } | null;
|
||||
fulfillmentWarehouse?: { fulfillmentProviderId: bigint | null } | null;
|
||||
}): Promise<XiaofeixiaConfig | null> {
|
||||
const ids = [
|
||||
order.delivery?.fulfillmentProviderId,
|
||||
order.fulfillmentWarehouse?.fulfillmentProviderId,
|
||||
].filter((id): id is bigint => id != null);
|
||||
const seen = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const key = String(id);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
try {
|
||||
return await this.fulfillmentProviderService.resolveXiaofeixiaConfig(id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
||||
}
|
||||
|
||||
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const ta = new Date(a.createTime).getTime();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildXfxGoodsPayload } from './xfx-goods.util';
|
||||
|
||||
describe('buildXfxGoodsPayload', () => {
|
||||
it('瓶装:品名 + 酒精度,件数等于购买瓶数', () => {
|
||||
expect(
|
||||
buildXfxGoodsPayload({
|
||||
productName: '杜康老窖',
|
||||
productSpec: '单瓶',
|
||||
physicalSpec: '500ml | 53度',
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
goodsName: '杜康老窖 500ml | 53度 单瓶',
|
||||
goodsNum: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('箱装:追加包装规格,件数换算为瓶当量', () => {
|
||||
expect(
|
||||
buildXfxGoodsPayload({
|
||||
productName: '杜康老窖',
|
||||
productSpec: '整箱',
|
||||
physicalSpec: '500ml | 53度',
|
||||
quantity: 2,
|
||||
bottlesPerUnit: 6,
|
||||
}),
|
||||
).toEqual({
|
||||
goodsName: '杜康老窖 500ml | 53度 整箱',
|
||||
goodsNum: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('无度数:回落 SKU 规格;再缺失则只用品名', () => {
|
||||
expect(
|
||||
buildXfxGoodsPayload({
|
||||
productName: '杜康老窖',
|
||||
productSpec: '单瓶',
|
||||
physicalSpec: null,
|
||||
quantity: 3,
|
||||
bottlesPerUnit: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
goodsName: '杜康老窖 单瓶',
|
||||
goodsNum: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildXfxGoodsPayload({
|
||||
productName: '杜康老窖',
|
||||
productSpec: ' ',
|
||||
physicalSpec: undefined,
|
||||
quantity: 1,
|
||||
bottlesPerUnit: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
goodsName: '杜康老窖',
|
||||
goodsNum: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('规格重复:不把相同文案拼两次', () => {
|
||||
expect(
|
||||
buildXfxGoodsPayload({
|
||||
productName: '杜康老窖',
|
||||
productSpec: '500ml | 53度',
|
||||
physicalSpec: '500ml | 53度',
|
||||
quantity: 1,
|
||||
bottlesPerUnit: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
goodsName: '杜康老窖 500ml | 53度',
|
||||
goodsNum: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildXfxGoodsPayload({
|
||||
productName: '杜康老窖',
|
||||
productSpec: '53度',
|
||||
physicalSpec: '500ml | 53度',
|
||||
quantity: 1,
|
||||
bottlesPerUnit: 1,
|
||||
}).goodsName,
|
||||
).toBe('杜康老窖 500ml | 53度');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { toBottleQuantity } from '@dukang/domain';
|
||||
|
||||
const GOODS_NAME_MAX_LEN = 128;
|
||||
|
||||
export type XfxGoodsInput = {
|
||||
productName: string;
|
||||
/** SKU 规格快照,如「单瓶 / 整箱」 */
|
||||
productSpec?: string | null;
|
||||
/** SPU 物理规格,如「500ml | 53度」 */
|
||||
physicalSpec?: string | null;
|
||||
quantity: number;
|
||||
bottlesPerUnit: number;
|
||||
};
|
||||
|
||||
export type XfxGoodsPayload = {
|
||||
goodsName: string;
|
||||
goodsNum: number;
|
||||
};
|
||||
|
||||
/** 小飞侠创建运单货品:品名+酒精度规格,件数用瓶当量 */
|
||||
export function buildXfxGoodsPayload(input: XfxGoodsInput): XfxGoodsPayload {
|
||||
const perUnit = input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1;
|
||||
return {
|
||||
goodsName: buildXfxGoodsName(input),
|
||||
goodsNum: toBottleQuantity(input.quantity, perUnit),
|
||||
};
|
||||
}
|
||||
|
||||
function buildXfxGoodsName(input: XfxGoodsInput): string {
|
||||
const name = trimSpec(input.productName);
|
||||
const physical = trimSpec(input.physicalSpec);
|
||||
const skuSpec = trimSpec(input.productSpec);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (name) parts.push(name);
|
||||
|
||||
if (physical) {
|
||||
parts.push(physical);
|
||||
if (skuSpec && !isRedundantSpec(physical, skuSpec)) {
|
||||
parts.push(skuSpec);
|
||||
}
|
||||
} else if (skuSpec) {
|
||||
parts.push(skuSpec);
|
||||
}
|
||||
|
||||
return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, GOODS_NAME_MAX_LEN);
|
||||
}
|
||||
|
||||
function trimSpec(raw?: string | null): string {
|
||||
return (raw ?? '').trim();
|
||||
}
|
||||
|
||||
function isRedundantSpec(physical: string, skuSpec: string): boolean {
|
||||
const a = physical.replace(/\s+/g, '');
|
||||
const b = skuSpec.replace(/\s+/g, '');
|
||||
if (!b || a === b) return true;
|
||||
return a.includes(b) || b.includes(a);
|
||||
}
|
||||
@@ -181,7 +181,12 @@ export class AdminDashboardService {
|
||||
: Promise.resolve(0),
|
||||
can('deliveries')
|
||||
? this.prisma.orderDelivery.count({
|
||||
where: cityFilter ? { order: { cityId: cityFilter } } : undefined,
|
||||
where: {
|
||||
order: {
|
||||
deliveryType: { not: 'ON_SITE_PICKUP' },
|
||||
...(cityFilter ? { cityId: cityFilter } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.d
|
||||
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,
|
||||
@@ -93,7 +95,16 @@ export class AdminOrdersService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: 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: {
|
||||
@@ -104,7 +115,12 @@ export class AdminOrdersService {
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => this.withDeliveryLogisticsFee(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async previewExport(dto: AdminOrdersExportDto) {
|
||||
@@ -240,7 +256,11 @@ export class AdminOrdersService {
|
||||
phoneVerifiedAt: true,
|
||||
},
|
||||
},
|
||||
delivery: true,
|
||||
delivery: {
|
||||
include: {
|
||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
||||
},
|
||||
},
|
||||
benefitCoupon: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -279,7 +299,8 @@ export class AdminOrdersService {
|
||||
? await this.adminRedeemService.buildCouponRedeemTrace(coupon)
|
||||
: { redeemSummary: null, redeemRecords: [] };
|
||||
|
||||
const { benefitCoupon: _coupon, ...orderRest } = order;
|
||||
const withFee = this.withDeliveryLogisticsFee(order);
|
||||
const { benefitCoupon: _coupon, ...orderRest } = withFee;
|
||||
|
||||
return serializeBigInt(
|
||||
mapOrderCompat({
|
||||
@@ -320,6 +341,7 @@ export class AdminOrdersService {
|
||||
include: {
|
||||
delivery: true,
|
||||
fulfillmentWarehouse: true,
|
||||
product: { select: { spec: true } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -346,7 +368,7 @@ export class AdminOrdersService {
|
||||
});
|
||||
order = await this.prisma.order.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { delivery: true, fulfillmentWarehouse: true },
|
||||
include: { delivery: true, fulfillmentWarehouse: true, product: { select: { spec: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -371,6 +393,13 @@ export class AdminOrdersService {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -383,8 +412,8 @@ export class AdminOrdersService {
|
||||
toMobile: order.receiverPhone,
|
||||
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
toAddressDetail: order.receiverAddress,
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
goodsName,
|
||||
goodsNum,
|
||||
weight: dto.weight ?? defaults.weight,
|
||||
payMode: dto.payMode || defaults.payMode,
|
||||
remark: dto.remark || `HQ发货 ${order.orderNo}`,
|
||||
@@ -433,6 +462,34 @@ export class AdminOrdersService {
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util';
|
||||
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
|
||||
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
|
||||
|
||||
@@ -334,18 +336,45 @@ export class AdminRedeemService {
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryOrderSelect = {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
productName: true,
|
||||
productSpec: true,
|
||||
barcode69: true,
|
||||
quantity: true,
|
||||
saleUnit: true,
|
||||
bottlesPerUnit: true,
|
||||
payAmount: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
receiverAddress: true,
|
||||
receiverProvince: true,
|
||||
receiverCity: true,
|
||||
receiverDistrict: true,
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
imageResource: { select: { url: true } },
|
||||
} satisfies Prisma.OrderSelect;
|
||||
|
||||
@Injectable()
|
||||
export class AdminDeliveriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||||
) {}
|
||||
|
||||
async list(query: AdminDeliveriesQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.OrderDeliveryWhereInput = {};
|
||||
const where: Prisma.OrderDeliveryWhereInput = {
|
||||
order: { deliveryType: { not: 'ON_SITE_PICKUP' } },
|
||||
};
|
||||
if (query.provider) where.provider = query.provider as DeliveryProvider;
|
||||
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
|
||||
if (query.orderNo) {
|
||||
where.order = { orderNo: { contains: query.orderNo } };
|
||||
where.order = { deliveryType: { not: 'ON_SITE_PICKUP' }, orderNo: { contains: query.orderNo } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -355,39 +384,53 @@ export class AdminDeliveriesService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
order: {
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
status: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
deliveryType: true,
|
||||
productName: true,
|
||||
quantity: true,
|
||||
},
|
||||
},
|
||||
order: { select: deliveryOrderSelect },
|
||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.orderDelivery.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => this.withLogisticsFee(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const delivery = await this.prisma.orderDelivery.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
order: {
|
||||
include: {
|
||||
user: { select: { id: true, userNo: true, phone: true } },
|
||||
imageResource: { select: { url: true } },
|
||||
},
|
||||
},
|
||||
order: { select: deliveryOrderSelect },
|
||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
||||
},
|
||||
});
|
||||
if (!delivery) throw new NotFoundException('配送单不存在');
|
||||
return serializeBigInt(delivery);
|
||||
return serializeBigInt(this.withLogisticsFee(delivery));
|
||||
}
|
||||
|
||||
private withLogisticsFee<
|
||||
T extends {
|
||||
provider: string;
|
||||
order: { quantity: number; bottlesPerUnit: number; deliveryType: string };
|
||||
fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null;
|
||||
},
|
||||
>(row: T) {
|
||||
const { fulfillmentProvider, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
logisticsFee: calcDeliveryFreightAmount({
|
||||
quantity: row.order.quantity,
|
||||
bottlesPerUnit: row.order.bottlesPerUnit,
|
||||
deliveryType: row.order.deliveryType,
|
||||
provider: row.provider,
|
||||
providerCode: fulfillmentProvider?.code,
|
||||
pricing: this.fulfillmentProviderService.parsePricingRules(
|
||||
fulfillmentProvider?.pricingRulesJson ?? null,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateDeliveryDto) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
allocateBenefitCoupons,
|
||||
} from '@dukang/domain';
|
||||
import {
|
||||
buildShopRedeemLandingUrl,
|
||||
ClientApp,
|
||||
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
||||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { SettlementService } from '../settlement/settlement.service';
|
||||
@@ -84,6 +86,7 @@ export class RedeemService {
|
||||
private readonly authService: AuthService,
|
||||
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private maskPhoneForStore(phone: string) {
|
||||
@@ -576,7 +579,13 @@ export class RedeemService {
|
||||
},
|
||||
});
|
||||
|
||||
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
|
||||
return {
|
||||
token,
|
||||
expireAt,
|
||||
amount: body.amount,
|
||||
boundStoreId: body.storeId ?? null,
|
||||
landingUrl: buildShopRedeemLandingUrl(this.systemConfig.getAppConfig().shopH5Url, token),
|
||||
};
|
||||
}
|
||||
|
||||
async getToken(token: string) {
|
||||
|
||||
@@ -243,8 +243,7 @@ export class StorePackageService {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, storeId);
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const live = await this.listLivePackages(storeId);
|
||||
return serializeBigInt({ live });
|
||||
return this.getPackagesWithPending(storeId);
|
||||
}
|
||||
|
||||
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[], actorId: bigint) {
|
||||
|
||||
@@ -465,9 +465,11 @@ export class TradeService {
|
||||
operator: 'MOCK_PAY',
|
||||
}),
|
||||
});
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MANUAL' },
|
||||
});
|
||||
if (order.deliveryType !== 'ON_SITE_PICKUP') {
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MANUAL' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.afterOrderPaid(order.id);
|
||||
@@ -516,6 +518,12 @@ export class TradeService {
|
||||
);
|
||||
}
|
||||
|
||||
if (order.deliveryType === 'ON_SITE_PICKUP') {
|
||||
// 现场取货:支付即完成,不建配送单、不推仓配
|
||||
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||
if (!delivery) {
|
||||
await this.prisma.orderDelivery.create({
|
||||
@@ -523,12 +531,6 @@ export class TradeService {
|
||||
});
|
||||
}
|
||||
|
||||
if (order.deliveryType === 'ON_SITE_PICKUP') {
|
||||
// 现场取货:支付后即向微信录入「用户自提」发货信息
|
||||
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fulfillmentService.dispatchAfterPay(orderId);
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (refreshed?.status === 'PENDING_SHIP') {
|
||||
@@ -619,11 +621,13 @@ export class TradeService {
|
||||
operator: 'WECHAT_PAY',
|
||||
}),
|
||||
});
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
if (!delivery) {
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MANUAL' },
|
||||
});
|
||||
if (order.deliveryType !== 'ON_SITE_PICKUP') {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
if (!delivery) {
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MANUAL' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2625,11 +2629,13 @@ export class TradeService {
|
||||
operator,
|
||||
}),
|
||||
});
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
if (!delivery) {
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MANUAL' },
|
||||
});
|
||||
if (order.deliveryType !== 'ON_SITE_PICKUP') {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
if (!delivery) {
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MANUAL' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user