增加现场提货的配置

This commit is contained in:
2026-08-26 09:22:29 +08:00
parent c7cd0f6cf2
commit 632539e8dc
28 changed files with 393 additions and 105 deletions
+32 -13
View File
@@ -7,6 +7,7 @@ import {
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase,
minBottleQtyForDelivery,
toBottleQuantity,
toMinSaleQuantity,
validateBusinessHours,
@@ -34,36 +35,54 @@ describe('calcBenefitAmount', () => {
});
describe('validateMinPurchase', () => {
const mins = { pickupMinQty: 2, localMinQty: 2, crossMinQty: 6 };
it('local requires 2 bottles', () => {
expect(validateMinPurchase('LOCAL', 1, 2, 6).ok).toBe(false);
expect(validateMinPurchase('LOCAL', 2, 2, 6).ok).toBe(true);
expect(validateMinPurchase('LOCAL', 1, mins).ok).toBe(false);
expect(validateMinPurchase('LOCAL', 2, mins).ok).toBe(true);
});
it('cross city requires 6 bottles', () => {
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
expect(validateMinPurchase('CROSS_CITY', 5, mins).ok).toBe(false);
expect(validateMinPurchase('CROSS_CITY', 6, mins).ok).toBe(true);
});
it('on-site pickup requires at least local min (2 bottles)', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
it('on-site pickup requires pickup min (default 2 bottles)', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 0, mins).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 2, mins).ok).toBe(true);
});
it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => {
it('on-site pickup uses pickupMinQty independently of localMinQty', () => {
const split = { pickupMinQty: 3, localMinQty: 2, crossMinQty: 6 };
expect(validateMinPurchase('ON_SITE_PICKUP', 2, split).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 3, split).ok).toBe(true);
expect(validateMinPurchase('LOCAL', 2, split).ok).toBe(true);
});
it('box SKU: 1 box (6 bottles) satisfies local, cross and pickup mins', () => {
const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const };
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true);
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true);
expect(validateMinPurchase('LOCAL', 1, mins, opts).ok).toBe(true);
expect(validateMinPurchase('CROSS_CITY', 1, mins, opts).ok).toBe(true);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins, opts).ok).toBe(true);
});
it('box SKU message uses 箱', () => {
const r = validateMinPurchase('LOCAL', 0, 2, 6, { bottlesPerUnit: 6, saleUnit: 'BOX' });
const r = validateMinPurchase('LOCAL', 0, mins, { bottlesPerUnit: 6, saleUnit: 'BOX' });
expect(r.ok).toBe(false);
expect(r.message).toContain('箱');
});
});
describe('minBottleQtyForDelivery', () => {
it('picks pickup / local / cross independently', () => {
const mins = { pickupMinQty: 2, localMinQty: 3, crossMinQty: 6 };
expect(minBottleQtyForDelivery('ON_SITE_PICKUP', mins)).toBe(2);
expect(minBottleQtyForDelivery('LOCAL', mins)).toBe(3);
expect(minBottleQtyForDelivery('CROSS_CITY', mins)).toBe(6);
});
});
describe('toBottleQuantity / toMinSaleQuantity', () => {
it('converts sale qty to bottles', () => {
expect(toBottleQuantity(2, 1)).toBe(2);
+31 -19
View File
@@ -33,36 +33,48 @@ export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefi
return saleUnit === 'BOX' ? '箱' : '瓶';
}
export type DeliveryFulfillmentType = 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
/** 城市履约起购(瓶当量) */
export type MinPurchaseMins = {
pickupMinQty: number;
localMinQty: number;
crossMinQty: number;
};
export function minBottleQtyForDelivery(
deliveryType: DeliveryFulfillmentType,
mins: MinPurchaseMins,
): number {
if (deliveryType === 'ON_SITE_PICKUP') {
return mins.pickupMinQty > 0 ? mins.pickupMinQty : 2;
}
if (deliveryType === 'LOCAL') {
return mins.localMinQty;
}
return mins.crossMinQty;
}
export function validateMinPurchase(
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
deliveryType: DeliveryFulfillmentType,
quantity: number,
localMinQty: number,
crossMinQty: number,
mins: MinPurchaseMins,
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
): { ok: boolean; message?: string } {
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
const unit = saleUnitLabel(saleUnit);
if (deliveryType === 'ON_SITE_PICKUP') {
const minBottles = localMinQty > 0 ? localMinQty : 2;
if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
}
return { ok: true };
}
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
const minBottles = minBottleQtyForDelivery(deliveryType, mins);
if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return {
ok: false,
message:
deliveryType === 'LOCAL'
const message =
deliveryType === 'ON_SITE_PICKUP'
? `现场提货至少购买 ${minSale}${unit}`
: deliveryType === 'LOCAL'
? `同城配送至少购买 ${minSale}${unit}`
: `跨城配送至少购买 ${minSale}${unit}`,
};
: `跨城配送至少购买 ${minSale}${unit}`;
return { ok: false, message };
}
return { ok: true };
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isXfxProviderCode } from './fulfillment-provider';
import { isXfxProviderCode, sanitizeDeliveryHintHtml } from './fulfillment-provider';
describe('isXfxProviderCode', () => {
it('匹配标准编码与城市前缀', () => {
@@ -16,3 +16,40 @@ describe('isXfxProviderCode', () => {
expect(isXfxProviderCode('SF')).toBe(false);
});
});
describe('sanitizeDeliveryHintHtml', () => {
const styled =
'<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>';
it('保留允许的 style', () => {
expect(sanitizeDeliveryHintHtml(styled)).toBe(styled);
});
it('解码 &quot; 后保留 style', () => {
const raw =
'<span style=&quot;color:#A61D24;font-weight:700;font-size:13px&quot;>同城配送,预计24小时内送到</span><br />\n<span>13点之前下单,当日送达</span>';
expect(sanitizeDeliveryHintHtml(raw)).toBe(
`${styled}<br/>\n<span>13点之前下单,当日送达</span>`,
);
});
it('弯引号与无引号 style 也能保留', () => {
expect(
sanitizeDeliveryHintHtml(
'<span style=\u201Ccolor:#A61D24;font-weight:700;font-size:13px\u201D>同城配送,预计24小时内送到</span>',
),
).toBe(styled);
expect(
sanitizeDeliveryHintHtml(
'<span style=color:#A61D24;font-weight:700;font-size:13px>同城配送,预计24小时内送到</span>',
),
).toBe(styled);
});
it('仍去掉脚本与非法样式', () => {
expect(sanitizeDeliveryHintHtml('<span style="color:red;background:url(x)">x</span>')).toBe(
'<span style="color:red">x</span>',
);
expect(sanitizeDeliveryHintHtml('<script>alert(1)</script><span>ok</span>')).toBe('<span>ok</span>');
});
});
@@ -138,6 +138,29 @@ export type LocalDeliveryDto = {
hintHtml: string | null;
};
/** 粘贴 HTML 源码时常带 &quot; / 弯引号;不解码则 style 正则匹配失败,整段样式被剥掉 */
function decodeHintHtmlEntities(html: string): string {
let prev = '';
let out = html;
for (let i = 0; i < 3 && out !== prev; i += 1) {
prev = out;
out = out
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#0*34;/g, '"')
.replace(/&#x0*22;/gi, '"')
.replace(/&apos;/gi, "'")
.replace(/&#0*39;/g, "'")
.replace(/&#x0*27;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&nbsp;/gi, ' ');
}
return out
.replace(/[\u201c\u201d\u201e\u00ab\u00bb]/g, '"')
.replace(/[\u2018\u2019]/g, "'");
}
function sanitizeHintStyle(raw: string): string {
return raw
.split(';')
@@ -156,6 +179,16 @@ function sanitizeHintStyle(raw: string): string {
.join(';');
}
function extractHintAttr(attrs: string, name: string): string {
const re = new RegExp(
`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
'i',
);
const match = attrs.match(re);
if (!match) return '';
return (match[1] ?? match[2] ?? match[3] ?? '').trim();
}
/** 文本换行转成 br,供 C 端 RichText 使用(不改 HQ 存盘原文) */
export function deliveryHintHtmlToRichNodes(html: string): string {
return html
@@ -171,6 +204,7 @@ export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) {
html = html.slice(0, LOCAL_DELIVERY_HINT_MAX_LEN);
}
html = decodeHintHtmlEntities(html);
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
html = html.replace(/javascript\s*:/gi, '');
@@ -180,18 +214,12 @@ export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
if (!HINT_ALLOWED_TAGS.has(name)) return '';
if (name === 'br') return closing ? '' : '<br/>';
if (closing) return `</${name}>`;
let style = '';
const styleMatch = String(attrs).match(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/i);
if (styleMatch) {
style = sanitizeHintStyle(styleMatch[2] ?? styleMatch[3] ?? '');
}
const style = sanitizeHintStyle(extractHintAttr(String(attrs), 'style'));
let color = '';
let size = '';
if (name === 'font') {
const colorMatch = String(attrs).match(/\scolor\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (colorMatch) color = (colorMatch[2] ?? colorMatch[3] ?? colorMatch[4] ?? '').trim();
const sizeMatch = String(attrs).match(/\ssize\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (sizeMatch) size = (sizeMatch[2] ?? sizeMatch[3] ?? sizeMatch[4] ?? '').trim();
color = extractHintAttr(String(attrs), 'color');
size = extractHintAttr(String(attrs), 'size');
}
const extra: string[] = [];
if (style) extra.push(`style="${style}"`);