Merge pull request 'Dev jacy v3.5.11 企业微信通知调整' (#52) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/52
This commit was merged in pull request #52.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 | 客服 | 已付款起电话/微信客服 |
|
||||||
|
|||||||
@@ -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:待付款 | 已付款 | 已完成
|
||||||
|
|||||||
@@ -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
|
||||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -50,34 +50,9 @@ function keyOf(ids: string[]) {
|
|||||||
return [...ids].sort().join('_');
|
return [...ids].sort().join('_');
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
|
||||||
productId: string;
|
if (!initialSkus.length) {
|
||||||
initialAttrs: SpecAttr[];
|
return [
|
||||||
initialSkus: SkuRow[];
|
|
||||||
onSaved: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
|
||||||
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
|
||||||
initialAttrs.length
|
|
||||||
? initialAttrs
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const [skus, setSkus] = useState<SkuRow[]>(
|
|
||||||
initialSkus.length
|
|
||||||
? initialSkus.map((s) => ({
|
|
||||||
...s,
|
|
||||||
specValueIds: s.specValueIds ?? [],
|
|
||||||
status: s.status || 'ON_SALE',
|
|
||||||
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
|
||||||
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
|
||||||
allowOnSitePickup: !!s.allowOnSitePickup,
|
|
||||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
|
||||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
|
||||||
isDefault: !!s.isDefault,
|
|
||||||
imageUrl: s.imageUrl ?? '',
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
{
|
||||||
specValueIds: [],
|
specValueIds: [],
|
||||||
barcode69: '',
|
barcode69: '',
|
||||||
@@ -91,11 +66,43 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
);
|
}
|
||||||
|
return initialSkus.map((s) => ({
|
||||||
|
...s,
|
||||||
|
specValueIds: s.specValueIds ?? [],
|
||||||
|
status: s.status || 'ON_SALE',
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
productId: string;
|
||||||
|
initialAttrs: SpecAttr[];
|
||||||
|
initialSkus: SkuRow[];
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
|
const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
|
||||||
|
const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAttrs(initialAttrs.length ? initialAttrs : []);
|
||||||
|
setSkus(normalizeSkuRows(initialSkus));
|
||||||
|
setEditIndex(null);
|
||||||
|
// 仅切商品时重载,避免父组件重渲染冲掉编辑中内容
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||||
|
|
||||||
function regenerateMatrix() {
|
function regenerateMatrix() {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ export default function ProductsPage() {
|
|||||||
label: '规格与 SKU',
|
label: '规格与 SKU',
|
||||||
children: (
|
children: (
|
||||||
<ProductSpecsEditor
|
<ProductSpecsEditor
|
||||||
|
key={String(detail.id)}
|
||||||
productId={String(detail.id)}
|
productId={String(detail.id)}
|
||||||
initialAttrs={(detail.specAttrs as never) ?? []}
|
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||||
initialSkus={(detail.skus as never) ?? []}
|
initialSkus={(detail.skus as never) ?? []}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & {
|
||||||
}
|
|
||||||
|
|
||||||
function getWxOpenCsChat(): ((option: OpenCsChatOption & {
|
|
||||||
success?: () => void;
|
success?: () => void;
|
||||||
fail?: (err: { errMsg?: string }) => void;
|
fail?: (err: WxCsFail) => void;
|
||||||
}) => void) | null {
|
},
|
||||||
const wxApi = (
|
) => void;
|
||||||
globalThis as {
|
|
||||||
wx?: {
|
|
||||||
openCustomerServiceChat?: (option: OpenCsChatOption & {
|
|
||||||
success?: () => void;
|
|
||||||
fail?: (err: { errMsg?: string }) => 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 */
|
||||||
}
|
}
|
||||||
).wx?.openCustomerServiceChat;
|
candidates.push((globalThis as typeof globalThis & { wx?: WxCsChat }).wx);
|
||||||
return typeof wxApi === 'function' ? wxApi : null;
|
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 rawCsError(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
if (err && typeof err === 'object' && 'errMsg' in err) {
|
||||||
|
return String((err as WxCsFail).errMsg || '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把微信 fail 转成可读提示;用户取消返回空串,调用方不 toast */
|
||||||
|
export function formatOpenCsError(err: unknown): string {
|
||||||
|
const raw = rawCsError(err);
|
||||||
|
if (/cancel/i.test(raw)) return '';
|
||||||
|
if (/not bind|not bound/i.test(raw)) {
|
||||||
|
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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
const attrs = product?.specAttrs ?? [];
|
const attrs = product?.specAttrs ?? [];
|
||||||
const skus = product?.skus ?? [];
|
const skus = product?.skus ?? [];
|
||||||
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
|
const onSaleSkuCount = skus.filter((s) => s.status === 'ON_SALE').length;
|
||||||
|
const specEnabled = onSaleSkuCount > 1 && attrs.length > 0;
|
||||||
const activeSku = useMemo(() => {
|
const activeSku = useMemo(() => {
|
||||||
if (!product) return undefined;
|
if (!product) return undefined;
|
||||||
if (!specEnabled) {
|
if (!specEnabled) {
|
||||||
@@ -231,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('&')}`;
|
||||||
|
|||||||
@@ -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` 订单监控):
|
||||||
|
|
||||||
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
|
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
|
||||||
|
|||||||
@@ -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.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||||
|
|||||||
@@ -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 }`
|
||||||
|
- Trade:preview / 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
@@ -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…
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
if (bottleQty < minBottles) {
|
||||||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
|
const message =
|
||||||
}
|
deliveryType === 'ON_SITE_PICKUP'
|
||||||
return { ok: true };
|
? `现场提货至少购买 ${minSale}${unit}`
|
||||||
}
|
: deliveryType === 'LOCAL'
|
||||||
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
|
||||||
if (bottleQty < minBottles) {
|
|
||||||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message:
|
|
||||||
deliveryType === 'LOCAL'
|
|
||||||
? `同城配送至少购买 ${minSale}${unit}`
|
? `同城配送至少购买 ${minSale}${unit}`
|
||||||
: `跨城配送至少购买 ${minSale}${unit}`,
|
: `跨城配送至少购买 ${minSale}${unit}`;
|
||||||
};
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export interface ProductDto {
|
|||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
/** 是否允许跨城配送 */
|
/** 是否允许跨城配送 */
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
/** v3.5.4:是否配置了销售规格(多 SKU) */
|
/** C 端是否展示规格选择(可售 SKU > 1 且已配规格轴) */
|
||||||
specEnabled?: boolean;
|
specEnabled?: boolean;
|
||||||
/** 列表轻量:展示 SKU 销售单位 */
|
/** 列表轻量:展示 SKU 销售单位 */
|
||||||
saleUnit?: ProductSaleUnit;
|
saleUnit?: ProductSaleUnit;
|
||||||
|
|||||||
@@ -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('解码 " 后保留 style', () => {
|
||||||
|
const raw =
|
||||||
|
'<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计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 源码时常带 " / 弯引号;不解码则 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(/&/gi, '&')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/�*34;/g, '"')
|
||||||
|
.replace(/�*22;/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/�*39;/g, "'")
|
||||||
|
.replace(/�*27;/gi, "'")
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/ /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}"`);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
WECOM_PUSH_CONDITION_GROUPS,
|
||||||
|
WECOM_PUSH_CONDITION_LABELS,
|
||||||
|
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
|
||||||
|
WECOM_SETTLEMENT_PUSH_NAME,
|
||||||
|
parseWecomPushConditions,
|
||||||
|
} from './wecom-message-push';
|
||||||
|
|
||||||
|
describe('wecom-message-push', () => {
|
||||||
|
it('alert.settlement 展示为结算任务失败通知且在系统监控组', () => {
|
||||||
|
expect(WECOM_PUSH_CONDITION_LABELS['alert.settlement']).toBe('结算任务失败通知');
|
||||||
|
const system = WECOM_PUSH_CONDITION_GROUPS.find((g) => g.key === 'system');
|
||||||
|
expect(system?.conditions).toEqual(['alert.system', 'alert.settlement']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认结算通知群条件含任务失败与四账单', () => {
|
||||||
|
expect(WECOM_SETTLEMENT_PUSH_NAME).toBe('结算通知群');
|
||||||
|
expect(WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'alert.settlement',
|
||||||
|
'store.withdraw_approved',
|
||||||
|
'finance.store_bill',
|
||||||
|
'finance.partner_bill',
|
||||||
|
'finance.winery_bill',
|
||||||
|
'finance.logistics_bill',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析新财务条件', () => {
|
||||||
|
expect(parseWecomPushConditions(['finance.store_bill', 'bogus'])).toEqual([
|
||||||
|
'finance.store_bill',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知) */
|
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知 + v3.5.11 结算通知) */
|
||||||
export const WECOM_PUSH_CONDITIONS = [
|
export const WECOM_PUSH_CONDITIONS = [
|
||||||
'alert.ops',
|
'alert.ops',
|
||||||
'support_ticket.created',
|
'support_ticket.created',
|
||||||
@@ -11,9 +11,14 @@ export const WECOM_PUSH_CONDITIONS = [
|
|||||||
'store.package_audit_pending',
|
'store.package_audit_pending',
|
||||||
'store.info_change_pending',
|
'store.info_change_pending',
|
||||||
'store.withdraw_pending',
|
'store.withdraw_pending',
|
||||||
|
'store.withdraw_approved',
|
||||||
'order.paid',
|
'order.paid',
|
||||||
'redeem.success',
|
'redeem.success',
|
||||||
'invoice.pending',
|
'invoice.pending',
|
||||||
|
'finance.store_bill',
|
||||||
|
'finance.partner_bill',
|
||||||
|
'finance.winery_bill',
|
||||||
|
'finance.logistics_bill',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
|
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
|
||||||
@@ -24,15 +29,20 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
|
|||||||
'alert.pay': '支付异常',
|
'alert.pay': '支付异常',
|
||||||
'alert.redeem': '核销异常',
|
'alert.redeem': '核销异常',
|
||||||
'alert.system': '系统监控(5xx、回调、定时任务)',
|
'alert.system': '系统监控(5xx、回调、定时任务)',
|
||||||
'alert.settlement': '结算任务告警',
|
'alert.settlement': '结算任务失败通知',
|
||||||
'dev_plan.task_dispatch': '开发任务评审派发',
|
'dev_plan.task_dispatch': '开发任务评审派发',
|
||||||
'store.audit_pending': '门店提交/重提待审',
|
'store.audit_pending': '门店提交/重提待审',
|
||||||
'store.package_audit_pending': '套餐变更待审',
|
'store.package_audit_pending': '套餐变更待审',
|
||||||
'store.info_change_pending': '门店信息变更待审',
|
'store.info_change_pending': '门店信息变更待审',
|
||||||
'store.withdraw_pending': '门店手动提现待审',
|
'store.withdraw_pending': '门店手动提现待审',
|
||||||
|
'store.withdraw_approved': '门店手动提现已通过',
|
||||||
'order.paid': '订单支付成功',
|
'order.paid': '订单支付成功',
|
||||||
'redeem.success': '门店核销成功',
|
'redeem.success': '门店核销成功',
|
||||||
'invoice.pending': '发票申请待开票',
|
'invoice.pending': '发票申请待开票',
|
||||||
|
'finance.store_bill': '门店日账单已生成',
|
||||||
|
'finance.partner_bill': '合伙人月账单已生成',
|
||||||
|
'finance.winery_bill': '酒厂日账单已生成',
|
||||||
|
'finance.logistics_bill': '物流月对账已生成',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
||||||
@@ -68,9 +78,20 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'system',
|
key: 'system',
|
||||||
label: '系统与结算',
|
label: '系统监控',
|
||||||
conditions: ['alert.system', 'alert.settlement'],
|
conditions: ['alert.system', 'alert.settlement'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'settlement',
|
||||||
|
label: '结算通知',
|
||||||
|
conditions: [
|
||||||
|
'store.withdraw_approved',
|
||||||
|
'finance.store_bill',
|
||||||
|
'finance.partner_bill',
|
||||||
|
'finance.winery_bill',
|
||||||
|
'finance.logistics_bill',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'dev_plan',
|
key: 'dev_plan',
|
||||||
label: '开发计划',
|
label: '开发计划',
|
||||||
@@ -115,9 +136,20 @@ export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[]
|
|||||||
'redeem.success',
|
'redeem.success',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 默认「结算通知群」 */
|
||||||
|
export const WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS: WecomPushCondition[] = [
|
||||||
|
'alert.settlement',
|
||||||
|
'store.withdraw_approved',
|
||||||
|
'finance.store_bill',
|
||||||
|
'finance.partner_bill',
|
||||||
|
'finance.winery_bill',
|
||||||
|
'finance.logistics_bill',
|
||||||
|
];
|
||||||
|
|
||||||
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
|
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
|
||||||
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
|
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
|
||||||
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
|
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
|
||||||
|
export const WECOM_SETTLEMENT_PUSH_NAME = '结算通知群';
|
||||||
|
|
||||||
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
|
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
|
||||||
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
|
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
|
||||||
@@ -131,7 +163,12 @@ export const WECOM_TEMPLATE_EVENT_KEYS = [
|
|||||||
'store.package_audit_pending',
|
'store.package_audit_pending',
|
||||||
'store.info_change_pending',
|
'store.info_change_pending',
|
||||||
'store.withdraw_pending',
|
'store.withdraw_pending',
|
||||||
|
'store.withdraw_approved',
|
||||||
'invoice.pending',
|
'invoice.pending',
|
||||||
|
'finance.store_bill',
|
||||||
|
'finance.partner_bill',
|
||||||
|
'finance.winery_bill',
|
||||||
|
'finance.logistics_bill',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
|
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
|
||||||
@@ -143,14 +180,50 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string>
|
|||||||
'store.package_audit_pending': '套餐变更待审',
|
'store.package_audit_pending': '套餐变更待审',
|
||||||
'store.info_change_pending': '门店信息变更待审',
|
'store.info_change_pending': '门店信息变更待审',
|
||||||
'store.withdraw_pending': '门店手动提现待审',
|
'store.withdraw_pending': '门店手动提现待审',
|
||||||
|
'store.withdraw_approved': '门店手动提现已通过',
|
||||||
'invoice.pending': '发票申请待开票',
|
'invoice.pending': '发票申请待开票',
|
||||||
|
'finance.store_bill': '门店日账单已生成',
|
||||||
|
'finance.partner_bill': '合伙人月账单已生成',
|
||||||
|
'finance.winery_bill': '酒厂日账单已生成',
|
||||||
|
'finance.logistics_bill': '物流月对账已生成',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 各事件可用占位符说明(HQ 模板编辑) */
|
/** 各事件可用占位符说明(HQ 模板编辑) */
|
||||||
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
|
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
|
||||||
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
|
'order.paid': [
|
||||||
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
|
'orderNo',
|
||||||
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
|
'payAmount',
|
||||||
|
'cityName',
|
||||||
|
'skuSummary',
|
||||||
|
'deliveryLabel',
|
||||||
|
'receiverInfo',
|
||||||
|
'phoneMasked',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
|
'redeem.success': [
|
||||||
|
'redeemNo',
|
||||||
|
'amount',
|
||||||
|
'storeName',
|
||||||
|
'channel',
|
||||||
|
'userName',
|
||||||
|
'userPhone',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
|
'store.audit_pending': [
|
||||||
|
'storeName',
|
||||||
|
'cityName',
|
||||||
|
'submitter',
|
||||||
|
'partnerLabel',
|
||||||
|
'action',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
'storeId',
|
||||||
|
],
|
||||||
'store.package_audit_pending': [
|
'store.package_audit_pending': [
|
||||||
'storeName',
|
'storeName',
|
||||||
'cityName',
|
'cityName',
|
||||||
@@ -181,6 +254,16 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
|||||||
'handleLabel',
|
'handleLabel',
|
||||||
'storeId',
|
'storeId',
|
||||||
],
|
],
|
||||||
|
'store.withdraw_approved': [
|
||||||
|
'storeName',
|
||||||
|
'withdrawNo',
|
||||||
|
'amount',
|
||||||
|
'bankAccount',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
'storeId',
|
||||||
|
],
|
||||||
'invoice.pending': [
|
'invoice.pending': [
|
||||||
'invoiceNo',
|
'invoiceNo',
|
||||||
'orderNo',
|
'orderNo',
|
||||||
@@ -191,6 +274,39 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
|||||||
'handleUrl',
|
'handleUrl',
|
||||||
'handleLabel',
|
'handleLabel',
|
||||||
],
|
],
|
||||||
|
'finance.store_bill': [
|
||||||
|
'period',
|
||||||
|
'billCount',
|
||||||
|
'billSummary',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
|
'finance.partner_bill': [
|
||||||
|
'period',
|
||||||
|
'billCount',
|
||||||
|
'billSummary',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
|
'finance.winery_bill': [
|
||||||
|
'period',
|
||||||
|
'amount',
|
||||||
|
'orderCount',
|
||||||
|
'bankAccount',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
|
'finance.logistics_bill': [
|
||||||
|
'period',
|
||||||
|
'billCount',
|
||||||
|
'billSummary',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WecomPushTemplateDto = {
|
export type WecomPushTemplateDto = {
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ async function main() {
|
|||||||
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
|
||||||
|
pickupMinQty: 2,
|
||||||
|
|
||||||
localMinQty: 2,
|
localMinQty: 2,
|
||||||
|
|
||||||
crossMinQty: 6,
|
crossMinQty: 6,
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ export async function loadStorePrimaryBanksMap(
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatWecomBankAccount(bank?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
} | null): string {
|
||||||
|
if (!bank) return '未登记收款账户';
|
||||||
|
const name = bank.bankAccountName?.trim() || '';
|
||||||
|
const no = bank.bankAccountNo?.trim() || '';
|
||||||
|
if (!name && !no) return '未登记收款账户';
|
||||||
|
const bankLine = bank.bankName?.trim() || bank.bankBranch?.trim() || '';
|
||||||
|
return [name, bankLine, no].filter(Boolean).join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadWineryBankConfig(
|
export async function loadWineryBankConfig(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> {
|
): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
|
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
|
||||||
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
|
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
|
||||||
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
||||||
|
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
|
||||||
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
|
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
|
||||||
|
WECOM_SETTLEMENT_PUSH_NAME,
|
||||||
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||||
WECOM_STORE_AUDIT_PUSH_NAME,
|
WECOM_STORE_AUDIT_PUSH_NAME,
|
||||||
WECOM_TEMPLATE_EVENT_KEYS,
|
WECOM_TEMPLATE_EVENT_KEYS,
|
||||||
@@ -22,6 +24,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
||||||
import {
|
import {
|
||||||
WECOM_PUSH_TEMPLATE_DEFAULTS,
|
WECOM_PUSH_TEMPLATE_DEFAULTS,
|
||||||
|
WECOM_PUSH_TEMPLATE_LEGACY_BODIES,
|
||||||
buildHqHandleUrl,
|
buildHqHandleUrl,
|
||||||
getDefaultTemplate,
|
getDefaultTemplate,
|
||||||
renderWecomTemplate,
|
renderWecomTemplate,
|
||||||
@@ -66,7 +69,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */
|
/** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 结算通知 / 模板缺行 */
|
||||||
async ensureDefaults(): Promise<void> {
|
async ensureDefaults(): Promise<void> {
|
||||||
const count = await this.prisma.wecomMessagePush.count();
|
const count = await this.prisma.wecomMessagePush.count();
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
@@ -141,6 +144,12 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
30,
|
30,
|
||||||
process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL,
|
process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL,
|
||||||
);
|
);
|
||||||
|
await this.ensureNamedPush(
|
||||||
|
WECOM_SETTLEMENT_PUSH_NAME,
|
||||||
|
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
|
||||||
|
35,
|
||||||
|
process.env.WECOM_SETTLEMENT_WEBHOOK_URL,
|
||||||
|
);
|
||||||
await this.ensureTemplates();
|
await this.ensureTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,13 +190,13 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`);
|
this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅插入缺失 eventKey,不覆盖已有文案 */
|
/** 插入缺失 eventKey;仅当 body 仍为上一版默认文案时升级,不覆盖 HQ 手改 */
|
||||||
async ensureTemplates(): Promise<void> {
|
async ensureTemplates(): Promise<void> {
|
||||||
for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) {
|
for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) {
|
||||||
const existing = await this.prisma.wecomPushTemplate.findUnique({
|
const existing = await this.prisma.wecomPushTemplate.findUnique({
|
||||||
where: { eventKey: def.eventKey },
|
where: { eventKey: def.eventKey },
|
||||||
});
|
});
|
||||||
if (existing) continue;
|
if (!existing) {
|
||||||
await this.prisma.wecomPushTemplate.create({
|
await this.prisma.wecomPushTemplate.create({
|
||||||
data: {
|
data: {
|
||||||
eventKey: def.eventKey,
|
eventKey: def.eventKey,
|
||||||
@@ -197,6 +206,20 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
|
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const legacy = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey];
|
||||||
|
if (legacy && existing.body === legacy) {
|
||||||
|
await this.prisma.wecomPushTemplate.update({
|
||||||
|
where: { eventKey: def.eventKey },
|
||||||
|
data: {
|
||||||
|
title: def.title,
|
||||||
|
body: def.body,
|
||||||
|
handleLabel: def.handleLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`upgraded wecom push template from legacy default: ${def.eventKey}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +470,9 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
orderNo: 'DK202608200001',
|
orderNo: 'DK202608200001',
|
||||||
payAmount: '199.00',
|
payAmount: '199.00',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
skuSummary: '杜康原浆 ×2',
|
skuSummary: '杜康原浆 52度 瓶×2',
|
||||||
|
deliveryLabel: '同城',
|
||||||
|
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
|
||||||
phoneMasked: '138****8000',
|
phoneMasked: '138****8000',
|
||||||
},
|
},
|
||||||
handlePath: '/orders?orderNo=DK202608200001',
|
handlePath: '/orders?orderNo=DK202608200001',
|
||||||
@@ -458,6 +483,8 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
amount: '88.00',
|
amount: '88.00',
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
channel: '扫码',
|
channel: '扫码',
|
||||||
|
userName: '好客用户',
|
||||||
|
userPhone: '138****8000',
|
||||||
},
|
},
|
||||||
handlePath: '/redeem-records?redeemNo=RD202608200001',
|
handlePath: '/redeem-records?redeemNo=RD202608200001',
|
||||||
},
|
},
|
||||||
@@ -465,7 +492,8 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
partnerLabel: '示例合伙人',
|
submitter: '郑州某某商贸 · 张三',
|
||||||
|
partnerLabel: '郑州某某商贸 · 张三',
|
||||||
action: '新建',
|
action: '新建',
|
||||||
storeId: '1',
|
storeId: '1',
|
||||||
},
|
},
|
||||||
@@ -475,7 +503,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
submitter: '合伙人',
|
submitter: '郑州某某商贸 · 张三',
|
||||||
packageCount: '3',
|
packageCount: '3',
|
||||||
requestId: '1',
|
requestId: '1',
|
||||||
},
|
},
|
||||||
@@ -485,7 +513,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
submitter: '门店',
|
submitter: '门店 · 李四',
|
||||||
changedFields: '门店名称、详细地址',
|
changedFields: '门店名称、详细地址',
|
||||||
requestId: '1',
|
requestId: '1',
|
||||||
},
|
},
|
||||||
@@ -501,6 +529,16 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1',
|
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1',
|
||||||
},
|
},
|
||||||
|
'store.withdraw_approved': {
|
||||||
|
vars: {
|
||||||
|
storeName: '示例门店',
|
||||||
|
withdrawNo: 'SW202608200001',
|
||||||
|
amount: '500.00',
|
||||||
|
bankAccount: '张三 / 郑州支行 / 6222000011112222',
|
||||||
|
storeId: '1',
|
||||||
|
},
|
||||||
|
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PAID&storeId=1',
|
||||||
|
},
|
||||||
'invoice.pending': {
|
'invoice.pending': {
|
||||||
vars: {
|
vars: {
|
||||||
invoiceNo: 'IV202608200001',
|
invoiceNo: 'IV202608200001',
|
||||||
@@ -511,6 +549,39 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001',
|
handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001',
|
||||||
},
|
},
|
||||||
|
'finance.store_bill': {
|
||||||
|
vars: {
|
||||||
|
period: '2026-08-25',
|
||||||
|
billCount: '2',
|
||||||
|
billSummary: '示例门店 ¥300.00 张三 / 郑州支行 / 6222000011112222',
|
||||||
|
},
|
||||||
|
handlePath: '/finance/store-bills?kind=T1_BILL',
|
||||||
|
},
|
||||||
|
'finance.partner_bill': {
|
||||||
|
vars: {
|
||||||
|
period: '2026-07',
|
||||||
|
billCount: '1',
|
||||||
|
billSummary: '郑州某某商贸 ¥120.00 张三 / 郑州支行 / 6222000011112222',
|
||||||
|
},
|
||||||
|
handlePath: '/finance/partner-bills',
|
||||||
|
},
|
||||||
|
'finance.winery_bill': {
|
||||||
|
vars: {
|
||||||
|
period: '2026-08-23',
|
||||||
|
amount: '1990.00',
|
||||||
|
orderCount: '8',
|
||||||
|
bankAccount: '杜康酒厂 / 洛阳支行 / 6222000099998888',
|
||||||
|
},
|
||||||
|
handlePath: '/finance/winery-bills',
|
||||||
|
},
|
||||||
|
'finance.logistics_bill': {
|
||||||
|
vars: {
|
||||||
|
period: '2026-07',
|
||||||
|
billCount: '1',
|
||||||
|
billSummary: '小飞侠 ¥86.00 承运商 / 郑州支行 / 6222000033334444',
|
||||||
|
},
|
||||||
|
handlePath: '/finance/logistics-bills',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return samples[eventKey];
|
return samples[eventKey];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'实付:¥{{payAmount}}',
|
'实付:¥{{payAmount}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'商品:{{skuSummary}}',
|
'商品:{{skuSummary}}',
|
||||||
|
'物流:{{deliveryLabel}}',
|
||||||
|
'收货:{{receiverInfo}}',
|
||||||
'用户:{{phoneMasked}}',
|
'用户:{{phoneMasked}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -33,6 +35,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'金额:¥{{amount}}',
|
'金额:¥{{amount}}',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'渠道:{{channel}}',
|
'渠道:{{channel}}',
|
||||||
|
'用户:{{userName}} {{userPhone}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
@@ -45,6 +48,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'**门店审核待处理 · {{action}}**',
|
'**门店审核待处理 · {{action}}**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
|
'提交人:{{submitter}}',
|
||||||
'合伙人:{{partnerLabel}}',
|
'合伙人:{{partnerLabel}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -58,7 +62,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'**套餐变更待审核**',
|
'**套餐变更待审核**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'提交端:{{submitter}}',
|
'提交人:{{submitter}}',
|
||||||
'套餐条数:{{packageCount}}',
|
'套餐条数:{{packageCount}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -72,7 +76,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'**门店信息变更待审**',
|
'**门店信息变更待审**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'提交端:{{submitter}}',
|
'提交人:{{submitter}}',
|
||||||
'变更字段:{{changedFields}}',
|
'变更字段:{{changedFields}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -93,6 +97,20 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
handleLabel: '去处理',
|
handleLabel: '去处理',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'store.withdraw_approved',
|
||||||
|
title: '门店提现已通过',
|
||||||
|
body: [
|
||||||
|
'**门店提现已通过**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'提现单号:{{withdrawNo}}',
|
||||||
|
'金额:¥{{amount}}',
|
||||||
|
'收款账户:{{bankAccount}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
eventKey: 'invoice.pending',
|
eventKey: 'invoice.pending',
|
||||||
title: '发票申请待开票',
|
title: '发票申请待开票',
|
||||||
@@ -108,8 +126,110 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
handleLabel: '去处理',
|
handleLabel: '去处理',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'finance.store_bill',
|
||||||
|
title: '门店日账单已生成',
|
||||||
|
body: [
|
||||||
|
'**门店日账单已生成**',
|
||||||
|
'账期:{{period}}',
|
||||||
|
'笔数:{{billCount}}',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'finance.partner_bill',
|
||||||
|
title: '合伙人月账单已生成',
|
||||||
|
body: [
|
||||||
|
'**合伙人月账单已生成**',
|
||||||
|
'账期:{{period}}',
|
||||||
|
'笔数:{{billCount}}',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'finance.winery_bill',
|
||||||
|
title: '酒厂日账单已生成',
|
||||||
|
body: [
|
||||||
|
'**酒厂日账单已生成**',
|
||||||
|
'账期:{{period}}',
|
||||||
|
'订单数:{{orderCount}}',
|
||||||
|
'金额:¥{{amount}}',
|
||||||
|
'收款账户:{{bankAccount}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'finance.logistics_bill',
|
||||||
|
title: '物流月对账已生成',
|
||||||
|
body: [
|
||||||
|
'**物流月对账已生成**',
|
||||||
|
'账期:{{period}}',
|
||||||
|
'笔数:{{billCount}}',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** v3.5.3 默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */
|
||||||
|
export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEventKey, string>> = {
|
||||||
|
'order.paid': [
|
||||||
|
'**订单支付成功**',
|
||||||
|
'订单号:{{orderNo}}',
|
||||||
|
'实付:¥{{payAmount}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'商品:{{skuSummary}}',
|
||||||
|
'用户:{{phoneMasked}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
'redeem.success': [
|
||||||
|
'**门店核销成功**',
|
||||||
|
'核销单号:{{redeemNo}}',
|
||||||
|
'金额:¥{{amount}}',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'渠道:{{channel}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
'store.audit_pending': [
|
||||||
|
'**门店审核待处理 · {{action}}**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'合伙人:{{partnerLabel}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
'store.package_audit_pending': [
|
||||||
|
'**套餐变更待审核**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'提交端:{{submitter}}',
|
||||||
|
'套餐条数:{{packageCount}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
'store.info_change_pending': [
|
||||||
|
'**门店信息变更待审**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'提交端:{{submitter}}',
|
||||||
|
'变更字段:{{changedFields}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
|
export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
|
||||||
return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey);
|
return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class SettlementScheduler {
|
|||||||
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '酒厂日账单任务失败',
|
title: '酒厂日账单任务失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`,
|
||||||
@@ -41,7 +41,7 @@ export class SettlementScheduler {
|
|||||||
this.logger.error('Store bill job failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Store bill job failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '门店日账单任务失败',
|
title: '门店日账单任务失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`,
|
||||||
@@ -60,7 +60,7 @@ export class SettlementScheduler {
|
|||||||
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P1',
|
level: 'P1',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '门店提现超时扫描失败',
|
title: '门店提现超时扫描失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`,
|
||||||
@@ -79,7 +79,7 @@ export class SettlementScheduler {
|
|||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '合伙人月账单部分失败',
|
title: '合伙人月账单部分失败',
|
||||||
detail: `total=${result.total} success=${result.success} failed=${result.failed}`,
|
detail: `total=${result.total} success=${result.success} failed=${result.failed}`,
|
||||||
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
|
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
|
||||||
@@ -89,7 +89,7 @@ export class SettlementScheduler {
|
|||||||
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '合伙人月账单任务失败',
|
title: '合伙人月账单任务失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
|
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
|
||||||
@@ -103,7 +103,7 @@ export class SettlementScheduler {
|
|||||||
if (logistics.failed > 0) {
|
if (logistics.failed > 0) {
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '物流月对账部分失败',
|
title: '物流月对账部分失败',
|
||||||
detail: `total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
|
detail: `total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
|
||||||
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
|
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
|
||||||
@@ -113,7 +113,7 @@ export class SettlementScheduler {
|
|||||||
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'job',
|
category: 'settlement',
|
||||||
title: '物流月对账任务失败',
|
title: '物流月对账任务失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
|
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../../common/test-whitelist/test-whitelist.service';
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||||
import {
|
import {
|
||||||
|
catalogSpecPickerEnabled,
|
||||||
flattenSkuOntoProduct,
|
flattenSkuOntoProduct,
|
||||||
listMinOnSalePrice,
|
listMinOnSalePrice,
|
||||||
mapSkuDto,
|
mapSkuDto,
|
||||||
@@ -133,7 +134,7 @@ export class CatalogService {
|
|||||||
allowOnSitePickup: flat.allowOnSitePickup,
|
allowOnSitePickup: flat.allowOnSitePickup,
|
||||||
allowOnlinePurchase: flat.allowOnlinePurchase,
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
specEnabled: catalogSpecPickerEnabled(skus, specAttrs.length),
|
||||||
saleUnit: flat.saleUnit,
|
saleUnit: flat.saleUnit,
|
||||||
...media,
|
...media,
|
||||||
};
|
};
|
||||||
@@ -196,7 +197,7 @@ export class CatalogService {
|
|||||||
allowOnlinePurchase: flat.allowOnlinePurchase,
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
saleUnit: flat.saleUnit,
|
saleUnit: flat.saleUnit,
|
||||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
specEnabled: catalogSpecPickerEnabled(skus, specAttrs.length),
|
||||||
specAttrs: mapSpecAttrsDto(specAttrs),
|
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||||
skus: cSkus,
|
skus: cSkus,
|
||||||
defaultSkuId: defaultSku?.id.toString(),
|
defaultSkuId: defaultSku?.id.toString(),
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export function isSkuOnSale(sku: { status: string }): boolean {
|
|||||||
return sku.status === 'ON_SALE';
|
return sku.status === 'ON_SALE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C 端是否展示规格选择:可售 SKU 多于 1 且已配规格轴 */
|
||||||
|
export function catalogSpecPickerEnabled(
|
||||||
|
skus: Array<{ status: string }>,
|
||||||
|
specAttrCount: number,
|
||||||
|
): boolean {
|
||||||
|
return skus.filter(isSkuOnSale).length > 1 && specAttrCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSpecKey(valueIds: bigint[]): string {
|
export function buildSpecKey(valueIds: bigint[]): string {
|
||||||
if (!valueIds.length) return '';
|
if (!valueIds.length) return '';
|
||||||
return [...valueIds]
|
return [...valueIds]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
} from './dto/admin-mutate.dto';
|
} from './dto/admin-mutate.dto';
|
||||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard';
|
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard';
|
||||||
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
|
import { formatHqWecomLabel, formatPartnerWecomLabel } from '../store/wecom-submitter-label';
|
||||||
|
|
||||||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||||
function normalizeStoreOptionalText(value: unknown): string | null {
|
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||||
@@ -56,6 +58,7 @@ export class AdminStoresService {
|
|||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
private readonly testWhitelist: TestWhitelistService,
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
private readonly hqPermissions: HqPermissionsResolver,
|
private readonly hqPermissions: HqPermissionsResolver,
|
||||||
|
private readonly wecomPush: WecomMessagePushService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> {
|
private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> {
|
||||||
@@ -776,6 +779,23 @@ export class AdminStoresService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [submitter, partnerLabel] = await Promise.all([
|
||||||
|
formatHqWecomLabel(this.prisma, actorId),
|
||||||
|
formatPartnerWecomLabel(this.prisma, partnerAccountId),
|
||||||
|
]);
|
||||||
|
void this.wecomPush.dispatchEvent(
|
||||||
|
'store.audit_pending',
|
||||||
|
{
|
||||||
|
storeName: store.name,
|
||||||
|
cityName: store.cityName,
|
||||||
|
submitter,
|
||||||
|
partnerLabel,
|
||||||
|
action: '新建',
|
||||||
|
storeId: store.id.toString(),
|
||||||
|
},
|
||||||
|
{ handlePath: `/stores?storeId=${store.id.toString()}` },
|
||||||
|
);
|
||||||
|
|
||||||
return this.detailStore(store.id, actorId);
|
return this.detailStore(store.id, actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
generateRedeemNo,
|
generateRedeemNo,
|
||||||
generateRedeemPendingNo,
|
generateRedeemPendingNo,
|
||||||
|
maskContactPhone,
|
||||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
resolveSettlementRate,
|
resolveSettlementRate,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
@@ -194,7 +195,10 @@ export class RedeemService {
|
|||||||
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||||
|
|
||||||
const [userRow, storeRow] = await Promise.all([
|
const [userRow, storeRow] = await Promise.all([
|
||||||
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
|
this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isTest: true, nickname: true, phone: true },
|
||||||
|
}),
|
||||||
this.prisma.store.findUnique({
|
this.prisma.store.findUnique({
|
||||||
where: { id: account.storeId },
|
where: { id: account.storeId },
|
||||||
select: { isTest: true },
|
select: { isTest: true },
|
||||||
@@ -290,6 +294,8 @@ export class RedeemService {
|
|||||||
amount: amountNum.toFixed(2),
|
amount: amountNum.toFixed(2),
|
||||||
storeName: account.store.name || String(account.storeId),
|
storeName: account.store.name || String(account.storeId),
|
||||||
channel: channelLabel,
|
channel: channelLabel,
|
||||||
|
userName: userRow?.nickname?.trim() || '—',
|
||||||
|
userPhone: maskContactPhone(userRow?.phone),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
|
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
loadStorePrimaryBank,
|
loadStorePrimaryBank,
|
||||||
loadStorePrimaryBanksMap,
|
loadStorePrimaryBanksMap,
|
||||||
loadWineryBankConfig,
|
loadWineryBankConfig,
|
||||||
|
formatWecomBankAccount,
|
||||||
} from '../../common/store/store-bank.util';
|
} from '../../common/store/store-bank.util';
|
||||||
|
|
||||||
function generateBillNo(prefix: string) {
|
function generateBillNo(prefix: string) {
|
||||||
@@ -62,6 +63,14 @@ function round2(n: number) {
|
|||||||
return Math.round(n * 100) / 100;
|
return Math.round(n * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BILL_DIGEST_MAX_LINES = 15;
|
||||||
|
|
||||||
|
function capBillSummary(lines: string[]): string {
|
||||||
|
if (!lines.length) return '无';
|
||||||
|
if (lines.length <= BILL_DIGEST_MAX_LINES) return lines.join('\n');
|
||||||
|
return `${lines.slice(0, BILL_DIGEST_MAX_LINES).join('\n')}\n…等共 ${lines.length} 笔`;
|
||||||
|
}
|
||||||
|
|
||||||
function getStoreWithdrawDailyLimit(): number {
|
function getStoreWithdrawDailyLimit(): number {
|
||||||
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
|
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
|
||||||
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
|
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
|
||||||
@@ -95,6 +104,59 @@ export class SettlementService {
|
|||||||
private readonly wecomPush: WecomMessagePushService,
|
private readonly wecomPush: WecomMessagePushService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private notifyPartnerBillDigest(
|
||||||
|
period: string,
|
||||||
|
rows: Array<{
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
if (!rows.length) return;
|
||||||
|
const lines = rows.map(
|
||||||
|
(r) =>
|
||||||
|
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
|
||||||
|
);
|
||||||
|
void this.wecomPush.dispatchEvent(
|
||||||
|
'finance.partner_bill',
|
||||||
|
{
|
||||||
|
period,
|
||||||
|
billCount: String(rows.length),
|
||||||
|
billSummary: capBillSummary(lines),
|
||||||
|
},
|
||||||
|
{ handlePath: '/finance/partner-bills' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyLogisticsBillDigest(
|
||||||
|
period: string,
|
||||||
|
rows: Array<{
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
if (!rows.length) return;
|
||||||
|
const lines = rows.map(
|
||||||
|
(r) =>
|
||||||
|
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
|
||||||
|
);
|
||||||
|
void this.wecomPush.dispatchEvent(
|
||||||
|
'finance.logistics_bill',
|
||||||
|
{
|
||||||
|
period,
|
||||||
|
billCount: String(rows.length),
|
||||||
|
billSummary: capBillSummary(lines),
|
||||||
|
},
|
||||||
|
{ handlePath: '/finance/logistics-bills' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Store payout (line) ─────────────────────────────
|
// ─── Store payout (line) ─────────────────────────────
|
||||||
|
|
||||||
async createStorePayout(
|
async createStorePayout(
|
||||||
@@ -584,6 +646,27 @@ export class SettlementService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [store, bank] = await Promise.all([
|
||||||
|
this.prisma.store.findUnique({
|
||||||
|
where: { id: row.storeId },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
loadStorePrimaryBank(this.prisma, row.storeId),
|
||||||
|
]);
|
||||||
|
void this.wecomPush.dispatchEvent(
|
||||||
|
'store.withdraw_approved',
|
||||||
|
{
|
||||||
|
storeName: store?.name || String(row.storeId),
|
||||||
|
withdrawNo: row.withdrawNo,
|
||||||
|
amount: Number(row.amount).toFixed(2),
|
||||||
|
bankAccount: formatWecomBankAccount(bank),
|
||||||
|
storeId: row.storeId.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
handlePath: `/finance/store-bills?kind=WITHDRAW&status=PAID&storeId=${row.storeId.toString()}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return serializeBigInt(updated);
|
return serializeBigInt(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +734,7 @@ export class SettlementService {
|
|||||||
);
|
);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P1',
|
level: 'P1',
|
||||||
category: 'finance',
|
category: 'settlement',
|
||||||
title: '门店提现超时未审',
|
title: '门店提现超时未审',
|
||||||
detail: [
|
detail: [
|
||||||
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
||||||
@@ -847,7 +930,7 @@ export class SettlementService {
|
|||||||
withdrawItem: { is: null },
|
withdrawItem: { is: null },
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
},
|
},
|
||||||
include: { store: { select: { id: true, settlementRate: true } } },
|
include: { store: { select: { id: true, name: true, settlementRate: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const byStore = new Map<string, typeof payouts>();
|
const byStore = new Map<string, typeof payouts>();
|
||||||
@@ -860,8 +943,10 @@ export class SettlementService {
|
|||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = [];
|
||||||
for (const [storeIdStr, list] of byStore) {
|
for (const [storeIdStr, list] of byStore) {
|
||||||
const storeId = BigInt(storeIdStr);
|
const storeId = BigInt(storeIdStr);
|
||||||
|
const storeName = list[0]?.store?.name || storeIdStr;
|
||||||
const existing = await this.prisma.storeBill.findUnique({
|
const existing = await this.prisma.storeBill.findUnique({
|
||||||
where: { storeId_billDate: { storeId, billDate } },
|
where: { storeId_billDate: { storeId, billDate } },
|
||||||
});
|
});
|
||||||
@@ -873,13 +958,14 @@ export class SettlementService {
|
|||||||
const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0));
|
const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0));
|
||||||
const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0));
|
const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0));
|
||||||
const rate = Number(list[0].settlementRate);
|
const rate = Number(list[0].settlementRate);
|
||||||
|
const updatedTotal = round2(Number(existing.payoutAmount) + payoutAmount);
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.storeBill.update({
|
await tx.storeBill.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
redeemCount: existing.redeemCount + list.length,
|
redeemCount: existing.redeemCount + list.length,
|
||||||
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
|
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
|
||||||
payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount),
|
payoutAmount: updatedTotal,
|
||||||
settlementRate: rate,
|
settlementRate: rate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -889,6 +975,7 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
|
createdBills.push({ storeId, storeName, amount: updatedTotal });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,9 +1001,31 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
|
createdBills.push({ storeId, storeName, amount: payoutAmount });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
|
const period = billDate.toISOString().slice(0, 10);
|
||||||
|
if (createdBills.length) {
|
||||||
|
const banks = await loadStorePrimaryBanksMap(
|
||||||
|
this.prisma,
|
||||||
|
createdBills.map((b) => b.storeId),
|
||||||
|
);
|
||||||
|
const lines = createdBills.map(
|
||||||
|
(b) =>
|
||||||
|
`${b.storeName} ¥${b.amount.toFixed(2)} ${formatWecomBankAccount(banks.get(b.storeId.toString()) ?? null)}`,
|
||||||
|
);
|
||||||
|
void this.wecomPush.dispatchEvent(
|
||||||
|
'finance.store_bill',
|
||||||
|
{
|
||||||
|
period,
|
||||||
|
billCount: String(createdBills.length),
|
||||||
|
billSummary: capBillSummary(lines),
|
||||||
|
},
|
||||||
|
{ handlePath: '/finance/store-bills?kind=T1_BILL' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { billDate: period, created, skipped, payoutLinked: payouts.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1360,17 +1469,43 @@ export class SettlementService {
|
|||||||
async generateAllPartnerBills(body: { year: number; month: number }) {
|
async generateAllPartnerBills(body: { year: number; month: number }) {
|
||||||
const partners = await this.prisma.partnerAccount.findMany({
|
const partners = await this.prisma.partnerAccount.findMany({
|
||||||
where: { isPrimary: 1, status: 'ACTIVE' },
|
where: { isPrimary: 1, status: 'ACTIVE' },
|
||||||
select: { id: true, companyName: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true,
|
||||||
|
name: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
bankBranch: true,
|
||||||
|
bankAccountNo: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
|
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
|
||||||
|
const created: Array<{
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
bankAccountName: string | null;
|
||||||
|
bankBranch: string | null;
|
||||||
|
bankAccountNo: string | null;
|
||||||
|
}> = [];
|
||||||
for (const p of partners) {
|
for (const p of partners) {
|
||||||
try {
|
try {
|
||||||
await this.generatePartnerBill({
|
const bill = await this.generatePartnerBill(
|
||||||
|
{
|
||||||
partnerId: p.id.toString(),
|
partnerId: p.id.toString(),
|
||||||
year: body.year,
|
year: body.year,
|
||||||
month: body.month,
|
month: body.month,
|
||||||
});
|
},
|
||||||
|
{ notify: false },
|
||||||
|
);
|
||||||
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
|
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
|
||||||
|
const label =
|
||||||
|
[p.companyName?.trim(), p.name?.trim()].filter(Boolean).join(' · ') || p.id.toString();
|
||||||
|
created.push({
|
||||||
|
label,
|
||||||
|
amount: Number(bill.totalAmount),
|
||||||
|
bankAccountName: p.bankAccountName,
|
||||||
|
bankBranch: p.bankBranch,
|
||||||
|
bankAccountNo: p.bankAccountNo,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.push({
|
results.push({
|
||||||
partnerId: p.id.toString(),
|
partnerId: p.id.toString(),
|
||||||
@@ -1380,6 +1515,10 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.notifyPartnerBillDigest(
|
||||||
|
`${body.year}-${String(body.month).padStart(2, '0')}`,
|
||||||
|
created,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
total: partners.length,
|
total: partners.length,
|
||||||
success: results.filter((r) => r.ok).length,
|
success: results.filter((r) => r.ok).length,
|
||||||
@@ -1403,7 +1542,10 @@ export class SettlementService {
|
|||||||
return serializeBigInt(bill);
|
return serializeBigInt(bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
async generatePartnerBill(
|
||||||
|
body: { partnerId: string; year: number; month: number },
|
||||||
|
opts?: { notify?: boolean },
|
||||||
|
) {
|
||||||
const partnerAccountId = BigInt(body.partnerId);
|
const partnerAccountId = BigInt(body.partnerId);
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
const periodStart = new Date(body.year, body.month - 1, 1);
|
||||||
@@ -1485,6 +1627,24 @@ export class SettlementService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts?.notify !== false) {
|
||||||
|
const label =
|
||||||
|
[primary.companyName?.trim(), primary.name?.trim()].filter(Boolean).join(' · ') ||
|
||||||
|
primary.id.toString();
|
||||||
|
this.notifyPartnerBillDigest(
|
||||||
|
`${body.year}-${String(body.month).padStart(2, '0')}`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
amount: totalAmount,
|
||||||
|
bankAccountName: primary.bankAccountName,
|
||||||
|
bankBranch: primary.bankBranch,
|
||||||
|
bankAccountNo: primary.bankAccountNo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return serializeBigInt(bill);
|
return serializeBigInt(bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1775,8 +1935,21 @@ export class SettlementService {
|
|||||||
return header;
|
return header;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const period = billDate.toISOString().slice(0, 10);
|
||||||
|
const wineryBank = await loadWineryBankConfig(this.prisma);
|
||||||
|
void this.wecomPush.dispatchEvent(
|
||||||
|
'finance.winery_bill',
|
||||||
|
{
|
||||||
|
period,
|
||||||
|
amount: wineryAmount.toFixed(2),
|
||||||
|
orderCount: String(orders.length),
|
||||||
|
bankAccount: formatWecomBankAccount(wineryBank),
|
||||||
|
},
|
||||||
|
{ handlePath: '/finance/winery-bills' },
|
||||||
|
);
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
billDate: billDate.toISOString().slice(0, 10),
|
billDate: period,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
bill,
|
bill,
|
||||||
});
|
});
|
||||||
@@ -1997,14 +2170,25 @@ export class SettlementService {
|
|||||||
message?: string;
|
message?: string;
|
||||||
billId?: string;
|
billId?: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
const created: Array<{
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
bankAccountName: string | null;
|
||||||
|
bankName: string | null;
|
||||||
|
bankBranch: string | null;
|
||||||
|
bankAccountNo: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
try {
|
try {
|
||||||
const bill = await this.generateLogisticsBill({
|
const bill = await this.generateLogisticsBill(
|
||||||
|
{
|
||||||
providerId: p.id.toString(),
|
providerId: p.id.toString(),
|
||||||
year: body.year,
|
year: body.year,
|
||||||
month: body.month,
|
month: body.month,
|
||||||
});
|
},
|
||||||
|
{ notify: false },
|
||||||
|
);
|
||||||
results.push({
|
results.push({
|
||||||
providerId: p.id.toString(),
|
providerId: p.id.toString(),
|
||||||
providerCode: p.code,
|
providerCode: p.code,
|
||||||
@@ -2019,6 +2203,16 @@ export class SettlementService {
|
|||||||
: String(bill.bill.id)
|
: String(bill.bill.id)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
if (!bill.skipped && bill.bill) {
|
||||||
|
created.push({
|
||||||
|
label: p.name,
|
||||||
|
amount: Number(bill.bill.logisticsAmount ?? 0),
|
||||||
|
bankAccountName: p.bankAccountName,
|
||||||
|
bankName: p.bankName,
|
||||||
|
bankBranch: p.bankBranch,
|
||||||
|
bankAccountNo: p.bankAccountNo,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.push({
|
results.push({
|
||||||
providerId: p.id.toString(),
|
providerId: p.id.toString(),
|
||||||
@@ -2030,6 +2224,11 @@ export class SettlementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.notifyLogisticsBillDigest(
|
||||||
|
`${body.year}-${String(body.month).padStart(2, '0')}`,
|
||||||
|
created,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: providers.length,
|
total: providers.length,
|
||||||
success: results.filter((r) => r.ok).length,
|
success: results.filter((r) => r.ok).length,
|
||||||
@@ -2038,7 +2237,10 @@ export class SettlementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateLogisticsBill(body: { providerId: string; year: number; month: number }) {
|
async generateLogisticsBill(
|
||||||
|
body: { providerId: string; year: number; month: number },
|
||||||
|
opts?: { notify?: boolean },
|
||||||
|
) {
|
||||||
const fulfillmentProviderId = BigInt(body.providerId);
|
const fulfillmentProviderId = BigInt(body.providerId);
|
||||||
const provider = await this.prisma.fulfillmentProvider.findUnique({
|
const provider = await this.prisma.fulfillmentProvider.findUnique({
|
||||||
where: { id: fulfillmentProviderId },
|
where: { id: fulfillmentProviderId },
|
||||||
@@ -2190,6 +2392,22 @@ export class SettlementService {
|
|||||||
return header;
|
return header;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts?.notify !== false) {
|
||||||
|
this.notifyLogisticsBillDigest(
|
||||||
|
`${body.year}-${String(body.month).padStart(2, '0')}`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: provider.name,
|
||||||
|
amount: Number(bill.logisticsAmount),
|
||||||
|
bankAccountName: provider.bankAccountName,
|
||||||
|
bankName: provider.bankName,
|
||||||
|
bankBranch: provider.bankBranch,
|
||||||
|
bankAccountNo: provider.bankAccountNo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skipped: false,
|
skipped: false,
|
||||||
bill: serializeBigInt(bill),
|
bill: serializeBigInt(bill),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
|
import { formatHqWecomLabel, formatPartnerWecomLabel, formatShopWecomLabel } from './wecom-submitter-label';
|
||||||
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
||||||
|
|
||||||
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
||||||
@@ -335,13 +336,18 @@ export class StoreInfoChangeService {
|
|||||||
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
|
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitterLabel = input.submitterType === 'PARTNER' ? '合伙人' : '门店';
|
const submitter =
|
||||||
|
input.submitterType === 'PARTNER'
|
||||||
|
? await formatPartnerWecomLabel(this.prisma, input.submitterId)
|
||||||
|
: input.submitterType === 'HQ_DIRECT_ADMIN'
|
||||||
|
? await formatHqWecomLabel(this.prisma, input.submitterId)
|
||||||
|
: await formatShopWecomLabel(this.prisma, input.submitterId);
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'store.info_change_pending',
|
'store.info_change_pending',
|
||||||
{
|
{
|
||||||
storeName: String(store.name ?? input.storeId),
|
storeName: String(store.name ?? input.storeId),
|
||||||
cityName: String(store.cityName ?? '—'),
|
cityName: String(store.cityName ?? '—'),
|
||||||
submitter: submitterLabel,
|
submitter,
|
||||||
changedFields: formatStoreInfoChangeFieldLabels(changedFields),
|
changedFields: formatStoreInfoChangeFieldLabels(changedFields),
|
||||||
requestId: created.id.toString(),
|
requestId: created.id.toString(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
|
import { formatPartnerWecomLabel, formatShopWecomLabel } from './wecom-submitter-label';
|
||||||
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
||||||
|
|
||||||
type PackageInput = Record<string, unknown>;
|
type PackageInput = Record<string, unknown>;
|
||||||
@@ -193,13 +194,16 @@ export class StorePackageService {
|
|||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
select: { name: true, cityName: true },
|
select: { name: true, cityName: true },
|
||||||
});
|
});
|
||||||
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
|
const submitter =
|
||||||
|
submitterType === 'PARTNER'
|
||||||
|
? await formatPartnerWecomLabel(this.prisma, submitterId)
|
||||||
|
: await formatShopWecomLabel(this.prisma, submitterId);
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'store.package_audit_pending',
|
'store.package_audit_pending',
|
||||||
{
|
{
|
||||||
storeName: store?.name ?? String(storeId),
|
storeName: store?.name ?? String(storeId),
|
||||||
cityName: store?.cityName || '—',
|
cityName: store?.cityName || '—',
|
||||||
submitter: submitterLabel,
|
submitter,
|
||||||
packageCount: String(packages.length),
|
packageCount: String(packages.length),
|
||||||
requestId: req.id.toString(),
|
requestId: req.id.toString(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
normalizeTestPhone,
|
normalizeTestPhone,
|
||||||
} from '../../common/test-whitelist/test-whitelist.service';
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
|
import { formatPartnerWecomLabel } from './wecom-submitter-label';
|
||||||
|
|
||||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
@@ -76,25 +77,29 @@ export class StoreService {
|
|||||||
private readonly wecomPush: WecomMessagePushService,
|
private readonly wecomPush: WecomMessagePushService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
|
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
|
||||||
private notifyStoreAuditPending(opts: {
|
private notifyStoreAuditPending(opts: {
|
||||||
storeId: bigint;
|
storeId: bigint;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
cityName?: string | null;
|
cityName?: string | null;
|
||||||
partnerLabel?: string | null;
|
partnerLabel?: string | null;
|
||||||
|
submitter?: string | null;
|
||||||
submitType: '新建' | '重提';
|
submitType: '新建' | '重提';
|
||||||
|
handlePath?: string;
|
||||||
}) {
|
}) {
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'store.audit_pending',
|
'store.audit_pending',
|
||||||
{
|
{
|
||||||
storeName: opts.storeName,
|
storeName: opts.storeName,
|
||||||
cityName: opts.cityName || '—',
|
cityName: opts.cityName || '—',
|
||||||
|
submitter: opts.submitter || opts.partnerLabel || '—',
|
||||||
partnerLabel: opts.partnerLabel || '—',
|
partnerLabel: opts.partnerLabel || '—',
|
||||||
action: opts.submitType,
|
action: opts.submitType,
|
||||||
storeId: opts.storeId.toString(),
|
storeId: opts.storeId.toString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
handlePath: `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
|
handlePath:
|
||||||
|
opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -587,15 +592,16 @@ export class StoreService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.config.autoApproveStore) {
|
if (!this.config.autoApproveStore) {
|
||||||
const partner = await this.prisma.partnerAccount.findUnique({
|
const [submitter, partnerLabel] = await Promise.all([
|
||||||
where: { id: primaryId },
|
formatPartnerWecomLabel(this.prisma, partnerAccountId),
|
||||||
select: { name: true, phone: true },
|
formatPartnerWecomLabel(this.prisma, primaryId),
|
||||||
});
|
]);
|
||||||
this.notifyStoreAuditPending({
|
this.notifyStoreAuditPending({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
storeName: store.name,
|
storeName: store.name,
|
||||||
cityName: store.cityName,
|
cityName: store.cityName,
|
||||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
submitter,
|
||||||
|
partnerLabel,
|
||||||
submitType: '新建',
|
submitType: '新建',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -757,15 +763,16 @@ export class StoreService {
|
|||||||
remark: '合伙人修改资料后重新提交审核',
|
remark: '合伙人修改资料后重新提交审核',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const partner = await this.prisma.partnerAccount.findUnique({
|
const [submitter, partnerLabel] = await Promise.all([
|
||||||
where: { id: primaryId },
|
formatPartnerWecomLabel(this.prisma, partnerAccountId),
|
||||||
select: { name: true, phone: true },
|
formatPartnerWecomLabel(this.prisma, primaryId),
|
||||||
});
|
]);
|
||||||
this.notifyStoreAuditPending({
|
this.notifyStoreAuditPending({
|
||||||
storeId: updated.id,
|
storeId: updated.id,
|
||||||
storeName: updated.name,
|
storeName: updated.name,
|
||||||
cityName: updated.cityName,
|
cityName: updated.cityName,
|
||||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
submitter,
|
||||||
|
partnerLabel,
|
||||||
submitType: '重提',
|
submitType: '重提',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -882,15 +889,16 @@ export class StoreService {
|
|||||||
remark: '合伙人重新上传资料后重新提交审核',
|
remark: '合伙人重新上传资料后重新提交审核',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const partner = await this.prisma.partnerAccount.findUnique({
|
const [submitter, partnerLabel] = await Promise.all([
|
||||||
where: { id: primaryId },
|
formatPartnerWecomLabel(this.prisma, partnerAccountId),
|
||||||
select: { name: true, phone: true },
|
formatPartnerWecomLabel(this.prisma, primaryId),
|
||||||
});
|
]);
|
||||||
this.notifyStoreAuditPending({
|
this.notifyStoreAuditPending({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
storeName: store.name,
|
storeName: store.name,
|
||||||
cityName: store.cityName,
|
cityName: store.cityName,
|
||||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
submitter,
|
||||||
|
partnerLabel,
|
||||||
submitType: '重提',
|
submitType: '重提',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export function joinCompanyAndName(
|
||||||
|
company?: string | null,
|
||||||
|
name?: string | null,
|
||||||
|
fallback = '—',
|
||||||
|
): string {
|
||||||
|
const person = (name || '').trim() || fallback;
|
||||||
|
const org = (company || '').trim();
|
||||||
|
return org ? `${org} · ${person}` : person;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatPartnerWecomLabel(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
accountId: bigint,
|
||||||
|
): Promise<string> {
|
||||||
|
const acc = await prisma.partnerAccount.findUnique({
|
||||||
|
where: { id: accountId },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
companyName: true,
|
||||||
|
isPrimary: true,
|
||||||
|
parentAccountId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!acc) return '—';
|
||||||
|
let company = acc.companyName?.trim() || '';
|
||||||
|
if (!company && acc.isPrimary !== 1 && acc.parentAccountId) {
|
||||||
|
const primary = await prisma.partnerAccount.findUnique({
|
||||||
|
where: { id: acc.parentAccountId },
|
||||||
|
select: { companyName: true },
|
||||||
|
});
|
||||||
|
company = primary?.companyName?.trim() || '';
|
||||||
|
}
|
||||||
|
const name = acc.name?.trim() || acc.phone || String(accountId);
|
||||||
|
return joinCompanyAndName(company, name, String(accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatHqWecomLabel(prisma: PrismaClient, hqAccountId: bigint): Promise<string> {
|
||||||
|
const hq = await prisma.hqAccount.findUnique({
|
||||||
|
where: { id: hqAccountId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return hq?.name?.trim() || 'HQ';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatShopWecomLabel(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
storeAccountId: bigint,
|
||||||
|
): Promise<string> {
|
||||||
|
const acc = await prisma.storeAccount.findUnique({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
select: { name: true, phone: true },
|
||||||
|
});
|
||||||
|
const name = acc?.name?.trim() || acc?.phone || String(storeAccountId);
|
||||||
|
return `门店 · ${name}`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import type { FreightPayType } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
calcBenefitAmount,
|
calcBenefitAmount,
|
||||||
generateOrderNo,
|
generateOrderNo,
|
||||||
|
maskContactPhone,
|
||||||
|
minBottleQtyForDelivery,
|
||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
toMinSaleQuantity,
|
toMinSaleQuantity,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
@@ -157,11 +159,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 +179,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 {
|
||||||
@@ -501,10 +502,22 @@ export class TradeService {
|
|||||||
if (!order) return;
|
if (!order) return;
|
||||||
|
|
||||||
if (!order.isTest) {
|
if (!order.isTest) {
|
||||||
const skuSummary = `${order.productName}×${order.quantity}`;
|
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const phone = order.user?.phone || '';
|
const spec = (order.productSpec || '').trim();
|
||||||
const phoneMasked =
|
const skuSummary = spec
|
||||||
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—';
|
? `${order.productName} ${spec} ${unit}×${order.quantity}`
|
||||||
|
: `${order.productName} ${unit}×${order.quantity}`;
|
||||||
|
const deliveryLabel =
|
||||||
|
order.deliveryType === 'ON_SITE_PICKUP'
|
||||||
|
? '现场提货'
|
||||||
|
: order.deliveryType === 'CROSS_CITY'
|
||||||
|
? '跨城'
|
||||||
|
: '同城';
|
||||||
|
const receiverInfo =
|
||||||
|
order.deliveryType === 'ON_SITE_PICKUP'
|
||||||
|
? `${order.receiverName} ${order.receiverPhone}`.trim()
|
||||||
|
: `${order.receiverName} ${order.receiverPhone} ${order.receiverAddress}`.trim();
|
||||||
|
const phoneMasked = maskContactPhone(order.user?.phone || order.receiverPhone);
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'order.paid',
|
'order.paid',
|
||||||
{
|
{
|
||||||
@@ -512,6 +525,8 @@ export class TradeService {
|
|||||||
payAmount: Number(order.payAmount).toFixed(2),
|
payAmount: Number(order.payAmount).toFixed(2),
|
||||||
cityName: order.city?.name || '—',
|
cityName: order.city?.name || '—',
|
||||||
skuSummary,
|
skuSummary,
|
||||||
|
deliveryLabel,
|
||||||
|
receiverInfo,
|
||||||
phoneMasked,
|
phoneMasked,
|
||||||
},
|
},
|
||||||
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
|
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
|
||||||
@@ -1666,11 +1681,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 +1714,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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user