feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isXfxProviderCode } from './fulfillment-provider';
|
||||
|
||||
describe('isXfxProviderCode', () => {
|
||||
it('匹配标准编码与城市前缀', () => {
|
||||
expect(isXfxProviderCode('XFX')).toBe(true);
|
||||
expect(isXfxProviderCode('xiaofeixia')).toBe(true);
|
||||
expect(isXfxProviderCode('ZZXFX')).toBe(true);
|
||||
expect(isXfxProviderCode('XFX_ZZ')).toBe(true);
|
||||
});
|
||||
|
||||
it('不匹配普通承运商', () => {
|
||||
expect(isXfxProviderCode('')).toBe(false);
|
||||
expect(isXfxProviderCode('LOGISTICS')).toBe(false);
|
||||
expect(isXfxProviderCode('MANUAL')).toBe(false);
|
||||
expect(isXfxProviderCode('SF')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -114,8 +114,13 @@ export interface WarehouseFulfillmentConfig {
|
||||
|
||||
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
|
||||
|
||||
/** 小飞侠承运商编码:精确 XFX/XIAOFEIXIA,以及城市前缀如 ZZXFX */
|
||||
export function isXfxProviderCode(code: string): boolean {
|
||||
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
|
||||
const c = code.trim().toUpperCase();
|
||||
if (!c) return false;
|
||||
if ((XFX_PROVIDER_CODES as readonly string[]).includes(c)) return true;
|
||||
if (c.includes('XIAOFEIXIA')) return true;
|
||||
return c.endsWith('XFX') || c.startsWith('XFX');
|
||||
}
|
||||
|
||||
/** 承运商未配置提示时 C 端回退文案 */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildShopRedeemLandingUrl, parseRedeemTokenFromScan } from './redeem';
|
||||
|
||||
const TOKEN = 'a1b2c3d4e5f6789012345678901234ab';
|
||||
|
||||
describe('buildShopRedeemLandingUrl', () => {
|
||||
it('joins shop H5 origin with /redeem?token=', () => {
|
||||
expect(buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN)).toBe(
|
||||
`https://shop.dukanghaoke.com/redeem?token=${TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slash on the base', () => {
|
||||
expect(buildShopRedeemLandingUrl('https://shop-test.dukanghaoke.com/', TOKEN)).toBe(
|
||||
`https://shop-test.dukanghaoke.com/redeem?token=${TOKEN}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRedeemTokenFromScan', () => {
|
||||
it('accepts a raw 32-hex token', () => {
|
||||
expect(parseRedeemTokenFromScan(TOKEN.toUpperCase())).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('extracts token from shop landing URL', () => {
|
||||
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
|
||||
expect(parseRedeemTokenFromScan(url)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('strips WeChat QR_CODE prefix before parsing URL', () => {
|
||||
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
|
||||
expect(parseRedeemTokenFromScan(`QR_CODE,${url}`)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('returns null for empty or unrelated content', () => {
|
||||
expect(parseRedeemTokenFromScan('')).toBeNull();
|
||||
expect(parseRedeemTokenFromScan('https://shop.dukanghaoke.com/records')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,48 @@ export interface RedeemTokenResult {
|
||||
expireAt: string;
|
||||
amount: number;
|
||||
boundStoreId?: string | null;
|
||||
/** 门店 H5 核销确认页落地 URL(写入二维码,微信扫一扫可直达) */
|
||||
landingUrl?: string;
|
||||
}
|
||||
|
||||
const REDEEM_TOKEN_HEX = /^[a-f0-9]{32}$/i;
|
||||
|
||||
/** 门店核销确认页落地 URL,供用户端核销码使用 */
|
||||
export function buildShopRedeemLandingUrl(shopH5Url: string, token: string): string {
|
||||
const base = shopH5Url.replace(/\/$/, '');
|
||||
return `${base}/redeem?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** 微信扫码偶发 `QR_CODE,payload` 前缀 */
|
||||
function stripScanTypePrefix(raw: string): string {
|
||||
const comma = raw.indexOf(',');
|
||||
if (comma > 0 && comma < 24 && /^[A-Z0-9_]+$/.test(raw.slice(0, comma))) {
|
||||
return raw.slice(comma + 1).trim();
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||
const trimmed = stripScanTypePrefix(raw.trim());
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (REDEEM_TOKEN_HEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
try {
|
||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||
const fromQuery = url.searchParams.get('token');
|
||||
if (fromQuery && REDEEM_TOKEN_HEX.test(fromQuery)) {
|
||||
return fromQuery.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
|
||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||
}
|
||||
|
||||
export interface RedeemPreviewDto {
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface OrderTrackDto {
|
||||
provider?: string;
|
||||
trackingNo?: string | null;
|
||||
logisticsCompany?: string | null;
|
||||
/** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */
|
||||
queryError?: string | null;
|
||||
}
|
||||
|
||||
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
||||
|
||||
Reference in New Issue
Block a user