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
+3 -1
View File
@@ -19,9 +19,11 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"lint": "eslint src"
},
"devDependencies": {
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"vitest": "^1.6.1"
}
}
@@ -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 端回退文案 */
+39
View File
@@ -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();
});
});
+42
View File
@@ -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;
}
/** 从微信扫码结果解析核销 token32 位 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 {
+2
View File
@@ -95,6 +95,8 @@ export interface OrderTrackDto {
provider?: string;
trackingNo?: string | null;
logisticsCompany?: string | null;
/** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */
queryError?: string | null;
}
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
+2 -1
View File
@@ -9,5 +9,6 @@
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
},
});