增加现场提货的配置

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
+1 -1
View File
@@ -89,7 +89,7 @@ await this.prisma.order.update({ ... });
```typescript ```typescript
const benefitAmount = product.benefitAmount ?? product.price; const benefitAmount = product.benefitAmount ?? product.price;
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额 // 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
// 同城 min 2 瓶 / 跨城 min 6 瓶 // 起购:提货/同城 min 2 瓶 / 跨城 min 6 瓶(城市配置,非承运商)
// 支付成功 → log_third_party + user_order.pay_status=PAID // 支付成功 → log_third_party + user_order.pay_status=PAID
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT) // → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
+1 -1
View File
@@ -37,7 +37,7 @@ disable-model-invocation: true
``` ```
权益额 = 订单实付 1:1(永久) 权益额 = 订单实付 1:1(永久)
同城起购 2 瓶 / 跨城 1 箱(6瓶) 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
门店结算 = 核销额 × 60% 门店结算 = 核销额 × 60%
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%) 单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
核销码 TTL = 3 分钟;试核销固定 ¥100 核销码 TTL = 3 分钟;试核销固定 ¥100
@@ -12,9 +12,9 @@
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 | | REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 | | REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 | | REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 | | REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 | | REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 | | REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 | | REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
| REQ-U-011 | 权益 | 支付成功 1:1 永久 | | REQ-U-011 | 权益 | 支付成功 1:1 永久 |
| REQ-U-012 | 客服 | 已付款起电话/微信客服 | | REQ-U-012 | 客服 | 已付款起电话/微信客服 |
+1 -1
View File
@@ -35,7 +35,7 @@
``` ```
权益额 = benefit_amount ?? price 权益额 = benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
C 端门店仅 status=OPEN C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 Tab:待付款 | 已付款 | 已完成
+1 -1
View File
@@ -92,7 +92,7 @@
```text ```text
权益发放额 = common_product_item.benefit_amount ?? price 权益发放额 = common_product_item.benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
C 端门店列表仅 status=OPEN C 端门店列表仅 status=OPEN
订单 Taball | pending_pay | pending_ship | pending_receive | completed 订单 Taball | pending_pay | pending_ship | pending_receive | completed
+23 -2
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
Divider,
Drawer, Drawer,
Form, Form,
Input, Input,
@@ -362,8 +363,28 @@ export default function CitiesPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item> <Divider orientation="left"></Divider>
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item> <Form.Item
name="pickupMinQty"
label="现场提货"
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="localMinQty"
label="同城配送"
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="crossMinQty"
label="跨城配送"
extra="按瓶当量。当前箱规 6 瓶/箱时,填 6 = 最少 1 箱。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item <Form.Item
name="maxPartnerCommissionPercent" name="maxPartnerCommissionPercent"
label="合伙人佣金合计上限 %" label="合伙人佣金合计上限 %"
@@ -349,6 +349,7 @@ export default function FulfillmentProvidersPage() {
<Form.Item <Form.Item
name="baseBottles" name="baseBottles"
label="起送瓶数" label="起送瓶数"
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
rules={[{ required: true }]} rules={[{ required: true }]}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
> >
@@ -3,7 +3,7 @@ import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets'; import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets';
import { openWecomCustomerServiceChat } from '../lib/wecom-cs'; import { formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs';
export type ContactCsSessionContext = { export type ContactCsSessionContext = {
orderId?: string; orderId?: string;
@@ -72,7 +72,9 @@ export default function ContactCsButton({
} }
toast('请在微信内打开后联系客服'); toast('请在微信内打开后联系客服');
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '无法打开客服'); console.error('[wecom-cs] open failed', e);
const msg = formatOpenCsError(e);
if (msg) toast(msg);
} }
} }
+66 -33
View File
@@ -1,5 +1,3 @@
import Taro from '@tarojs/taro';
export type WecomCsSessionContext = { export type WecomCsSessionContext = {
orderId?: string; orderId?: string;
orderNo?: string; orderNo?: string;
@@ -14,28 +12,69 @@ type OpenCsChatOption = {
sendMessagePath?: string; sendMessagePath?: string;
}; };
type OpenCsChatFn = (option: OpenCsChatOption) => Promise<unknown>; type WxCsFail = { errMsg?: string; errCode?: number };
function getTaroOpenCsChat(): OpenCsChatFn | null { type WxCsChat = {
const api = (Taro as unknown as { openCustomerServiceChat?: OpenCsChatFn }).openCustomerServiceChat; openCustomerServiceChat?: (
return typeof api === 'function' ? api : null; option: OpenCsChatOption & {
success?: () => void;
fail?: (err: WxCsFail) => void;
},
) => void;
};
/**
* 取小程序原生 wx.openCustomerServiceChat。
* 官方不支持 Promise 风格;Taro 模块作用域下也可能读不到全局 wx。
*/
function getWxOpenCsChat(): WxCsChat['openCustomerServiceChat'] | null {
if (process.env.TARO_ENV !== 'weapp') return null;
const candidates: Array<WxCsChat | null | undefined> = [];
try {
// eslint-disable-next-line no-undef
if (typeof wx !== 'undefined') candidates.push(wx as WxCsChat);
} catch {
/* ignore */
}
candidates.push((globalThis as typeof globalThis & { wx?: WxCsChat }).wx);
try {
const fromRuntime = new Function(
'return typeof wx !== "undefined" ? wx : null',
)() as WxCsChat | null;
candidates.push(fromRuntime);
} catch {
/* ignore */
}
for (const api of candidates) {
if (api && typeof api.openCustomerServiceChat === 'function') {
return api.openCustomerServiceChat.bind(api);
}
}
return null;
} }
function getWxOpenCsChat(): ((option: OpenCsChatOption & { function rawCsError(err: unknown): string {
success?: () => void; if (err instanceof Error) return err.message;
fail?: (err: { errMsg?: string }) => void; if (typeof err === 'string') return err;
}) => void) | null { if (err && typeof err === 'object' && 'errMsg' in err) {
const wxApi = ( return String((err as WxCsFail).errMsg || '');
globalThis as { }
wx?: { return '';
openCustomerServiceChat?: (option: OpenCsChatOption & { }
success?: () => void;
fail?: (err: { errMsg?: string }) => void; /** 把微信 fail 转成可读提示;用户取消返回空串,调用方不 toast */
}) => void; export function formatOpenCsError(err: unknown): string {
}; const raw = rawCsError(err);
} if (/cancel/i.test(raw)) return '';
).wx?.openCustomerServiceChat; if (/not bind|not bound/i.test(raw)) {
return typeof wxApi === 'function' ? wxApi : null; return '企业ID未绑定当前小程序。请到微信公众平台「功能 → 客服 → 微信客服」填写同一企业ID';
}
if (!raw || /openCustomerServiceChat:fail$/i.test(raw.trim())) {
return '无法打开微信客服。开发者工具模拟器通常不可用,请用真机预览';
}
return raw;
} }
/** 小程序调起企业微信「微信客服」会话 */ /** 小程序调起企业微信「微信客服」会话 */
@@ -50,6 +89,11 @@ export async function openWecomCustomerServiceChat(params: {
throw new Error('企微客服未配置'); throw new Error('企微客服未配置');
} }
const wxApi = getWxOpenCsChat();
if (!wxApi) {
throw new Error('当前微信版本不支持企业微信客服');
}
const option: OpenCsChatOption = { const option: OpenCsChatOption = {
extInfo: { url }, extInfo: { url },
corpId, corpId,
@@ -64,22 +108,11 @@ export async function openWecomCustomerServiceChat(params: {
} }
} }
const taroApi = getTaroOpenCsChat();
if (taroApi) {
await taroApi(option);
return;
}
const wxApi = getWxOpenCsChat();
if (!wxApi) {
throw new Error('当前微信版本不支持企业微信客服');
}
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
wxApi({ wxApi({
...option, ...option,
success: () => resolve(), success: () => resolve(),
fail: (err) => reject(new Error(err?.errMsg || '无法打开企业微信客服')), fail: (err) => reject(err),
}); });
}); });
} }
+1 -1
View File
@@ -185,7 +185,7 @@ export default function HomePage() {
} }
async function goOnSitePickup(productId: string) { async function goOnSitePickup(productId: string) {
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`; const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
if (!isLoggedIn()) { if (!isLoggedIn()) {
goLogin(returnPath); goLogin(returnPath);
return; return;
@@ -56,6 +56,11 @@ export default function OrderConfirmPickupPage() {
}) })
.then((data) => { .then((data) => {
if (!cancelled) { if (!cancelled) {
const floor = data.minQty ?? 2;
if (quantity < floor) {
setQuantity(floor);
return;
}
setPreview(data); setPreview(data);
setMsg(data.quantityOk === false ? data.quantityMessage || '' : ''); setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
} }
@@ -109,7 +114,7 @@ export default function OrderConfirmPickupPage() {
async function submit() { async function submit() {
if (!canSubmit) { if (!canSubmit) {
if (!quantityOk) { if (!quantityOk) {
const tip = `现场提货至少购买 ${minQty}`; const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
setMsg(tip); setMsg(tip);
toast(tip); toast(tip);
return; return;
@@ -170,7 +175,7 @@ export default function OrderConfirmPickupPage() {
const submitLabel = loading const submitLabel = loading
? '提交中…' ? '提交中…'
: !quantityOk : !quantityOk
? `至少购买 ${minQty}` ? `至少购买 ${minQty}${unitLabel}`
: '提交订单'; : '提交订单';
return ( return (
@@ -223,7 +228,7 @@ export default function OrderConfirmPickupPage() {
</View> </View>
{!quantityOk ? ( {!quantityOk ? (
<Text className="order-qty-hint"> <Text className="order-qty-hint">
{`现场提货至少购买 ${minQty},请调整数量`} {`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -304,7 +304,7 @@ export default function OrderConfirmPage() {
: !addressOk : !addressOk
? '请更换地址' ? '请更换地址'
: !quantityOk : !quantityOk
? `至少购买 ${minQty}` ? `至少购买 ${minQty}${unitLabel}`
: '提交订单'; : '提交订单';
const displayMsg = msg || addressHint; const displayMsg = msg || addressHint;
@@ -349,7 +349,7 @@ export default function OrderConfirmPage() {
<View className="order-card"> <View className="order-card">
<Text className="u-muted"> <Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''} {quantity < minQty ? `;跨城至少购买 ${minQty}${unitLabel}` : ''}
</Text> </Text>
</View> </View>
) : null} ) : null}
@@ -232,7 +232,7 @@ export default function ProductDetailPage() {
if (!productId) return; if (!productId) return;
const skuId = ensureSkuSelected(); const skuId = ensureSkuSelected();
if (specEnabled && !skuId) return; if (specEnabled && !skuId) return;
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1; const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
const qs = [`productId=${productId}`, `qty=${qty}`]; const qs = [`productId=${productId}`, `qty=${qty}`];
if (skuId) qs.push(`skuId=${skuId}`); if (skuId) qs.push(`skuId=${skuId}`);
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`; const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
+3
View File
@@ -30,6 +30,7 @@
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) | | 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) | | 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) | | 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户;结算通知(提现通过+四账单) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
--- ---
@@ -83,6 +84,8 @@
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。 `待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
**履约起购**(城市 `common_city` 配置,按**瓶当量**;箱装 `quantity × bottlesPerUnit`):现场提货默认 **≥2 瓶**;同城配送默认 **≥2 瓶**;跨城配送默认 **≥6 瓶(1 箱)**。起购属销售策略,不放承运商。承运商「起送瓶数」只用于物流计价/对账,不拦下单。
**HQ 订单导出**`admin-web` 订单监控): **HQ 订单导出**`admin-web` 订单监控):
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出 - 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
+2
View File
@@ -65,9 +65,11 @@
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 | | 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 | | 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 | | 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发中 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算通知」并补提现通过与四账单 |
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) | | 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 | | 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
| 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | | 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
+102
View File
@@ -0,0 +1,102 @@
# 杜康好客 · v3.5.11 开发文档
> **2026-08-26** · catalog / trade / mini-user / admin-web / API
> **主题**:三种履约起购挂城市;企微通知补字段;结算通知(提现通过 + 四账单)
---
## 1. 版本目标
| # | 任务 | 类型 | 交付 |
|---|------|------|------|
| 1 | 履约起购 | 需求 | 城市三字段:提货 / 同城 / 跨城(瓶当量) |
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 |
| 6 | 结算通知 | 需求 | `alert.settlement` 改名;提现通过 + 财务四账单(含收款账户) |
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
---
## 2. 规则
起购属销售策略,存在 `common_city`
| 履约 | 字段 | 默认 | 口径 |
|------|------|------|------|
| 现场提货 | `pickup_min_qty` | 2 | 瓶当量 |
| 同城配送 | `local_min_qty` | 2 | 瓶当量 |
| 跨城配送 | `cross_min_qty` | 6 | 瓶当量;箱规 6 瓶/箱 = 最少 1 箱 |
箱装:`quantity × bottlesPerUnit`。承运商 `pricingRules.baseBottles` 只用于物流对账。
---
## 3. 变更面
- Domain`validateMinPurchase` 接收 `{ pickupMinQty, localMinQty, crossMinQty }`
- Tradepreview / create / 代下单读 `pickupMinQty`
- HQ 城市管理:三字段分组;承运商起送 extra
- mini-user:提货入口瓶装默认 2;确认页 `unitLabel`
- SQL[`migrate-city-pickup-min-qty-v3511.sql`](../server/dukang-api/prisma/migrate-city-pickup-min-qty-v3511.sql)
---
## 4. 企微通知字段
### 4.1 提交人
| 角色 | 文案 |
|------|------|
| 合伙人 | `{企业} · {姓名}`(企业空则只姓名) |
| HQ | `HqAccount.name` |
| 门店端 | `门店 · {StoreAccount.name}` |
覆盖:`store.audit_pending`(含 HQ 新建)、`store.info_change_pending``store.package_audit_pending`
入库模板若仍是上一版默认 body,启动时升级;HQ 手改过的不覆盖(可「恢复默认」)。
### 4.2 订单支付成功 `order.paid`
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
- 物流:现场提货 / 同城 / 跨城
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
- 测试单不推
### 4.3 核销成功 `redeem.success`
`userName``userPhone`(脱敏,与支付播报一致)。
---
## 5. 结算通知
条件 key 仍为 `alert.settlement`HQ 展示 **「结算通知」**(原「结算任务告警」)。分组从「系统与结算」拆为「系统监控」+「结算通知」。
| key | 触发 | 收款账户 |
|-----|------|----------|
| `alert.settlement` | 账单定时任务失败、提现超时未审 | — |
| `store.withdraw_approved` | 门店手动提现审核通过 | 门店主账号卡 |
| `finance.store_bill` | 门店日账单 `created>0` | 各店主账号卡 |
| `finance.partner_bill` | 合伙人月账单生成成功 | 合伙人卡 |
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
账单一次摘要(多店多行,最多 15 行);缺卡写「未登记收款账户」;内部群账号不脱敏。`created=0` / `skipped` 不推。
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
---
## 6. 验收
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
- [ ] HQ 新建门店:企微可见 HQ 名称
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确
- [ ] 核销成功:有用户昵称与脱敏手机
- [ ] HQ 推送条件展示「结算通知」,不再出现「结算任务告警」
- [ ] 账单任务失败 / 提现超时出现在勾了「结算通知」的群
- [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
+3 -1
View File
@@ -36,7 +36,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
| 链路 | 关键节点 | | 链路 | 关键节点 |
|------|----------| |------|----------|
| C 购酒 | 登录→开城商品→起购(同城2/跨城6)→支付→权益1:1→出码/核销→评价 | | C 购酒 | 登录→开城商品→起购(提货2/同城2/跨城6=1箱,城市配置)→支付→权益1:1→出码/核销→评价 |
| 门店 | 登录→扫码确认→记录→store_payout T+1 | | 门店 | 登录→扫码确认→记录→store_payout T+1 |
| 合伙人 | 拓店三步→HQ审核→辖区订单/账单 | | 合伙人 | 拓店三步→HQ审核→辖区订单/账单 |
| HQ | 开城/商品/审核/订单/权益/核销/结算/工单 | | HQ | 开城/商品/审核/订单/权益/核销/结算/工单 |
@@ -47,6 +47,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**C 端(v3.5.10**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html``GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat``CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。 **C 端(v3.5.10**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html``GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat``CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
**起购(v3.5.11**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
## 5. 验收用例(必过) ## 5. 验收用例(必过)
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
+32 -13
View File
@@ -7,6 +7,7 @@ import {
calcOrderBoxCount, calcOrderBoxCount,
shouldHoldAutoCourierDispatch, shouldHoldAutoCourierDispatch,
validateMinPurchase, validateMinPurchase,
minBottleQtyForDelivery,
toBottleQuantity, toBottleQuantity,
toMinSaleQuantity, toMinSaleQuantity,
validateBusinessHours, validateBusinessHours,
@@ -34,36 +35,54 @@ describe('calcBenefitAmount', () => {
}); });
describe('validateMinPurchase', () => { describe('validateMinPurchase', () => {
const mins = { pickupMinQty: 2, localMinQty: 2, crossMinQty: 6 };
it('local requires 2 bottles', () => { it('local requires 2 bottles', () => {
expect(validateMinPurchase('LOCAL', 1, 2, 6).ok).toBe(false); expect(validateMinPurchase('LOCAL', 1, mins).ok).toBe(false);
expect(validateMinPurchase('LOCAL', 2, 2, 6).ok).toBe(true); expect(validateMinPurchase('LOCAL', 2, mins).ok).toBe(true);
}); });
it('cross city requires 6 bottles', () => { it('cross city requires 6 bottles', () => {
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false); expect(validateMinPurchase('CROSS_CITY', 5, mins).ok).toBe(false);
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true); expect(validateMinPurchase('CROSS_CITY', 6, mins).ok).toBe(true);
}); });
it('on-site pickup requires at least local min (2 bottles)', () => { it('on-site pickup requires pickup min (default 2 bottles)', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false); expect(validateMinPurchase('ON_SITE_PICKUP', 0, mins).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false); expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true); 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 }; const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const };
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true); expect(validateMinPurchase('LOCAL', 1, mins, opts).ok).toBe(true);
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true); expect(validateMinPurchase('CROSS_CITY', 1, mins, opts).ok).toBe(true);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true); expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins, opts).ok).toBe(true);
}); });
it('box SKU message uses 箱', () => { 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.ok).toBe(false);
expect(r.message).toContain('箱'); 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', () => { describe('toBottleQuantity / toMinSaleQuantity', () => {
it('converts sale qty to bottles', () => { it('converts sale qty to bottles', () => {
expect(toBottleQuantity(2, 1)).toBe(2); 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' ? '箱' : '瓶'; 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( export function validateMinPurchase(
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', deliveryType: DeliveryFulfillmentType,
quantity: number, quantity: number,
localMinQty: number, mins: MinPurchaseMins,
crossMinQty: number,
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit }, options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
): { ok: boolean; message?: string } { ): { ok: boolean; message?: string } {
const bottlesPerUnit = options?.bottlesPerUnit ?? 1; const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE'); const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit); const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
const unit = saleUnitLabel(saleUnit); const unit = saleUnitLabel(saleUnit);
const minBottles = minBottleQtyForDelivery(deliveryType, mins);
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;
if (bottleQty < minBottles) { if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit); const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return { const message =
ok: false, deliveryType === 'ON_SITE_PICKUP'
message: ? `现场提货至少购买 ${minSale}${unit}`
deliveryType === 'LOCAL' : deliveryType === 'LOCAL'
? `同城配送至少购买 ${minSale}${unit}` ? `同城配送至少购买 ${minSale}${unit}`
: `跨城配送至少购买 ${minSale}${unit}`, : `跨城配送至少购买 ${minSale}${unit}`;
}; return { ok: false, message };
} }
return { ok: true }; return { ok: true };
} }
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { isXfxProviderCode } from './fulfillment-provider'; import { isXfxProviderCode, sanitizeDeliveryHintHtml } from './fulfillment-provider';
describe('isXfxProviderCode', () => { describe('isXfxProviderCode', () => {
it('匹配标准编码与城市前缀', () => { it('匹配标准编码与城市前缀', () => {
@@ -16,3 +16,40 @@ describe('isXfxProviderCode', () => {
expect(isXfxProviderCode('SF')).toBe(false); 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; 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 { function sanitizeHintStyle(raw: string): string {
return raw return raw
.split(';') .split(';')
@@ -156,6 +179,16 @@ function sanitizeHintStyle(raw: string): string {
.join(';'); .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 存盘原文) */ /** 文本换行转成 br,供 C 端 RichText 使用(不改 HQ 存盘原文) */
export function deliveryHintHtmlToRichNodes(html: string): string { export function deliveryHintHtmlToRichNodes(html: string): string {
return html return html
@@ -171,6 +204,7 @@ export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) { if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) {
html = html.slice(0, 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(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ''); html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
html = html.replace(/javascript\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 (!HINT_ALLOWED_TAGS.has(name)) return '';
if (name === 'br') return closing ? '' : '<br/>'; if (name === 'br') return closing ? '' : '<br/>';
if (closing) return `</${name}>`; if (closing) return `</${name}>`;
let style = ''; const style = sanitizeHintStyle(extractHintAttr(String(attrs), 'style'));
const styleMatch = String(attrs).match(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/i);
if (styleMatch) {
style = sanitizeHintStyle(styleMatch[2] ?? styleMatch[3] ?? '');
}
let color = ''; let color = '';
let size = ''; let size = '';
if (name === 'font') { if (name === 'font') {
const colorMatch = String(attrs).match(/\scolor\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i); color = extractHintAttr(String(attrs), 'color');
if (colorMatch) color = (colorMatch[2] ?? colorMatch[3] ?? colorMatch[4] ?? '').trim(); size = extractHintAttr(String(attrs), 'size');
const sizeMatch = String(attrs).match(/\ssize\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (sizeMatch) size = (sizeMatch[2] ?? sizeMatch[3] ?? sizeMatch[4] ?? '').trim();
} }
const extra: string[] = []; const extra: string[] = [];
if (style) extra.push(`style="${style}"`); if (style) extra.push(`style="${style}"`);
+1
View File
@@ -191,6 +191,7 @@ CREATE TABLE common_city (
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
province VARCHAR(32) NOT NULL, province VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
pickup_min_qty INT NOT NULL DEFAULT 2,
local_min_qty INT NOT NULL DEFAULT 2, local_min_qty INT NOT NULL DEFAULT 2,
cross_min_qty INT NOT NULL DEFAULT 6, cross_min_qty INT NOT NULL DEFAULT 6,
max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限', max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限',
@@ -0,0 +1,3 @@
-- v3.5.11:城市履约起购 — 现场提货独立阈值(瓶当量)
ALTER TABLE `common_city`
ADD COLUMN `pickup_min_qty` INT NOT NULL DEFAULT 2 AFTER `status`;
+1
View File
@@ -1027,6 +1027,7 @@ model CommonCity {
name String @db.VarChar(64) name String @db.VarChar(64)
province String @db.VarChar(32) province String @db.VarChar(32)
status CityStatus @default(PENDING) status CityStatus @default(PENDING)
pickupMinQty Int @default(2) @map("pickup_min_qty")
localMinQty Int @default(2) @map("local_min_qty") localMinQty Int @default(2) @map("local_min_qty")
crossMinQty Int @default(6) @map("cross_min_qty") crossMinQty Int @default(6) @map("cross_min_qty")
maxPartnerCommissionRate Decimal @default(0.05) @map("max_partner_commission_rate") @db.Decimal(5, 4) maxPartnerCommissionRate Decimal @default(0.05) @map("max_partner_commission_rate") @db.Decimal(5, 4)
+2
View File
@@ -146,6 +146,8 @@ async function main() {
status: 'ACTIVE', status: 'ACTIVE',
pickupMinQty: 2,
localMinQty: 2, localMinQty: 2,
crossMinQty: 6, crossMinQty: 6,
@@ -143,6 +143,7 @@ export class AdminCitiesService {
...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.province !== undefined ? { province: dto.province } : {}), ...(dto.province !== undefined ? { province: dto.province } : {}),
...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}), ...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
...(dto.pickupMinQty !== undefined ? { pickupMinQty: dto.pickupMinQty } : {}),
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}), ...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}), ...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
...(dto.maxPartnerCommissionRate !== undefined ...(dto.maxPartnerCommissionRate !== undefined
@@ -554,9 +554,18 @@ export class UpdateCityDto {
status?: string; status?: string;
@IsOptional() @IsOptional()
@IsNumber()
@Min(1)
pickupMinQty?: number;
@IsOptional()
@IsNumber()
@Min(1)
localMinQty?: number; localMinQty?: number;
@IsOptional() @IsOptional()
@IsNumber()
@Min(1)
crossMinQty?: number; crossMinQty?: number;
@IsOptional() @IsOptional()
@@ -10,6 +10,7 @@ import type { FreightPayType } from '@prisma/client';
import { import {
calcBenefitAmount, calcBenefitAmount,
generateOrderNo, generateOrderNo,
minBottleQtyForDelivery,
orderTabToStatuses, orderTabToStatuses,
toMinSaleQuantity, toMinSaleQuantity,
validateMinPurchase, validateMinPurchase,
@@ -157,11 +158,15 @@ export class TradeService {
} }
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1; const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
const mins = {
pickupMinQty: city.pickupMinQty,
localMinQty: city.localMinQty,
crossMinQty: city.crossMinQty,
};
const check = validateMinPurchase( const check = validateMinPurchase(
deliveryType, deliveryType,
body.quantity, body.quantity,
city.localMinQty, mins,
city.crossMinQty,
{ bottlesPerUnit, saleUnit: sale.saleUnit }, { bottlesPerUnit, saleUnit: sale.saleUnit },
); );
@@ -173,12 +178,7 @@ export class TradeService {
}); });
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minBottleQty = const minBottleQty = minBottleQtyForDelivery(deliveryType, mins);
deliveryType === 'ON_SITE_PICKUP'
? city.localMinQty
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit); const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit);
return { return {
@@ -1666,11 +1666,15 @@ export class TradeService {
} }
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1; const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
const mins = {
pickupMinQty: city.pickupMinQty,
localMinQty: city.localMinQty,
crossMinQty: city.crossMinQty,
};
const check = validateMinPurchase( const check = validateMinPurchase(
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL', deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL',
body.quantity, body.quantity,
city.localMinQty, mins,
city.crossMinQty,
{ bottlesPerUnit, saleUnit: sale.saleUnit }, { bottlesPerUnit, saleUnit: sale.saleUnit },
); );
if (!check.ok) throw new BadRequestException(check.message); if (!check.ok) throw new BadRequestException(check.message);
@@ -1695,7 +1699,7 @@ export class TradeService {
bottlesPerUnit, bottlesPerUnit,
bottleQuantity: body.quantity * bottlesPerUnit, bottleQuantity: body.quantity * bottlesPerUnit,
minQuantity: toMinSaleQuantity( minQuantity: toMinSaleQuantity(
deliveryType === 'CROSS_CITY' ? city.crossMinQty : city.localMinQty, minBottleQtyForDelivery(deliveryType, mins),
bottlesPerUnit, bottlesPerUnit,
), ),
}; };