feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址

同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 21:12:36 +08:00
parent 52a7d3789d
commit cc0c0a6ef8
44 changed files with 1029 additions and 218 deletions
@@ -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);
}