Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2ebbfef4 | |||
| f2fbf95c53 | |||
| 43de361e61 | |||
| 0b548a2764 | |||
| 40e8a12596 | |||
| 3d819dc10b | |||
| 8984465893 | |||
| 3f208d27a9 | |||
| fa69ced448 | |||
| 18c5abea79 | |||
| 5d4ed566de | |||
| 5fdddae41c | |||
| 22c0b03a47 | |||
| f2d03e2595 | |||
| 0c28c204b2 | |||
| e7f49a9639 | |||
| f941dcf072 | |||
| 3382d36a6c | |||
| 9b8e3f1347 | |||
| 1440a59fc8 | |||
| 11659484c6 | |||
| 0fb7ab7abb |
@@ -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 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
|
同城起购 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 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||||
| REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
|
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
|
||||||
| REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
|
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
|
||||||
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
||||||
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
||||||
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
||||||
|
|||||||
@@ -26,4 +26,3 @@ server/dukang-api/assets/fonts/*.ttc
|
|||||||
server/dukang-api/assets/fonts/*.otf
|
server/dukang-api/assets/fonts/*.otf
|
||||||
deploy/auto-release.env
|
deploy/auto-release.env
|
||||||
deploy/backups/
|
deploy/backups/
|
||||||
.playwright-mcp/
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
权益额 = benefit_amount ?? price
|
权益额 = benefit_amount ?? price
|
||||||
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
同城起购 2 瓶 / 跨城 6 瓶
|
||||||
核销:直接核销 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 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
同城起购 2 瓶 / 跨城 6 瓶
|
||||||
核销:直接核销 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
|
||||||
|
|||||||
@@ -308,40 +308,6 @@ body:has(.big-screen-page),
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-screen-bgm {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-right: 8px;
|
|
||||||
padding: 6px 14px 6px 12px;
|
|
||||||
border: 1px solid rgba(105, 192, 255, 0.45);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(8, 28, 56, 0.7);
|
|
||||||
color: rgba(230, 244, 255, 0.92);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-bgm:hover {
|
|
||||||
border-color: rgba(105, 192, 255, 0.85);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-bgm.is-on {
|
|
||||||
border-color: rgba(64, 169, 255, 0.85);
|
|
||||||
box-shadow: 0 0 12px rgba(64, 169, 255, 0.28);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-bgm-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-live-label {
|
.big-screen-live-label {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: rgba(230, 244, 255, 0.88);
|
color: rgba(230, 244, 255, 0.88);
|
||||||
|
|||||||
Binary file not shown.
@@ -11,23 +11,10 @@ import PackageImagesUpload from './PackageImagesUpload';
|
|||||||
type PackageRow = StorePackageItemDto;
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
export type AdminStorePackagesHandle = {
|
export type AdminStorePackagesHandle = {
|
||||||
/** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
|
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
||||||
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] {
|
|
||||||
return (live ?? []).map((p, i) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyRow(index = 0): PackageRow {
|
function emptyRow(index = 0): PackageRow {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -50,7 +37,6 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
const itemsRef = useRef(items);
|
const itemsRef = useRef(items);
|
||||||
const loadingRef = useRef(loading);
|
const loadingRef = useRef(loading);
|
||||||
const dirtyRef = useRef(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,33 +47,36 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
loadingRef.current = loading;
|
loadingRef.current = loading;
|
||||||
}, [loading]);
|
}, [loading]);
|
||||||
|
|
||||||
function applyServerPackages(data: StorePackagesResponse) {
|
|
||||||
dirtyRef.current = false;
|
|
||||||
setPendingRequest(data.pendingRequest ?? null);
|
|
||||||
setItems(mapLiveRows(data.live));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setPendingRequest(null);
|
setPendingRequest(null);
|
||||||
dirtyRef.current = false;
|
|
||||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
.then((data) => applyServerPackages(data))
|
.then((data) => {
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
setItems(
|
||||||
|
data.live?.length
|
||||||
|
? data.live.map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
})
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [storeId]);
|
}, [storeId]);
|
||||||
|
|
||||||
// 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
|
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = () => {
|
const onChanged = () => {
|
||||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
.then((data) => {
|
.then((data) => setPendingRequest(data.pendingRequest ?? null))
|
||||||
setPendingRequest(data.pendingRequest ?? null);
|
|
||||||
if (!dirtyRef.current) {
|
|
||||||
dirtyRef.current = false;
|
|
||||||
setItems(mapLiveRows(data.live));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
@@ -119,19 +108,16 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||||
dirtyRef.current = true;
|
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRow() {
|
function addRow() {
|
||||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
dirtyRef.current = true;
|
|
||||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
const run = () => {
|
const run = () => {
|
||||||
dirtyRef.current = true;
|
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||||
return next.length ? next : [];
|
return next.length ? next : [];
|
||||||
@@ -217,8 +203,20 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!opts?.quiet) message.success('套餐已保存并生效');
|
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||||
dirtyRef.current = false;
|
setItems(
|
||||||
setItems(mapLiveRows(data.live));
|
data.live?.length
|
||||||
|
? data.live.map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
throw e;
|
throw e;
|
||||||
@@ -229,7 +227,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
saveIfLoaded: async (opts) => {
|
saveIfLoaded: async (opts) => {
|
||||||
if (loadingRef.current || !dirtyRef.current) return { skipped: true };
|
if (loadingRef.current) return { skipped: true };
|
||||||
await save(opts);
|
await save(opts);
|
||||||
return { skipped: false };
|
return { skipped: false };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -173,9 +173,7 @@ export default function OrderTrackDrawer({
|
|||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={
|
description={
|
||||||
track?.queryError
|
track?.manualQueryUrl
|
||||||
? track.queryError
|
|
||||||
: track?.manualQueryUrl
|
|
||||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||||
: '暂无路由信息,请稍后刷新'
|
: '暂无路由信息,请稍后刷新'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { 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,38 +50,6 @@ function keyOf(ids: string[]) {
|
|||||||
return [...ids].sort().join('_');
|
return [...ids].sort().join('_');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
|
|
||||||
if (!initialSkus.length) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
specValueIds: [],
|
|
||||||
barcode69: '',
|
|
||||||
price: 0,
|
|
||||||
status: 'ON_SALE',
|
|
||||||
allowOnlinePurchase: true,
|
|
||||||
allowCrossCityDelivery: true,
|
|
||||||
allowOnSitePickup: false,
|
|
||||||
saleUnit: 'BOTTLE',
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
isDefault: true,
|
|
||||||
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 = {
|
type Props = {
|
||||||
productId: string;
|
productId: string;
|
||||||
initialAttrs: SpecAttr[];
|
initialAttrs: SpecAttr[];
|
||||||
@@ -90,19 +58,44 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
|
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||||
const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
|
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: [],
|
||||||
|
barcode69: '',
|
||||||
|
price: 0,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
imageUrl: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
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() {
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ import {
|
|||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
ProjectOutlined,
|
ProjectOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
|
||||||
hasAnySystemSettingsPermission,
|
|
||||||
hasAnyStoreMenuPermission,
|
|
||||||
HQ_ADMIN_ROLES,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
@@ -32,15 +28,12 @@ import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
|||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
|
||||||
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
|
||||||
);
|
|
||||||
|
|
||||||
type MenuItem = NonNullable<MenuProps['items']>[number];
|
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||||
|
|
||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
|
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||||
{
|
{
|
||||||
key: 'products-group',
|
key: 'products-group',
|
||||||
icon: <ShoppingOutlined />,
|
icon: <ShoppingOutlined />,
|
||||||
@@ -51,17 +44,18 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
|
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||||
{
|
{
|
||||||
key: 'partners-group',
|
key: 'wecom-group',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
label: '开城',
|
label: '企微机器人',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/cities', label: '城市' },
|
{ key: '/wecom/bots', label: '智能机器人' },
|
||||||
{ key: '/city-partners', label: '城市合伙人' },
|
{ key: '/wecom/pushes', label: '消息推送' },
|
||||||
{ key: '/city-warehouses', label: '仓库' },
|
|
||||||
{ key: '/fulfillment-providers', label: '仓配管理' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||||
|
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||||
{
|
{
|
||||||
key: 'stores-group',
|
key: 'stores-group',
|
||||||
icon: <ShopOutlined />,
|
icon: <ShopOutlined />,
|
||||||
@@ -75,6 +69,17 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/store-media', label: '门店资源' },
|
{ key: '/store-media', label: '门店资源' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'partners-group',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '开城',
|
||||||
|
children: [
|
||||||
|
{ key: '/cities', label: '城市' },
|
||||||
|
{ key: '/city-partners', label: '城市合伙人' },
|
||||||
|
{ key: '/city-warehouses', label: '仓库' },
|
||||||
|
{ key: '/fulfillment-providers', label: '仓配管理' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'finance-group',
|
key: 'finance-group',
|
||||||
icon: <AccountBookOutlined />,
|
icon: <AccountBookOutlined />,
|
||||||
@@ -116,20 +121,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/tickets/support', label: '技术支持' },
|
{ key: '/tickets/support', label: '技术支持' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票' },
|
|
||||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
|
||||||
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
|
||||||
{
|
|
||||||
key: 'wecom-group',
|
|
||||||
icon: <TeamOutlined />,
|
|
||||||
label: '企微机器人',
|
|
||||||
children: [
|
|
||||||
{ key: '/wecom/bots', label: '智能机器人' },
|
|
||||||
{ key: '/wecom/pushes', label: '消息推送' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
|
||||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
|
||||||
{
|
{
|
||||||
key: 'dev-plan-group',
|
key: 'dev-plan-group',
|
||||||
icon: <ProjectOutlined />,
|
icon: <ProjectOutlined />,
|
||||||
@@ -140,6 +131,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/dev-plan/settings', label: '开发设置' },
|
{ key: '/dev-plan/settings', label: '开发设置' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
key: 'logs-group',
|
key: 'logs-group',
|
||||||
@@ -156,9 +148,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
|
||||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||||
@@ -413,9 +405,7 @@ export default function AdminLayout() {
|
|||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space>
|
<Space>
|
||||||
<span>{profile?.name || '—'}</span>
|
<span>{profile?.name || '—'}</span>
|
||||||
<span style={{ color: '#999' }}>
|
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
||||||
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
|
|
||||||
</span>
|
|
||||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||||
退出
|
退出
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -236,7 +236,5 @@ export type AdminOrderRow = {
|
|||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
logisticsCompany?: string | null;
|
logisticsCompany?: string | null;
|
||||||
manualQueryUrl?: string | null;
|
manualQueryUrl?: string | null;
|
||||||
/** 当次应付物流费(按瓶当量 × 承运商计价) */
|
|
||||||
logisticsFee?: number | null;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,18 +31,9 @@ export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
|||||||
USER_ON_SITE: '用户·现场取货',
|
USER_ON_SITE: '用户·现场取货',
|
||||||
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||||
WECHAT_TRADE_MANAGE: '微信结算同步',
|
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||||
WECHAT_PAY: '微信支付',
|
|
||||||
MOCK: 'Mock',
|
MOCK: 'Mock',
|
||||||
MOCK_PAY: 'Mock 支付',
|
MOCK_PAY: 'Mock 支付',
|
||||||
SYSTEM: '系统',
|
SYSTEM: '系统',
|
||||||
SYSTEM_AUTO_EXPIRE: '系统·超时取消',
|
|
||||||
PARTNER_PROXY: '合伙人代下单',
|
|
||||||
HQ_PROXY: 'HQ 代操作',
|
|
||||||
HQ_LOGISTICS: 'HQ 物流',
|
|
||||||
WAREHOUSE_MANUAL: '仓库手工',
|
|
||||||
HQ: '总部',
|
|
||||||
STORE: '门店',
|
|
||||||
PARTNER: '合伙人',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
import {
|
|
||||||
LEDGER_TYPE_LABELS,
|
|
||||||
ORDER_STATUS_LABELS,
|
|
||||||
ORDER_STATUS_OPERATOR_LABELS,
|
|
||||||
} from './constants';
|
|
||||||
import { HQ_OPERATION_ACTION_LABELS } from './hq-log';
|
|
||||||
import { USER_LOG_CATEGORY_LABELS } from './user-log';
|
|
||||||
import {
|
|
||||||
PARTNER_LOG_CATEGORY_LABELS,
|
|
||||||
HQ_ADMIN_ROLES,
|
|
||||||
resolvePartnerLogCategory,
|
|
||||||
resolveStoreLogCategory,
|
|
||||||
resolveUserLogCategory,
|
|
||||||
STORE_LOG_CATEGORY_LABELS,
|
|
||||||
WECOM_BOT_PERMISSION_LABELS,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
|
|
||||||
export function zhLabel(dict: Record<string, string>, value?: string | null, empty = '—'): string {
|
|
||||||
if (value == null || value === '') return empty;
|
|
||||||
return dict[value] ?? value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CLIENT_APP_LABELS: Record<string, string> = {
|
|
||||||
USER_MINI: 'C 端小程序',
|
|
||||||
USER_H5: 'C 端 H5',
|
|
||||||
PARTNER_MINI: '合伙人小程序',
|
|
||||||
PARTNER_H5: '合伙人 H5',
|
|
||||||
HQ_MINI: 'HQ 小程序',
|
|
||||||
HQ_WEB: 'HQ 后台',
|
|
||||||
SHOP_H5: '门店 H5',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const REF_TYPE_LABELS: Record<string, string> = {
|
|
||||||
ORDER: '订单',
|
|
||||||
USER: '用户',
|
|
||||||
STORE: '门店',
|
|
||||||
PARTNER: '合伙人',
|
|
||||||
CITY: '城市',
|
|
||||||
PRODUCT: '商品',
|
|
||||||
COUPON: '权益券',
|
|
||||||
TICKET: '工单',
|
|
||||||
SUPPORT_TICKET: '技术支持工单',
|
|
||||||
INVOICE: '发票',
|
|
||||||
PROMO: '推广码',
|
|
||||||
PROMO_CODE: '推广码',
|
|
||||||
HQ: '总部',
|
|
||||||
HQ_ACCOUNT: 'HQ 账号',
|
|
||||||
STORE_ACCOUNT: '门店账号',
|
|
||||||
PARTNER_ACCOUNT: '合伙人账号',
|
|
||||||
PAYOUT: '打款',
|
|
||||||
WITHDRAWAL: '提现',
|
|
||||||
BILL: '账单',
|
|
||||||
DELIVERY: '配送单',
|
|
||||||
REDEEM: '核销',
|
|
||||||
WAREHOUSE: '仓库',
|
|
||||||
PACKAGE: '套餐',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DOMAIN_EVENT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
ORDER_STATUS: '订单状态',
|
|
||||||
BENEFIT_LEDGER: '权益流水',
|
|
||||||
STORE_AUDIT: '门店审核',
|
|
||||||
TICKET_COLLAB: '工单协作',
|
|
||||||
PROMO_TOUCH: '推广触达',
|
|
||||||
HQ_OPERATION: 'HQ 操作',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PARAM_DESC_LABELS: Record<string, string> = {
|
|
||||||
from_status: '原状态',
|
|
||||||
to_status: '目标状态',
|
|
||||||
operator: '操作人',
|
|
||||||
ledger_type: '流水类型',
|
|
||||||
coupon_id: '权益券 ID',
|
|
||||||
action: '行为',
|
|
||||||
target_type: '对象类型',
|
|
||||||
target_id: '对象 ID',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const USER_LOG_EVENT_LABELS: Record<string, string> = {
|
|
||||||
login_success: '登录成功',
|
|
||||||
sms_login: '短信登录',
|
|
||||||
sms_send: '发送验证码',
|
|
||||||
sms_verify_fail: '验证码校验失败',
|
|
||||||
session_start: '会话开始',
|
|
||||||
home_view: '浏览首页',
|
|
||||||
product_list_view: '浏览商品列表',
|
|
||||||
product_click: '点击商品',
|
|
||||||
product_detail_view: '浏览商品详情',
|
|
||||||
order_confirm_view: '浏览确认订单',
|
|
||||||
order_submit: '提交订单',
|
|
||||||
order_list_view: '浏览订单列表',
|
|
||||||
order_detail_view: '浏览订单详情',
|
|
||||||
order_cancel: '取消订单',
|
|
||||||
pay_page_view: '进入支付页',
|
|
||||||
pay_success: '支付成功',
|
|
||||||
pay_fail: '支付失败',
|
|
||||||
wechat_login: '微信登录',
|
|
||||||
wechat_phone: '微信授权手机号',
|
|
||||||
wechat_phone_login: '微信手机号登录',
|
|
||||||
wechat_location: '微信定位',
|
|
||||||
wechat_album: '微信选图',
|
|
||||||
wechat_bind: '绑定微信',
|
|
||||||
store_list_view: '浏览门店列表',
|
|
||||||
store_detail_view: '浏览门店详情',
|
|
||||||
benefit_page_view: '浏览权益页',
|
|
||||||
benefit_detail_view: '浏览权益详情',
|
|
||||||
benefit_redeem_start: '开始核销',
|
|
||||||
benefit_redeem_success: '核销成功',
|
|
||||||
redeem_code_view: '查看核销码',
|
|
||||||
redeem_success_view: '核销成功页',
|
|
||||||
profile_update: '修改资料',
|
|
||||||
bind_phone: '绑定手机号',
|
|
||||||
address_list_view: '浏览地址列表',
|
|
||||||
address_edit: '编辑地址',
|
|
||||||
promo_touch: '推广触达',
|
|
||||||
promo_share: '分享推广',
|
|
||||||
cs_contact: '联系客服',
|
|
||||||
after_sale_list_view: '浏览售后列表',
|
|
||||||
after_sale_apply: '申请售后',
|
|
||||||
client_error: '前端异常',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const STORE_LOG_EVENT_LABELS: Record<string, string> = {
|
|
||||||
store_sms_send: '发送验证码',
|
|
||||||
store_sms_login: '短信登录',
|
|
||||||
store_sms_verify_fail: '验证码校验失败',
|
|
||||||
store_login_success: '登录成功',
|
|
||||||
store_select: '选择门店',
|
|
||||||
store_wechat_login: '微信登录',
|
|
||||||
store_wechat_bind: '绑定微信',
|
|
||||||
store_home_view: '浏览首页',
|
|
||||||
store_redeem_entry_view: '进入核销页',
|
|
||||||
store_redeem_confirm_view: '核销确认页',
|
|
||||||
store_phone_redeem_view: '手机号核销页',
|
|
||||||
store_records_view: '浏览核销记录',
|
|
||||||
store_withdraw_view: '浏览提现页',
|
|
||||||
store_packages_view: '浏览套餐页',
|
|
||||||
store_mine_view: '浏览我的',
|
|
||||||
store_staff_view: '浏览子账号',
|
|
||||||
store_redeem_preview: '核销预览',
|
|
||||||
store_redeem_confirm: '确认核销',
|
|
||||||
store_redeem_confirm_fail: '核销失败',
|
|
||||||
store_redeem_weaknet_threshold: '弱网阈值',
|
|
||||||
store_redeem_pending_submit: '提交挂起核销',
|
|
||||||
store_redeem_pending_complete: '完成挂起核销',
|
|
||||||
store_redeem_pending_reject: '驳回挂起核销',
|
|
||||||
store_redeem_phone_lookup_sms: '手机号核销发码',
|
|
||||||
store_redeem_phone_balance: '查询用户权益余额',
|
|
||||||
store_redeem_phone_prepare: '手机号核销准备',
|
|
||||||
store_redeem_scan_start: '扫码核销开始',
|
|
||||||
store_payout_created: '生成打款单',
|
|
||||||
store_payout_paid: '打款完成',
|
|
||||||
store_withdraw_applied: '申请提现',
|
|
||||||
store_withdraw_paid: '提现已打款',
|
|
||||||
store_status_change: '变更营业状态',
|
|
||||||
store_staff_create: '新增子账号',
|
|
||||||
store_staff_update: '编辑子账号',
|
|
||||||
store_staff_delete: '删除子账号',
|
|
||||||
store_staff_permission_update: '更新子账号权限',
|
|
||||||
store_package_apply: '申请套餐',
|
|
||||||
store_package_audit_view: '查看套餐审核',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PARTNER_LOG_EVENT_LABELS: Record<string, string> = {
|
|
||||||
partner_sms_send: '发送验证码',
|
|
||||||
partner_sms_login: '短信登录',
|
|
||||||
partner_sms_verify_fail: '验证码校验失败',
|
|
||||||
partner_login_success: '登录成功',
|
|
||||||
partner_wechat_login: '微信登录',
|
|
||||||
partner_wechat_bind: '绑定微信',
|
|
||||||
partner_home_view: '浏览首页',
|
|
||||||
partner_store_list_view: '浏览门店列表',
|
|
||||||
partner_store_detail_view: '浏览门店详情',
|
|
||||||
partner_store_create_view: '进入录店页',
|
|
||||||
partner_order_list_view: '浏览订单列表',
|
|
||||||
partner_order_detail_view: '浏览订单详情',
|
|
||||||
partner_proxy_order_view: '进入代下单页',
|
|
||||||
partner_bills_view: '浏览账单列表',
|
|
||||||
partner_bill_detail_view: '浏览账单详情',
|
|
||||||
partner_staff_list_view: '浏览子账号',
|
|
||||||
partner_leaderboard_view: '浏览排行榜',
|
|
||||||
partner_weekly_report_view: '浏览周报',
|
|
||||||
partner_staff_create: '新增子账号',
|
|
||||||
partner_staff_sms_send: '子账号发验证码',
|
|
||||||
partner_staff_sms_verify_fail: '子账号验证码失败',
|
|
||||||
partner_staff_update: '编辑子账号',
|
|
||||||
partner_staff_delete: '删除子账号',
|
|
||||||
partner_staff_permission_update: '更新子账号权限',
|
|
||||||
partner_store_create: '录入门店',
|
|
||||||
partner_store_status_change: '变更门店状态',
|
|
||||||
partner_store_audit_approved: '门店审核通过',
|
|
||||||
partner_store_audit_rejected: '门店审核驳回',
|
|
||||||
partner_order_ship: '订单发货',
|
|
||||||
partner_delivery_advance: '推进配送',
|
|
||||||
partner_proxy_order_create: '代下单',
|
|
||||||
partner_bill_view: '查看账单',
|
|
||||||
partner_bill_confirm: '确认账单',
|
|
||||||
partner_warehouse_view: '浏览仓库',
|
|
||||||
partner_warehouse_update: '更新仓库',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EVENT_NAME_LABELS: Record<string, string> = {
|
|
||||||
...USER_LOG_EVENT_LABELS,
|
|
||||||
...STORE_LOG_EVENT_LABELS,
|
|
||||||
...PARTNER_LOG_EVENT_LABELS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const THIRD_PARTY_PROVIDER_LABELS: Record<string, string> = {
|
|
||||||
WECHAT_AUTH: '微信授权',
|
|
||||||
WECHAT_PAY: '微信支付',
|
|
||||||
WECHAT_REFUND: '微信退款',
|
|
||||||
WECHAT_MAP: '微信/腾讯地图',
|
|
||||||
ALIYUN_OSS: '阿里云 OSS',
|
|
||||||
ALIYUN_SMS: '阿里云短信',
|
|
||||||
MOCK_SMS: 'Mock 短信',
|
|
||||||
SMS: '短信',
|
|
||||||
XFX: '小飞侠',
|
|
||||||
LOGISTICS: '物流快递',
|
|
||||||
MANUAL: '手动',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const THIRD_PARTY_STATUS_LABELS: Record<string, string> = {
|
|
||||||
SUCCESS: '成功',
|
|
||||||
FAILED: '失败',
|
|
||||||
PENDING: '处理中',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const THIRD_PARTY_SCENE_LABELS: Record<string, string> = {
|
|
||||||
LOGIN: '登录',
|
|
||||||
USERINFO: '用户信息',
|
|
||||||
JSSDK_CONFIG: 'JSSDK 配置',
|
|
||||||
BIND_PHONE: '绑定手机号',
|
|
||||||
GET_LOCATION: '获取定位',
|
|
||||||
CHOOSE_IMAGE: '选择图片',
|
|
||||||
ORDER_PAY: '订单支付',
|
|
||||||
ORDER_REFUND: '订单退款',
|
|
||||||
UPLOAD_SHIPPING: '上传发货信息',
|
|
||||||
TRADE_MANAGE_PUSH: '交易结算推送',
|
|
||||||
ORDER_DISPATCH: '配送下单',
|
|
||||||
ORDER_DISPATCH_HOLD: '配送暂不下发',
|
|
||||||
TRACK_CALLBACK: '物流回调',
|
|
||||||
UPLOAD_TOKEN: '上传凭证',
|
|
||||||
UPLOAD_PUT_OBJECT: '上传文件',
|
|
||||||
GEOCODE: '地理编码',
|
|
||||||
REVERSE_GEOCODE: '逆地理编码',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WECOM_BOT_ACTION_LABELS: Record<string, string> = {
|
|
||||||
...WECOM_BOT_PERMISSION_LABELS,
|
|
||||||
'user.read_sms.start': '查询用户·发送验证码',
|
|
||||||
'user.read_sms.verify': '查询用户·校验验证码',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GENERIC_STATUS_LABELS: Record<string, string> = {
|
|
||||||
...ORDER_STATUS_LABELS,
|
|
||||||
...LEDGER_TYPE_LABELS,
|
|
||||||
...THIRD_PARTY_STATUS_LABELS,
|
|
||||||
PENDING: '待处理',
|
|
||||||
APPROVED: '已通过',
|
|
||||||
REJECTED: '已驳回',
|
|
||||||
ACTIVE: '正常',
|
|
||||||
DISABLED: '停用',
|
|
||||||
SUCCESS: '成功',
|
|
||||||
FAILED: '失败',
|
|
||||||
OPEN: '营业中',
|
|
||||||
PAUSED: '已暂停',
|
|
||||||
CLOSED: '已关闭',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
|
||||||
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const CODE_ZH: Record<string, string> = {
|
|
||||||
...CLIENT_APP_LABELS,
|
|
||||||
...REF_TYPE_LABELS,
|
|
||||||
...DOMAIN_EVENT_TYPE_LABELS,
|
|
||||||
...EVENT_NAME_LABELS,
|
|
||||||
...ORDER_STATUS_LABELS,
|
|
||||||
...ORDER_STATUS_OPERATOR_LABELS,
|
|
||||||
...LEDGER_TYPE_LABELS,
|
|
||||||
...HQ_OPERATION_ACTION_LABELS,
|
|
||||||
...HQ_ROLE_LABELS,
|
|
||||||
...THIRD_PARTY_PROVIDER_LABELS,
|
|
||||||
...THIRD_PARTY_STATUS_LABELS,
|
|
||||||
...THIRD_PARTY_SCENE_LABELS,
|
|
||||||
...WECOM_BOT_ACTION_LABELS,
|
|
||||||
...GENERIC_STATUS_LABELS,
|
|
||||||
...PARAM_DESC_LABELS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function zhCode(value?: string | null, empty = '—'): string {
|
|
||||||
if (value == null || value === '') return empty;
|
|
||||||
return CODE_ZH[value] ?? value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventNameLabel(value?: string | null): string {
|
|
||||||
return zhLabel(EVENT_NAME_LABELS, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clientAppLabel(value?: string | null): string {
|
|
||||||
return zhLabel(CLIENT_APP_LABELS, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRef(refType?: string | null, refId?: string | null): string {
|
|
||||||
if (!refType && !refId) return '—';
|
|
||||||
const type = zhLabel(REF_TYPE_LABELS, refType, refType || '');
|
|
||||||
return refId ? `${type} #${refId}` : type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function userEventCategoryLabel(eventName: string, category?: string | null): string {
|
|
||||||
const key = category || resolveUserLogCategory(eventName);
|
|
||||||
if (key && USER_LOG_CATEGORY_LABELS[key]) return USER_LOG_CATEGORY_LABELS[key];
|
|
||||||
return '其他';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeEventCategoryLabel(eventName: string, category?: string | null): string {
|
|
||||||
const key = category || resolveStoreLogCategory(eventName);
|
|
||||||
const labels = STORE_LOG_CATEGORY_LABELS as Record<string, string>;
|
|
||||||
if (key && labels[key]) return labels[key];
|
|
||||||
return '其他';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function partnerEventCategoryLabel(eventName: string, category?: string | null): string {
|
|
||||||
const key = category || resolvePartnerLogCategory(eventName);
|
|
||||||
const labels = PARTNER_LOG_CATEGORY_LABELS as Record<string, string>;
|
|
||||||
if (key && labels[key]) return labels[key];
|
|
||||||
return '其他';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function domainParamFieldLabel(desc?: string | null, fallbackIndex?: 1 | 2 | 3): string {
|
|
||||||
if (desc && PARAM_DESC_LABELS[desc]) return PARAM_DESC_LABELS[desc];
|
|
||||||
if (fallbackIndex) return `参数${fallbackIndex}`;
|
|
||||||
return '参数';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function zhRemark(value?: string | null): string {
|
|
||||||
if (!value) return '—';
|
|
||||||
return value
|
|
||||||
.replace(/partnerAccountId=/g, '合伙人账号=')
|
|
||||||
.replace(/hqAccountId=/g, 'HQ账号=')
|
|
||||||
.replace(/deliveryMode=/g, '履约方式=')
|
|
||||||
.replace(/\bmode=/g, '履约方式=')
|
|
||||||
.replace(/customer=/g, '客户=')
|
|
||||||
.replace(/\bON_SITE_PICKUP\b/g, '现场提货')
|
|
||||||
.replace(/\bCROSS_CITY\b/g, '跨城')
|
|
||||||
.replace(/\bLOCAL\b/g, '同城');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function domainParamValue(desc?: string | null, value?: string | null): string {
|
|
||||||
if (value == null || value === '') return '—';
|
|
||||||
if (desc === 'from_status' || desc === 'to_status') return zhLabel(ORDER_STATUS_LABELS, value);
|
|
||||||
if (desc === 'operator') return zhLabel(ORDER_STATUS_OPERATOR_LABELS, value);
|
|
||||||
if (desc === 'ledger_type') return zhLabel(LEDGER_TYPE_LABELS, value);
|
|
||||||
if (desc === 'action') return zhLabel(HQ_OPERATION_ACTION_LABELS, value);
|
|
||||||
if (desc === 'target_type') return zhLabel(REF_TYPE_LABELS, value);
|
|
||||||
return zhCode(value);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import bgmSrc from '../assets/big-screen/dukang-bgm.mp3';
|
|
||||||
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||||
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||||
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||||
@@ -34,50 +33,6 @@ const CELEBRATE_SFX: Record<AmountTier, string> = {
|
|||||||
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||||
let audioUnlocked = false;
|
let audioUnlocked = false;
|
||||||
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
||||||
const BGM_VOLUME = 0.32;
|
|
||||||
const BGM_DUCK_VOLUME = 0.08;
|
|
||||||
let bgmAudio: HTMLAudioElement | null = null;
|
|
||||||
let bgmDucked = false;
|
|
||||||
|
|
||||||
function getBgmAudio(): HTMLAudioElement | null {
|
|
||||||
if (typeof Audio === 'undefined') return null;
|
|
||||||
if (!bgmAudio) {
|
|
||||||
bgmAudio = new Audio(bgmSrc);
|
|
||||||
bgmAudio.loop = true;
|
|
||||||
bgmAudio.preload = 'auto';
|
|
||||||
bgmAudio.volume = BGM_VOLUME;
|
|
||||||
}
|
|
||||||
return bgmAudio;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBgmVolume() {
|
|
||||||
if (!bgmAudio) return;
|
|
||||||
bgmAudio.volume = bgmDucked ? BGM_DUCK_VOLUME : BGM_VOLUME;
|
|
||||||
}
|
|
||||||
|
|
||||||
function duckBgm() {
|
|
||||||
bgmDucked = true;
|
|
||||||
applyBgmVolume();
|
|
||||||
}
|
|
||||||
|
|
||||||
function unduckBgm() {
|
|
||||||
bgmDucked = false;
|
|
||||||
applyBgmVolume();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBgmPlaying(on: boolean): Promise<boolean> {
|
|
||||||
const audio = getBgmAudio();
|
|
||||||
if (!audio) return Promise.resolve(false);
|
|
||||||
applyBgmVolume();
|
|
||||||
if (!on) {
|
|
||||||
audio.pause();
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
return audio
|
|
||||||
.play()
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlockCelebrateAudio() {
|
function unlockCelebrateAudio() {
|
||||||
if (audioUnlocked || !sharedAudio) return;
|
if (audioUnlocked || !sharedAudio) return;
|
||||||
@@ -98,7 +53,6 @@ function unlockCelebrateAudio() {
|
|||||||
|
|
||||||
function playCelebrateSfx(tier: AmountTier): () => void {
|
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||||
if (!sharedAudio) return () => undefined;
|
if (!sharedAudio) return () => undefined;
|
||||||
duckBgm();
|
|
||||||
try {
|
try {
|
||||||
sharedAudio.pause();
|
sharedAudio.pause();
|
||||||
sharedAudio.currentTime = 0;
|
sharedAudio.currentTime = 0;
|
||||||
@@ -118,7 +72,6 @@ function playCelebrateSfx(tier: AmountTier): () => void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
unduckBgm();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,40 +341,10 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BgmToggleButton({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`big-screen-bgm${on ? ' is-on' : ''}`}
|
|
||||||
onClick={onToggle}
|
|
||||||
aria-pressed={on}
|
|
||||||
aria-label={on ? '暂停背景音乐' : '播放背景音乐'}
|
|
||||||
>
|
|
||||||
<span className="big-screen-bgm-icon" aria-hidden>
|
|
||||||
{on ? (
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M9 18V6h2v12H9zm4 0V6h2v12h-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
|
||||||
<path fill="currentColor" d="M8 5.14v13.72L19.06 12 8 5.14z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{on ? 'BGM 播放中' : '播放 BGM'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BigScreenPage() {
|
export default function BigScreenPage() {
|
||||||
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [bgmOn, setBgmOn] = useState(false);
|
|
||||||
const bgmWantedRef = useRef(false);
|
|
||||||
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||||
const seenIdsRef = useRef<Set<string> | null>(null);
|
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||||
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
||||||
@@ -493,22 +416,6 @@ export default function BigScreenPage() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const toggleBgm = useCallback(() => {
|
|
||||||
unlockCelebrateAudio();
|
|
||||||
const next = !bgmWantedRef.current;
|
|
||||||
bgmWantedRef.current = next;
|
|
||||||
if (!next) {
|
|
||||||
void setBgmPlaying(false);
|
|
||||||
setBgmOn(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBgmOn(true);
|
|
||||||
void setBgmPlaying(true).then((playing) => {
|
|
||||||
if (!bgmWantedRef.current) return;
|
|
||||||
setBgmOn(playing);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
unlockCelebrateAudio();
|
unlockCelebrateAudio();
|
||||||
const unlock = () => unlockCelebrateAudio();
|
const unlock = () => unlockCelebrateAudio();
|
||||||
@@ -517,8 +424,6 @@ export default function BigScreenPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('pointerdown', unlock);
|
window.removeEventListener('pointerdown', unlock);
|
||||||
window.removeEventListener('keydown', unlock);
|
window.removeEventListener('keydown', unlock);
|
||||||
bgmWantedRef.current = false;
|
|
||||||
void setBgmPlaying(false);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -575,7 +480,6 @@ export default function BigScreenPage() {
|
|||||||
<span className="big-screen-subtitle">· 发布会现场</span>
|
<span className="big-screen-subtitle">· 发布会现场</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="big-screen-live-wrap">
|
<div className="big-screen-live-wrap">
|
||||||
<BgmToggleButton on={bgmOn} onToggle={toggleBgm} />
|
|
||||||
<span className="big-screen-live-label">实时成交</span>
|
<span className="big-screen-live-label">实时成交</span>
|
||||||
<span className="big-screen-live">
|
<span className="big-screen-live">
|
||||||
<span className="big-screen-live-dot" />
|
<span className="big-screen-live-dot" />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Divider,
|
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@@ -363,28 +362,8 @@ 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>
|
||||||
<Divider orientation="left">履约起购</Divider>
|
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></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="合伙人佣金合计上限 %"
|
||||||
|
|||||||
@@ -1,43 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import {
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
DELIVERY_PROVIDER_LABELS,
|
|
||||||
DELIVERY_TYPE_LABELS,
|
|
||||||
ORDER_STATUS_LABELS,
|
|
||||||
fmtTime,
|
|
||||||
} from '../lib/constants';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
type DeliveryOrder = {
|
|
||||||
id: string;
|
|
||||||
orderNo: string;
|
|
||||||
status: string;
|
|
||||||
deliveryType: string;
|
|
||||||
productName: string;
|
|
||||||
productSpec?: string | null;
|
|
||||||
barcode69?: string | null;
|
|
||||||
quantity: number;
|
|
||||||
saleUnit?: string;
|
|
||||||
bottlesPerUnit?: number;
|
|
||||||
payAmount?: number;
|
|
||||||
receiverName: string;
|
|
||||||
receiverPhone: string;
|
|
||||||
receiverAddress?: string;
|
|
||||||
receiverProvince?: string;
|
|
||||||
receiverCity?: string;
|
|
||||||
receiverDistrict?: string;
|
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
|
||||||
imageResource?: { url: string } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,34 +19,19 @@ type Row = {
|
|||||||
trackingNo: string | null;
|
trackingNo: string | null;
|
||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** 当次应付物流费 */
|
||||||
logisticsFee?: number | null;
|
logisticsFee?: number | null;
|
||||||
order?: DeliveryOrder;
|
order?: {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
receiverName: string;
|
||||||
|
receiverPhone: string;
|
||||||
|
deliveryType: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatQty(order?: DeliveryOrder | null) {
|
|
||||||
if (!order || order.quantity == null) return '—';
|
|
||||||
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
|
|
||||||
const bottles =
|
|
||||||
order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1
|
|
||||||
? `(${order.quantity * order.bottlesPerUnit}瓶)`
|
|
||||||
: '';
|
|
||||||
return `${order.quantity}${unit}${bottles}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAddress(order?: DeliveryOrder | null) {
|
|
||||||
if (!order) return '—';
|
|
||||||
if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货';
|
|
||||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('');
|
|
||||||
const detail = (order.receiverAddress || '').trim();
|
|
||||||
if (!region) return detail || '—';
|
|
||||||
if (!detail || detail.startsWith(region)) return detail || region;
|
|
||||||
return `${region}${detail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeliveriesPage() {
|
export default function DeliveriesPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -107,91 +65,25 @@ export default function DeliveriesPage() {
|
|||||||
setTrackOpen(true);
|
setTrackOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDetail(row: Row) {
|
|
||||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
|
||||||
setDetail(d);
|
|
||||||
editForm.setFieldsValue({
|
|
||||||
provider: d.provider,
|
|
||||||
trackingNo: d.trackingNo,
|
|
||||||
providerOrderNo: d.providerOrderNo,
|
|
||||||
});
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '订单号',
|
title: '订单号',
|
||||||
dataIndex: ['order', 'orderNo'],
|
dataIndex: ['order', 'orderNo'],
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||||
title: '用户',
|
|
||||||
key: 'user',
|
|
||||||
width: 140,
|
|
||||||
render: (_, row) => {
|
|
||||||
const user = row.order?.user;
|
|
||||||
if (!user) return '—';
|
|
||||||
const label = user.userNo || user.phone || '—';
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{user.id ? (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
{user.phone && user.userNo ? (
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
|
||||||
{user.phone}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '商品',
|
|
||||||
key: 'product',
|
|
||||||
width: 200,
|
|
||||||
render: (_, row) => {
|
|
||||||
const order = row.order;
|
|
||||||
if (!order?.productName) return '—';
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span>{order.productName}</span>
|
|
||||||
{order.productSpec ? (
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
|
||||||
{order.productSpec}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '数量',
|
|
||||||
key: 'quantity',
|
|
||||||
width: 90,
|
|
||||||
render: (_, row) => formatQty(row.order),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '配送方式',
|
|
||||||
dataIndex: ['order', 'deliveryType'],
|
|
||||||
width: 90,
|
|
||||||
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '承运商',
|
|
||||||
dataIndex: 'provider',
|
|
||||||
width: 90,
|
|
||||||
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
|
|
||||||
},
|
|
||||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '运费',
|
title: '运费',
|
||||||
@@ -200,33 +92,20 @@ export default function DeliveriesPage() {
|
|||||||
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||||
},
|
},
|
||||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||||
{
|
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||||
title: '订单状态',
|
|
||||||
dataIndex: ['order', 'status'],
|
|
||||||
width: 100,
|
|
||||||
render: (s) => ORDER_STATUS_LABELS[s] || s,
|
|
||||||
},
|
|
||||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||||
{
|
|
||||||
title: '收货电话',
|
|
||||||
dataIndex: ['order', 'receiverPhone'],
|
|
||||||
width: 120,
|
|
||||||
render: (v) => v || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '配送地址',
|
|
||||||
key: 'address',
|
|
||||||
width: 280,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (_, row) => formatAddress(row.order),
|
|
||||||
},
|
|
||||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 140,
|
title: '操作', width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>编辑</Button>
|
<Button type="link" size="small" onClick={async () => {
|
||||||
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>编辑</Button>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -234,21 +113,19 @@ export default function DeliveriesPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
const order = detail?.order;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
|
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||||
@@ -265,63 +142,14 @@ export default function DeliveriesPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">
|
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||||
{order?.user ? (
|
|
||||||
<Space size={8} wrap>
|
|
||||||
{order.user.id ? (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
|
|
||||||
>
|
|
||||||
{order.user.userNo}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
order.user.userNo || '—'
|
|
||||||
)}
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
) : '—'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="商品">
|
|
||||||
<Space align="start">
|
|
||||||
{order?.imageResource?.url ? (
|
|
||||||
<img
|
|
||||||
src={order.imageResource.url}
|
|
||||||
alt=""
|
|
||||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span>
|
|
||||||
{order?.productName || '—'}
|
|
||||||
{order?.productSpec ? (
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
|
||||||
{order.productSpec}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
{order?.barcode69 ? (
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
|
||||||
{order.barcode69}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</Space>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="配送方式">
|
|
||||||
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="收货人">
|
|
||||||
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="运费">
|
<Descriptions.Item label="运费">
|
||||||
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import {
|
|
||||||
DOMAIN_EVENT_TYPE_LABELS,
|
|
||||||
domainParamFieldLabel,
|
|
||||||
domainParamValue,
|
|
||||||
formatRef,
|
|
||||||
zhCode,
|
|
||||||
zhLabel,
|
|
||||||
} from '../lib/display-labels';
|
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
@@ -23,11 +15,8 @@ type Row = {
|
|||||||
refId: string;
|
refId: string;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
param1?: string | null;
|
param1?: string | null;
|
||||||
param1Desc?: string | null;
|
|
||||||
param2?: string | null;
|
param2?: string | null;
|
||||||
param2Desc?: string | null;
|
|
||||||
param3?: string | null;
|
param3?: string | null;
|
||||||
param3Desc?: string | null;
|
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -81,18 +70,13 @@ export default function DomainEventsPage() {
|
|||||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tag>{zhLabel(DOMAIN_EVENT_TYPE_LABELS, v)}</Tag>
|
<Tag>{v}</Tag>
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '关联', render: (_, r) => formatRef(r.refType, r.refId) },
|
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => zhCode(v) },
|
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||||
{
|
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||||
title: '摘要',
|
|
||||||
render: (_, r) => domainParamValue(r.param1Desc, r.param1) !== '—'
|
|
||||||
? domainParamValue(r.param1Desc, r.param1)
|
|
||||||
: (r.remark || '—'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -149,18 +133,11 @@ export default function DomainEventsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} size="small">
|
<Descriptions column={1} size="small">
|
||||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
|
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
|
||||||
<Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
|
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
|
||||||
{domainParamValue(detail.param1Desc, detail.param1)}
|
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={domainParamFieldLabel(detail.param2Desc, 2)}>
|
|
||||||
{domainParamValue(detail.param2Desc, detail.param2)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={domainParamFieldLabel(detail.param3Desc, 3)}>
|
|
||||||
{domainParamValue(detail.param3Desc, detail.param3)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|||||||
@@ -349,7 +349,6 @@ 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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { formatRef, zhCode } from '../lib/display-labels';
|
|
||||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -85,7 +84,7 @@ export default function HqLogsPage() {
|
|||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => zhCode(v) },
|
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -144,7 +143,7 @@ export default function HqLogsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="refType" label="对象类型">
|
<Form.Item name="refType" label="对象类型">
|
||||||
<Input allowClear placeholder="对象编码" style={{ width: 140 }} />
|
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
@@ -193,12 +192,12 @@ export default function HqLogsPage() {
|
|||||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="角色">{zhCode(detail.hqRole)}</Descriptions.Item>
|
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="行为">
|
<Descriptions.Item label="行为">
|
||||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="对象">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
ORDER_STATUS_OPERATOR_LABELS,
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
fmtTime,
|
fmtTime,
|
||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import { zhRemark } from '../lib/display-labels';
|
|
||||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
@@ -675,13 +674,6 @@ export default function OrdersPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
render: (v: number) => `¥${v}`,
|
render: (v: number) => `¥${v}`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '运费',
|
|
||||||
key: 'logisticsFee',
|
|
||||||
width: 90,
|
|
||||||
render: (_, row) =>
|
|
||||||
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '好客权益',
|
title: '好客权益',
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -1166,11 +1158,6 @@ export default function OrdersPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="运费">
|
|
||||||
{detail.delivery.logisticsFee == null
|
|
||||||
? '—'
|
|
||||||
: `¥${Number(detail.delivery.logisticsFee).toFixed(2)}`}
|
|
||||||
</Descriptions.Item>
|
|
||||||
{detail.delivery.manualQueryUrl && (
|
{detail.delivery.manualQueryUrl && (
|
||||||
<Descriptions.Item label="查询链接">
|
<Descriptions.Item label="查询链接">
|
||||||
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||||
@@ -1211,7 +1198,7 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
render: (v) => zhRemark(v),
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||||
|
PARTNER_LOG_CATEGORY_LABELS,
|
||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
|
resolvePartnerLogCategory,
|
||||||
type PartnerLogCategory,
|
type PartnerLogCategory,
|
||||||
type PartnerStaffRole,
|
type PartnerStaffRole,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { clientAppLabel, eventNameLabel, formatRef, partnerEventCategoryLabel } from '../lib/display-labels';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -106,7 +107,7 @@ export default function PartnerLogsPage() {
|
|||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: PartnerLogCategory | null, r) => (
|
render: (v: PartnerLogCategory | null, r) => (
|
||||||
<Tag>{partnerEventCategoryLabel(r.eventName, v)}</Tag>
|
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,14 +121,14 @@ export default function PartnerLogsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{eventNameLabel(v)}
|
{v}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -187,11 +188,11 @@ export default function PartnerLogsPage() {
|
|||||||
<Form.Item name="companyName" label="公司">
|
<Form.Item name="companyName" label="公司">
|
||||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="partnerId" label="合伙人 ID">
|
<Form.Item name="partnerId" label="合伙人ID">
|
||||||
<Input placeholder="合伙人 ID" allowClear style={{ width: 120 }} />
|
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="eventName" label="事件编码">
|
<Form.Item name="eventName" label="事件名">
|
||||||
<Input placeholder="事件编码" allowClear style={{ width: 160 }} />
|
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
@@ -236,19 +237,11 @@ export default function PartnerLogsPage() {
|
|||||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="ID">{String(detail.id ?? '—')}</Descriptions.Item>
|
{Object.entries(detail).map(([k, v]) => (
|
||||||
<Descriptions.Item label="合伙人">{String(detail.companyName || detail.partnerId || '—')}</Descriptions.Item>
|
<Descriptions.Item key={k} label={k}>
|
||||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.accountPhone || '—')}</Descriptions.Item>
|
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||||
<Descriptions.Item label="分类">{partnerEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
))}
|
||||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt ? String(detail.createdAt) : null)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="参数">
|
|
||||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
||||||
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -610,7 +610,6 @@ 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) ?? []}
|
||||||
|
|||||||
@@ -159,20 +159,9 @@ export default function StoreCategoriesPage() {
|
|||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||||
const res = await request<{ deleted?: boolean; softDisabled?: boolean }>(
|
message.success('已处理');
|
||||||
`/admin/store-categories/${row.id}`,
|
void reload();
|
||||||
{ method: 'DELETE' },
|
|
||||||
);
|
|
||||||
if (res.softDisabled) {
|
|
||||||
message.success('该分类已有门店占用,已改为停用');
|
|
||||||
} else {
|
|
||||||
message.success('已删除');
|
|
||||||
}
|
|
||||||
void reload();
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '删除失败');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger>删除</Button>
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
STORE_LOG_CATEGORY_OPTIONS,
|
STORE_LOG_CATEGORY_OPTIONS,
|
||||||
|
STORE_LOG_CATEGORY_LABELS,
|
||||||
|
resolveStoreLogCategory,
|
||||||
type StoreLogCategory,
|
type StoreLogCategory,
|
||||||
} from '../lib/store-log';
|
} from '../lib/store-log';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { clientAppLabel, eventNameLabel, formatRef, storeEventCategoryLabel } from '../lib/display-labels';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -101,7 +102,7 @@ export default function StoreLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '分类', dataIndex: 'category', width: 100,
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
render: (v: StoreLogCategory | null, r) => (
|
render: (v: StoreLogCategory | null, r) => (
|
||||||
<Tag>{storeEventCategoryLabel(r.eventName, v)}</Tag>
|
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,7 +118,7 @@ export default function StoreLogsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{eventNameLabel(v)}
|
{v}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -127,7 +128,7 @@ export default function StoreLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -183,7 +184,7 @@ export default function StoreLogsPage() {
|
|||||||
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
||||||
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
<Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -197,12 +198,12 @@ export default function StoreLogsPage() {
|
|||||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="分类">
|
<Descriptions.Item label="分类">
|
||||||
{storeEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}
|
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="参数">
|
<Descriptions.Item label="参数">
|
||||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typograp
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import {
|
|
||||||
THIRD_PARTY_PROVIDER_LABELS,
|
|
||||||
THIRD_PARTY_SCENE_LABELS,
|
|
||||||
THIRD_PARTY_STATUS_LABELS,
|
|
||||||
formatRef,
|
|
||||||
zhLabel,
|
|
||||||
} from '../lib/display-labels';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -33,14 +26,14 @@ type Row = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDER_OPTIONS = [
|
const PROVIDER_OPTIONS = [
|
||||||
{ value: 'WECHAT_AUTH', label: '微信授权' },
|
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' },
|
||||||
{ value: 'WECHAT_PAY', label: '微信支付' },
|
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' },
|
||||||
{ value: 'WECHAT_MAP', label: '微信/腾讯地图' },
|
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
||||||
{ value: 'ALIYUN_OSS', label: '阿里云 OSS' },
|
{ value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' },
|
||||||
{ value: 'ALIYUN_SMS', label: '阿里云短信' },
|
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
||||||
{ value: 'MOCK_SMS', label: 'Mock 短信' },
|
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
||||||
{ value: 'XFX', label: '小飞侠' },
|
{ value: 'XFX', label: '小飞侠 (XFX)' },
|
||||||
{ value: 'LOGISTICS', label: '物流快递' },
|
{ value: 'LOGISTICS', label: 'LOGISTICS' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
@@ -82,18 +75,18 @@ export default function ThirdPartyLogsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{ title: '渠道', dataIndex: 'provider', width: 120, render: (v: string) => zhLabel(THIRD_PARTY_PROVIDER_LABELS, v) },
|
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||||
{ title: '场景', dataIndex: 'scene', width: 140, render: (v: string) => zhLabel(THIRD_PARTY_SCENE_LABELS, v) },
|
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||||
{
|
{
|
||||||
title: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{zhLabel(THIRD_PARTY_STATUS_LABELS, v)}</Tag>,
|
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{v}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '错误信息',
|
title: '错误信息',
|
||||||
@@ -154,11 +147,11 @@ export default function ThirdPartyLogsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="provider" label="渠道">
|
<Form.Item name="provider" label="Provider">
|
||||||
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
|
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="scene" label="场景">
|
<Form.Item name="scene" label="场景">
|
||||||
<Input allowClear placeholder="场景编码" style={{ width: 160 }} />
|
<Input allowClear placeholder="JSSDK_CONFIG / LOGIN" style={{ width: 160 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="refId" label="关联ID">
|
<Form.Item name="refId" label="关联ID">
|
||||||
<Input allowClear style={{ width: 120 }} />
|
<Input allowClear style={{ width: 120 }} />
|
||||||
@@ -188,15 +181,13 @@ export default function ThirdPartyLogsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="渠道">{zhLabel(THIRD_PARTY_PROVIDER_LABELS, String(detail.provider))}</Descriptions.Item>
|
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="场景">{zhLabel(THIRD_PARTY_SCENE_LABELS, String(detail.scene))}</Descriptions.Item>
|
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>
|
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>{String(detail.status)}</Tag>
|
||||||
{zhLabel(THIRD_PARTY_STATUS_LABELS, String(detail.status))}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">
|
<Descriptions.Item label="关联">
|
||||||
{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}
|
{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
|
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { USER_LOG_CATEGORY_OPTIONS, type UserLogCategory } from '../lib/user-log';
|
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { clientAppLabel, eventNameLabel, formatRef, userEventCategoryLabel } from '../lib/display-labels';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -28,6 +27,8 @@ type Row = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
|
||||||
|
|
||||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||||
if (!json) return '—';
|
if (!json) return '—';
|
||||||
const text = JSON.stringify(json);
|
const text = JSON.stringify(json);
|
||||||
@@ -78,7 +79,7 @@ export default function UserLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '分类', dataIndex: 'category', width: 100,
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
render: (v: UserLogCategory | null, r) => (
|
render: (v: UserLogCategory | null, r) => (
|
||||||
<Tag>{userEventCategoryLabel(r.eventName, v)}</Tag>
|
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,16 +93,13 @@ export default function UserLogsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{eventNameLabel(v)}
|
{v}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '端', dataIndex: 'clientApp', width: 110, render: (v) => clientAppLabel(v),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -150,7 +148,7 @@ export default function UserLogsPage() {
|
|||||||
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
<Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -162,10 +160,10 @@ export default function UserLogsPage() {
|
|||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
|
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
|
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="分类">{userEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="参数">
|
<Descriptions.Item label="参数">
|
||||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
|||||||
@@ -17,13 +17,12 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
|
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
||||||
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||||
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
|
|
||||||
|
|
||||||
type UserOrderRow = {
|
type UserOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -641,10 +640,10 @@ export default function UsersPage() {
|
|||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => userEventCategoryLabel(r.eventName),
|
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName', render: (v: string) => eventNameLabel(v) },
|
{ title: '事件', dataIndex: 'eventName' },
|
||||||
{ title: '端', dataIndex: 'clientApp', width: 110, render: (v: string | null) => clientAppLabel(v) },
|
{ title: '端', dataIndex: 'clientApp', width: 90 },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { WECOM_BOT_ACTION_LABELS, zhLabel } from '../lib/display-labels';
|
|
||||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -40,8 +39,8 @@ export default function WecomBotLogsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||||
{ title: '动作', dataIndex: 'action', width: 180, render: (v: string) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||||
{ title: '权限', dataIndex: 'permission', width: 160, render: (v) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '结果',
|
title: '结果',
|
||||||
dataIndex: 'success',
|
dataIndex: 'success',
|
||||||
@@ -82,13 +81,13 @@ export default function WecomBotLogsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="botId" label="机器人 ID">
|
<Form.Item name="botId" label="机器人 ID">
|
||||||
<Input allowClear placeholder="机器人 ID" style={{ width: 140 }} />
|
<Input allowClear placeholder="wecom_bot.id" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="wecomUserId" label="企微用户">
|
<Form.Item name="wecomUserId" label="企微 userid">
|
||||||
<Input allowClear placeholder="企微用户 ID" style={{ width: 140 }} />
|
<Input allowClear placeholder="userid" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="action" label="动作">
|
<Form.Item name="action" label="动作">
|
||||||
<Input allowClear placeholder="动作编码" style={{ width: 140 }} />
|
<Input allowClear placeholder="order.read" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="success" label="结果">
|
<Form.Item name="success" label="结果">
|
||||||
<Select
|
<Select
|
||||||
@@ -131,8 +130,8 @@ export default function WecomBotLogsPage() {
|
|||||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
|
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
|
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="动作">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.action)}</Descriptions.Item>
|
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item>
|
||||||
<Descriptions.Item label="权限">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.permission)}</Descriptions.Item>
|
<Descriptions.Item label="权限">{detail.permission || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
|
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
|
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
|
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return';
|
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||||
const SELECT_STORE_PATH = '/select-store';
|
const SELECT_STORE_PATH = '/select-store';
|
||||||
@@ -19,17 +18,14 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated && location.pathname === '/login') {
|
if (authenticated && location.pathname === '/login') {
|
||||||
const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/');
|
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
||||||
return <Navigate to={next} replace />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
||||||
rememberShopReturnPath(location);
|
|
||||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
rememberShopReturnPath(location);
|
|
||||||
const profile = getStoreProfile();
|
const profile = getStoreProfile();
|
||||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||||
|
|||||||
@@ -1 +1,22 @@
|
|||||||
export { parseRedeemTokenFromScan } from '@dukang/shared-types';
|
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||||
|
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||||
|
const fromQuery = url.searchParams.get('token');
|
||||||
|
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||||
|
return fromQuery.toLowerCase();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not a URL */
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||||
|
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
|
||||||
|
|
||||||
/** iOS 微信内用整页跳转,避免 JSSDK 入场 URL 与 SPA 路径不一致 */
|
|
||||||
export function goShopPath(
|
|
||||||
path: string,
|
|
||||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
|
||||||
opts?: { replace?: boolean },
|
|
||||||
): void {
|
|
||||||
if (shouldHardNavigateForJssdk()) {
|
|
||||||
hardNavigateInWechat(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(path, { replace: opts?.replace ?? true });
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/** 扫核销码落地 /redeem?token= 后未登录或需选店,记下回跳路径(OAuth 整页跳转会丢掉 location.state) */
|
|
||||||
export const SHOP_REDEEM_RETURN_KEY = 'shop_redeem_return';
|
|
||||||
|
|
||||||
function isRedeemReturnPath(path: string): boolean {
|
|
||||||
if (!path.startsWith('/redeem')) return false;
|
|
||||||
if (path.startsWith('/redeem/')) return false;
|
|
||||||
try {
|
|
||||||
const url = new URL(path, 'https://local.invalid');
|
|
||||||
return !!url.searchParams.get('token')?.trim();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rememberShopReturnTo(path: string): void {
|
|
||||||
if (!isRedeemReturnPath(path)) return;
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(SHOP_REDEEM_RETURN_KEY, path);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rememberShopReturnPath(location: { pathname: string; search: string }): void {
|
|
||||||
rememberShopReturnTo(`${location.pathname}${location.search}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function peekShopReturnTo(): string | null {
|
|
||||||
try {
|
|
||||||
const path = sessionStorage.getItem(SHOP_REDEEM_RETURN_KEY);
|
|
||||||
return path && isRedeemReturnPath(path) ? path : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function consumeShopReturnTo(): string | null {
|
|
||||||
const path = peekShopReturnTo();
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(SHOP_REDEEM_RETURN_KEY);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||||
import { consumeShopReturnTo } from '../lib/shop-redeem-return';
|
|
||||||
import { toastError } from '../lib/toast';
|
import { toastError } from '../lib/toast';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ export default function RedeemConfirmPage() {
|
|||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
consumeShopReturnTo();
|
|
||||||
setToken(scanned);
|
setToken(scanned);
|
||||||
}, [searchParams, navigate]);
|
}, [searchParams, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { goShopPath } from '../lib/shop-nav';
|
|
||||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||||
|
|
||||||
@@ -36,12 +35,10 @@ export default function RedeemSuccessPage() {
|
|||||||
});
|
});
|
||||||
}, [redeemNo, amount]);
|
}, [redeemNo, amount]);
|
||||||
|
|
||||||
const goHome = () => goShopPath('/', navigate, { replace: true });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-success-page">
|
<div className="shop-success-page">
|
||||||
<header className="shop-success-header">
|
<header className="shop-success-header">
|
||||||
<button type="button" className="shop-redeem-back" onClick={goHome} aria-label="返回">
|
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回">
|
||||||
<span className="material-symbols-outlined">arrow_back</span>
|
<span className="material-symbols-outlined">arrow_back</span>
|
||||||
</button>
|
</button>
|
||||||
<h1 className="app-page-title">杜康好客</h1>
|
<h1 className="app-page-title">杜康好客</h1>
|
||||||
@@ -88,11 +85,11 @@ export default function RedeemSuccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-success-actions">
|
<div className="shop-success-actions">
|
||||||
<button type="button" className="shop-success-primary-btn" onClick={goHome}>
|
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
|
||||||
<span>继续核销</span>
|
<span>继续核销</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="shop-success-outline-btn" onClick={goHome}>
|
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
||||||
<span>返回首页</span>
|
<span>返回首页</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import {
|
import {
|
||||||
needsStoreSelection,
|
needsStoreSelection,
|
||||||
@@ -9,11 +10,13 @@ import {
|
|||||||
type ShopSessionPayload,
|
type ShopSessionPayload,
|
||||||
type ShopStoreOption,
|
type ShopStoreOption,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { goShopPath } from '../lib/shop-nav';
|
|
||||||
import { consumeShopReturnTo, peekShopReturnTo } from '../lib/shop-redeem-return';
|
|
||||||
|
|
||||||
function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||||
goShopPath(consumeShopReturnTo() || '/', navigate);
|
if (shouldHardNavigateForJssdk()) {
|
||||||
|
hardNavigateInWechat('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SelectStorePage() {
|
export default function SelectStorePage() {
|
||||||
@@ -43,7 +46,7 @@ export default function SelectStorePage() {
|
|||||||
async function onSelect(storeId: string) {
|
async function onSelect(storeId: string) {
|
||||||
if (loadingId) return;
|
if (loadingId) return;
|
||||||
if (storeId === currentStoreId) {
|
if (storeId === currentStoreId) {
|
||||||
goAfterSelectStore(navigate);
|
goShopHome(navigate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingId(storeId);
|
setLoadingId(storeId);
|
||||||
@@ -51,7 +54,7 @@ export default function SelectStorePage() {
|
|||||||
try {
|
try {
|
||||||
const session = await selectStore(storeId);
|
const session = await selectStore(storeId);
|
||||||
applySession(session);
|
applySession(session);
|
||||||
goAfterSelectStore(navigate);
|
goShopHome(navigate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -167,14 +170,16 @@ export default function SelectStorePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** After login/wechat: route to select-store, redeem return, or home */
|
/** After login/wechat: route to select-store or home */
|
||||||
export function routeAfterShopLogin(
|
export function routeAfterShopLogin(
|
||||||
session: ShopSessionPayload,
|
session: ShopSessionPayload,
|
||||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||||
) {
|
) {
|
||||||
if (needsStoreSelection(session)) {
|
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||||
goShopPath('/select-store', navigate);
|
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||||
|
if (shouldHardNavigateForJssdk()) {
|
||||||
|
hardNavigateInWechat(path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
goShopPath(peekShopReturnTo() || '/', navigate);
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.5.10",
|
"version": "3.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 { formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs';
|
import { openWecomCustomerServiceChat } from '../lib/wecom-cs';
|
||||||
|
|
||||||
export type ContactCsSessionContext = {
|
export type ContactCsSessionContext = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
@@ -72,9 +72,7 @@ export default function ContactCsButton({
|
|||||||
}
|
}
|
||||||
toast('请在微信内打开后联系客服');
|
toast('请在微信内打开后联系客服');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[wecom-cs] open failed', e);
|
toast(e instanceof Error ? e.message : '无法打开客服');
|
||||||
const msg = formatOpenCsError(e);
|
|
||||||
if (msg) toast(msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import {
|
|||||||
const CANVAS_ID = 'redeem-qr-canvas';
|
const CANVAS_ID = 'redeem-qr-canvas';
|
||||||
|
|
||||||
type RedeemQrCodeProps = {
|
type RedeemQrCodeProps = {
|
||||||
/** 二维码内容:门店 H5 落地 URL,缺省回退 token */
|
token: string;
|
||||||
payload: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function drawOnWeappCanvas(payload: string) {
|
function drawOnWeappCanvas(token: string) {
|
||||||
const page = Taro.getCurrentInstance().page;
|
const page = Taro.getCurrentInstance().page;
|
||||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||||
query
|
query
|
||||||
@@ -34,33 +33,33 @@ function drawOnWeappCanvas(payload: string) {
|
|||||||
canvas.width = layoutW * dpr;
|
canvas.width = layoutW * dpr;
|
||||||
canvas.height = layoutH * dpr;
|
canvas.height = layoutH * dpr;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
drawRedeemQrOnCanvas(ctx, payload, drawSize);
|
drawRedeemQrOnCanvas(ctx, token, drawSize);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemQrCode({ payload }: RedeemQrCodeProps) {
|
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
|
||||||
const [imgSrc, setImgSrc] = useState('');
|
const [imgSrc, setImgSrc] = useState('');
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payload) {
|
if (!token) {
|
||||||
setImgSrc('');
|
setImgSrc('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeapp) {
|
if (isWeapp) {
|
||||||
const timer = setTimeout(() => drawOnWeappCanvas(payload), 120);
|
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void buildRedeemQrDataUrl(payload).then((url) => {
|
void buildRedeemQrDataUrl(token).then((url) => {
|
||||||
if (!cancelled) setImgSrc(url);
|
if (!cancelled) setImgSrc(url);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [payload, isWeapp]);
|
}, [token, isWeapp]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="redeem-qr-box">
|
<View className="redeem-qr-box">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BRAND_LOGO_URL,
|
BRAND_LOGO_URL,
|
||||||
BRAND_LOGO_WIDE_URL,
|
BRAND_LOGO_WIDE_URL,
|
||||||
CUSTOMER_SERVICE_PHONE,
|
CUSTOMER_SERVICE_PHONE,
|
||||||
CUSTOMER_SERVICE_WECOM_URL,
|
|
||||||
QUALIFICATION_DISCLOSURE_URL,
|
QUALIFICATION_DISCLOSURE_URL,
|
||||||
type ClientRuntimeConfig,
|
type ClientRuntimeConfig,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
@@ -15,8 +14,6 @@ export type BrandAssets = {
|
|||||||
brandLogoMarkUrl: string;
|
brandLogoMarkUrl: string;
|
||||||
qualificationDisclosureUrl: string;
|
qualificationDisclosureUrl: string;
|
||||||
customerServicePhone: string;
|
customerServicePhone: string;
|
||||||
customerServiceWecomUrl: string;
|
|
||||||
wecomCorpId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK: BrandAssets = {
|
const FALLBACK: BrandAssets = {
|
||||||
@@ -25,8 +22,6 @@ const FALLBACK: BrandAssets = {
|
|||||||
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
||||||
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
||||||
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
||||||
customerServiceWecomUrl: CUSTOMER_SERVICE_WECOM_URL,
|
|
||||||
wecomCorpId: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: BrandAssets | null = null;
|
let cached: BrandAssets | null = null;
|
||||||
@@ -40,9 +35,6 @@ function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets
|
|||||||
qualificationDisclosureUrl:
|
qualificationDisclosureUrl:
|
||||||
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
||||||
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
||||||
customerServiceWecomUrl:
|
|
||||||
config?.customerServiceWecomUrl?.trim() || FALLBACK.customerServiceWecomUrl,
|
|
||||||
wecomCorpId: config?.wecomCorpId?.trim() || FALLBACK.wecomCorpId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import { fetchClientConfig } from './pay-wechat';
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||||
export const APP_VERSION = '3.5.10';
|
export const APP_VERSION = '3.5.4';
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const QR_OPTIONS = {
|
|||||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用;payload 为落地 URL 或纯 token) */
|
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
|
||||||
export function drawRedeemQrOnCanvas(
|
export function drawRedeemQrOnCanvas(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
payload: string,
|
token: string,
|
||||||
sizePx = QR_SIZE,
|
sizePx = QR_SIZE,
|
||||||
) {
|
) {
|
||||||
const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' });
|
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
|
||||||
const count = qr.modules.size;
|
const count = qr.modules.size;
|
||||||
const cell = sizePx / count;
|
const cell = sizePx / count;
|
||||||
|
|
||||||
@@ -31,11 +31,11 @@ export function drawRedeemQrOnCanvas(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
||||||
export async function buildRedeemQrDataUrl(payload: string): Promise<string> {
|
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await QRCode.toDataURL(payload, QR_OPTIONS);
|
return await QRCode.toDataURL(token, QR_OPTIONS);
|
||||||
} catch {
|
} catch {
|
||||||
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(payload)}`;
|
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
|
||||||
export type WecomCsSessionContext = {
|
export type WecomCsSessionContext = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
@@ -12,69 +14,28 @@ type OpenCsChatOption = {
|
|||||||
sendMessagePath?: string;
|
sendMessagePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WxCsFail = { errMsg?: string; errCode?: number };
|
type OpenCsChatFn = (option: OpenCsChatOption) => Promise<unknown>;
|
||||||
|
|
||||||
type WxCsChat = {
|
function getTaroOpenCsChat(): OpenCsChatFn | null {
|
||||||
openCustomerServiceChat?: (
|
const api = (Taro as unknown as { openCustomerServiceChat?: OpenCsChatFn }).openCustomerServiceChat;
|
||||||
option: OpenCsChatOption & {
|
return typeof api === 'function' ? api : null;
|
||||||
success?: () => void;
|
}
|
||||||
fail?: (err: WxCsFail) => void;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
function getWxOpenCsChat(): ((option: OpenCsChatOption & {
|
||||||
* 取小程序原生 wx.openCustomerServiceChat。
|
success?: () => void;
|
||||||
* 官方不支持 Promise 风格;Taro 模块作用域下也可能读不到全局 wx。
|
fail?: (err: { errMsg?: string }) => void;
|
||||||
*/
|
}) => void) | null {
|
||||||
function getWxOpenCsChat(): WxCsChat['openCustomerServiceChat'] | null {
|
const wxApi = (
|
||||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
globalThis as {
|
||||||
|
wx?: {
|
||||||
const candidates: Array<WxCsChat | null | undefined> = [];
|
openCustomerServiceChat?: (option: OpenCsChatOption & {
|
||||||
try {
|
success?: () => void;
|
||||||
// eslint-disable-next-line no-undef
|
fail?: (err: { errMsg?: string }) => void;
|
||||||
if (typeof wx !== 'undefined') candidates.push(wx as WxCsChat);
|
}) => void;
|
||||||
} catch {
|
};
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
candidates.push((globalThis as typeof globalThis & { wx?: WxCsChat }).wx);
|
|
||||||
try {
|
|
||||||
const fromRuntime = new Function(
|
|
||||||
'return typeof wx !== "undefined" ? wx : null',
|
|
||||||
)() as WxCsChat | null;
|
|
||||||
candidates.push(fromRuntime);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const api of candidates) {
|
|
||||||
if (api && typeof api.openCustomerServiceChat === 'function') {
|
|
||||||
return api.openCustomerServiceChat.bind(api);
|
|
||||||
}
|
}
|
||||||
}
|
).wx?.openCustomerServiceChat;
|
||||||
return null;
|
return typeof wxApi === 'function' ? wxApi : 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 小程序调起企业微信「微信客服」会话 */
|
/** 小程序调起企业微信「微信客服」会话 */
|
||||||
@@ -89,11 +50,6 @@ 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,
|
||||||
@@ -108,11 +64,22 @@ 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(err),
|
fail: (err) => reject(new Error(err?.errMsg || '无法打开企业微信客服')),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=2`;
|
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ 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 || '' : '');
|
||||||
}
|
}
|
||||||
@@ -114,7 +109,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canSubmit) {
|
if (!canSubmit) {
|
||||||
if (!quantityOk) {
|
if (!quantityOk) {
|
||||||
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
|
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
toast(tip);
|
toast(tip);
|
||||||
return;
|
return;
|
||||||
@@ -175,7 +170,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
const submitLabel = loading
|
const submitLabel = loading
|
||||||
? '提交中…'
|
? '提交中…'
|
||||||
: !quantityOk
|
: !quantityOk
|
||||||
? `至少购买 ${minQty}${unitLabel}`
|
? `至少购买 ${minQty} 瓶`
|
||||||
: '提交订单';
|
: '提交订单';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -228,7 +223,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
</View>
|
</View>
|
||||||
{!quantityOk ? (
|
{!quantityOk ? (
|
||||||
<Text className="order-qty-hint">
|
<Text className="order-qty-hint">
|
||||||
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
|
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export default function OrderConfirmPage() {
|
|||||||
: !addressOk
|
: !addressOk
|
||||||
? '请更换地址'
|
? '请更换地址'
|
||||||
: !quantityOk
|
: !quantityOk
|
||||||
? `至少购买 ${minQty}${unitLabel}`
|
? `至少购买 ${minQty} 瓶`
|
||||||
: '提交订单';
|
: '提交订单';
|
||||||
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}${unitLabel}` : ''}。
|
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PageNavBar from '../../components/PageNavBar';
|
|||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import BenefitFigure from '../../components/BenefitFigure';
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
canPickupOnSite,
|
canPickupOnSite,
|
||||||
normalizeFulfillmentFlags,
|
normalizeFulfillmentFlags,
|
||||||
} from '../../lib/product-fulfillment';
|
} from '../../lib/product-fulfillment';
|
||||||
|
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||||
|
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
buildSceneSharePayload,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
@@ -90,6 +93,7 @@ export default function ProductDetailPage() {
|
|||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const [selected, setSelected] = useState<Record<string, string>>({});
|
const [selected, setSelected] = useState<Record<string, string>>({});
|
||||||
|
const [localHintHtml, setLocalHintHtml] = useState('');
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
@@ -136,12 +140,22 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
loadProduct();
|
loadProduct();
|
||||||
|
void resolveUserCity()
|
||||||
|
.then((city) =>
|
||||||
|
loadLocalDeliveries().then((list) => {
|
||||||
|
const row = matchLocalDelivery(list, {
|
||||||
|
cityCode: getCityCodeForCatalog(city),
|
||||||
|
cityName: city.cityName || city.displayCity || city.city,
|
||||||
|
});
|
||||||
|
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => setLocalHintHtml(resolveLocalDeliveryHintHtml(null)));
|
||||||
});
|
});
|
||||||
|
|
||||||
const attrs = product?.specAttrs ?? [];
|
const attrs = product?.specAttrs ?? [];
|
||||||
const skus = product?.skus ?? [];
|
const skus = product?.skus ?? [];
|
||||||
const onSaleSkuCount = skus.filter((s) => s.status === 'ON_SALE').length;
|
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
|
||||||
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) {
|
||||||
@@ -232,7 +246,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 : 2;
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
|
||||||
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('&')}`;
|
||||||
@@ -318,6 +332,10 @@ export default function ProductDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{allowOnline && localHintHtml ? (
|
||||||
|
<DeliveryHintHtml className="product-detail-fulfillment" html={localHintHtml} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View className="product-detail-promo">
|
<View className="product-detail-promo">
|
||||||
<View className="product-detail-promo-glow" />
|
<View className="product-detail-promo-glow" />
|
||||||
<View className="product-detail-promo-head">
|
<View className="product-detail-promo-head">
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ export default function RedeemCodePage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = decodeURIComponent(router.params.token ?? '');
|
const token = decodeURIComponent(router.params.token ?? '');
|
||||||
const amount = Number(router.params.amount ?? 0);
|
const amount = Number(router.params.amount ?? 0);
|
||||||
const landingUrl = router.params.landingUrl
|
|
||||||
? decodeURIComponent(router.params.landingUrl)
|
|
||||||
: '';
|
|
||||||
const qrPayload = landingUrl || token;
|
|
||||||
|
|
||||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -152,7 +148,7 @@ export default function RedeemCodePage() {
|
|||||||
<View className="redeem-code-panel">
|
<View className="redeem-code-panel">
|
||||||
<Text className="redeem-code-head">请向收银员出示此码</Text>
|
<Text className="redeem-code-head">请向收银员出示此码</Text>
|
||||||
<View className="redeem-qr-wrap">
|
<View className="redeem-qr-wrap">
|
||||||
<RedeemQrCode payload={qrPayload} />
|
<RedeemQrCode token={token} />
|
||||||
</View>
|
</View>
|
||||||
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
|
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
|
||||||
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
|
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
|
||||||
|
|||||||
@@ -113,15 +113,12 @@ export default function RedeemPage() {
|
|||||||
const body: { amount: number; couponId?: string } = { amount: value };
|
const body: { amount: number; couponId?: string } = { amount: value };
|
||||||
if (couponId) body.couponId = couponId;
|
if (couponId) body.couponId = couponId;
|
||||||
|
|
||||||
const data = await request<{ token: string; amount: number; landingUrl?: string }>('/redeem/tokens', {
|
const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: body,
|
data: body,
|
||||||
});
|
});
|
||||||
const landingQs = data.landingUrl
|
|
||||||
? `&landingUrl=${encodeURIComponent(data.landingUrl)}`
|
|
||||||
: '';
|
|
||||||
Taro.navigateTo({
|
Taro.navigateTo({
|
||||||
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}${landingQs}`,
|
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '生成失败');
|
toast(e instanceof Error ? e.message : '生成失败');
|
||||||
|
|||||||
@@ -405,11 +405,13 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{store.category?.name ? (
|
<View className="store-detail-tags">
|
||||||
<View className="store-detail-tags">
|
{store.category?.name ? (
|
||||||
<Text className="store-detail-tag">{store.category.name}</Text>
|
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||||
</View>
|
) : null}
|
||||||
) : null}
|
<Text className="store-detail-tag">可核销</Text>
|
||||||
|
<Text className="store-detail-tag">好客门店</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{marqueeLines.length > 0 ? (
|
{marqueeLines.length > 0 ? (
|
||||||
|
|||||||
@@ -128,6 +128,14 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-fulfillment {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
.product-detail-promo {
|
.product-detail-promo {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -30,8 +30,6 @@
|
|||||||
| 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) |
|
|
||||||
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,8 +83,6 @@
|
|||||||
|
|
||||||
`待付款 → 已付款 → 已完成`;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,13 +65,9 @@
|
|||||||
| 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) | 🔶 开发完成 |
|
|
||||||
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
|
||||||
| 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 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
# 杜康好客 · 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 推送条件展示「结算任务失败通知」,在系统监控组
|
|
||||||
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
|
|
||||||
- [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# 杜康好客 · v3.5.12 开发文档
|
|
||||||
|
|
||||||
> **2026-08-26** · admin-web / API
|
|
||||||
> **主题**:发布会订单大屏循环 BGM;HQ 日志 / 订单状态流转 / 用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏菜单顺序
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 版本目标
|
|
||||||
|
|
||||||
1. 订单大屏内置杜康 BGM,循环播放。
|
|
||||||
2. 右上角提供「播放 BGM / BGM 播放中」开关(浏览器禁止自动出声,需用户点击)。
|
|
||||||
3. 新成交撒花音效响起时,BGM 自动压低,特效结束后恢复。
|
|
||||||
4. HQ 日志、订单详情「状态流转」、用户详情「行为时间线」中的英文码全部改为中文展示。
|
|
||||||
5. 修复 HQ 删除门店分类:接口成功后列表不再自动补种默认树,分类真正消失(有门店占用则停用并明确提示)。
|
|
||||||
6. HQ 左侧导航按业务优先重排:前 11 项固定,系统设置置底。
|
|
||||||
|
|
||||||
**不做**:C 端 / 门店 / 合伙人音频;OSS 签名直链(会过期);成交音效替换;改库内存储码(筛选仍可用事件编码);改权限 key。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 规则
|
|
||||||
|
|
||||||
### 2.1 大屏 BGM
|
|
||||||
|
|
||||||
- 音频文件随 `admin-web` 打包:`apps/admin-web/src/assets/big-screen/dukang-bgm.mp3`(约 2MB)。
|
|
||||||
- BGM 与成交音效分两个 `Audio` 实例,互不打断。
|
|
||||||
- 离开大屏页立即暂停 BGM。
|
|
||||||
|
|
||||||
### 2.2 英文码展示中文
|
|
||||||
|
|
||||||
只改 HQ 展示,不改 API 字段值。对照表在 `apps/admin-web/src/lib/display-labels.ts`。
|
|
||||||
|
|
||||||
| 页面 | 原英文 | 展示 |
|
|
||||||
|------|--------|------|
|
|
||||||
| 日志(用户/门店/合伙人/HQ/第三方/领域事件/企微机器人) | `pay_success`、`USER_MINI`、`ORDER`、`WECHAT_PAY`、`SUCCESS`、`order.read` 等 | 支付成功、C 端小程序、订单、微信支付、成功、查询订单 |
|
|
||||||
| 订单详情 · 状态流转 | `WECHAT_PAY`、`PARTNER_PROXY`、`SYSTEM_AUTO_EXPIRE`;备注里的 `deliveryMode=LOCAL` | 微信支付、合伙人代下单、系统·超时取消;履约方式=同城 |
|
|
||||||
| 用户详情 · 行为时间线 | 分类 `pay`、事件 `pay_success`、端 `USER_H5` | 支付、支付成功、C 端 H5 |
|
|
||||||
|
|
||||||
未知码仍原文回退,避免丢信息。列表筛选项「事件编码 / 对象编码」仍按存储码查询。
|
|
||||||
|
|
||||||
### 2.3 删除门店分类
|
|
||||||
|
|
||||||
根因:`GET /admin/store-categories`(以及 C 端/合伙人分类列表)默认 `ensureDefaults()`。删除无门店占用的默认分类(如「火锅」)后刷新列表会按编码把行再 insert 回来,前端却提示「已处理」。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 列表接口 **不再自动补种**;缺省分类只通过 seed 或 HQ「同步默认分类」补齐。
|
|
||||||
- 无门店占用:硬删除,提示「已删除」。
|
|
||||||
- 有门店占用:改为停用,提示「该分类已有门店占用,已改为停用」。
|
|
||||||
- 仍有下级:拒绝删除(「请先删除或停用下级分类」)。
|
|
||||||
|
|
||||||
### 2.4 HQ 侧栏顺序
|
|
||||||
|
|
||||||
仅改 `apps/admin-web/src/layouts/AdminLayout.tsx` 的 `MENU_ITEMS` 展示顺序与「发票」文案;路由、权限 key 不变。无权限项仍按原规则隐藏。
|
|
||||||
|
|
||||||
固定前 11 项:概览 → 用户 → 商品 → 订单 → 开城 → 门店 → 财务 → 好客权益 → 配送单 → 工单 → 发票。
|
|
||||||
其余:推广码 → 微信绑定 → 企微机器人 → 语言模型 → 知识库 → 开发计划 → OSS 资源库 → 日志 → 权限分配 → HQ账户 → 白名单管理。
|
|
||||||
最后一项:系统设置。
|
|
||||||
|
|
||||||
侧栏「发票」对应原「发票管理」入口(`/invoices`),页内标题仍为发票管理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 关键路径
|
|
||||||
|
|
||||||
| 域 | 路径 |
|
|
||||||
|----|------|
|
|
||||||
| 大屏页 | `apps/admin-web/src/pages/BigScreenPage.tsx` |
|
|
||||||
| 样式 | `apps/admin-web/src/admin.css` |
|
|
||||||
| 音频 | `apps/admin-web/src/assets/big-screen/dukang-bgm.mp3` |
|
|
||||||
| 中文对照 | `apps/admin-web/src/lib/display-labels.ts` |
|
|
||||||
| 日志页 | `UserLogsPage` / `StoreLogsPage` / `PartnerLogsPage` / `HqLogsPage` / `ThirdPartyLogsPage` / `DomainEventsPage` / `WecomBotLogsPage` |
|
|
||||||
| 订单详情 | `OrdersPage.tsx` 状态流转表 |
|
|
||||||
| 用户详情 | `UsersPage.tsx` 行为时间线 |
|
|
||||||
| 门店分类删除 | `store-category.service.ts` · `admin-store-categories.controller.ts` · `StoreCategoriesPage.tsx` |
|
|
||||||
| HQ 侧栏顺序 | `apps/admin-web/src/layouts/AdminLayout.tsx` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 发版步骤
|
|
||||||
|
|
||||||
| 步骤 | 动作 |
|
|
||||||
|------|------|
|
|
||||||
| 1 | `pnpm --filter @dukang/admin-web build` |
|
|
||||||
| 2 | 发 `admin-web` 静态资源 + `dukang-api`(分类删除修复) |
|
|
||||||
|
|
||||||
无 Prisma 迁移。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 验收清单
|
|
||||||
|
|
||||||
- [ ] 打开 `/orders/big-screen`,右上角可见「播放 BGM」
|
|
||||||
- [ ] 点击后循环播放背景音乐,按钮变为「BGM 播放中」;再点暂停
|
|
||||||
- [ ] 新成交撒花时成交音效清晰,BGM 变轻;特效结束后 BGM 恢复
|
|
||||||
- [ ] 离开大屏后背景音乐停止
|
|
||||||
- [ ] 日志列表「事件 / 端 / 关联 / 渠道 / 状态」为中文,详情抽屉字段名不为 `param1` / `eventName`
|
|
||||||
- [ ] 订单详情「状态流转」操作人为中文,备注中的履约方式为中文
|
|
||||||
- [ ] 用户详情「行为时间线」分类、事件、端为中文
|
|
||||||
- [ ] 删除无门店占用的分类后列表不再出现该行,提示「已删除」(再点「同步默认分类」才会补回默认项)
|
|
||||||
- [ ] 删除有门店占用的分类:行仍在但状态为停用,提示「已有门店占用,已改为停用」
|
|
||||||
- [ ] 有下级的一级分类删除被拒绝,提示先处理下级
|
|
||||||
- [ ] HQ 侧栏前 11 项为:概览、用户、商品、订单、开城、门店、财务、好客权益、配送单、工单、发票
|
|
||||||
- [ ] 系统设置在侧栏最后一项
|
|
||||||
+1
-5
@@ -36,7 +36,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
| 链路 | 关键节点 |
|
| 链路 | 关键节点 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| C 购酒 | 登录→开城商品→起购(提货2/同城2/跨城6=1箱,城市配置)→支付→权益1:1→出码/核销→评价 |
|
| C 购酒 | 登录→开城商品→起购(同城2/跨城6)→支付→权益1:1→出码/核销→评价 |
|
||||||
| 门店 | 登录→扫码确认→记录→store_payout T+1 |
|
| 门店 | 登录→扫码确认→记录→store_payout T+1 |
|
||||||
| 合伙人 | 拓店三步→HQ审核→辖区订单/账单 |
|
| 合伙人 | 拓店三步→HQ审核→辖区订单/账单 |
|
||||||
| HQ | 开城/商品/审核/订单/权益/核销/结算/工单 |
|
| HQ | 开城/商品/审核/订单/权益/核销/结算/工单 |
|
||||||
@@ -47,10 +47,6 @@ 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`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
|
||||||
|
|
||||||
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。
|
|
||||||
|
|
||||||
## 5. 验收用例(必过)
|
## 5. 验收用例(必过)
|
||||||
|
|
||||||
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
calcOrderBoxCount,
|
calcOrderBoxCount,
|
||||||
shouldHoldAutoCourierDispatch,
|
shouldHoldAutoCourierDispatch,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
minBottleQtyForDelivery,
|
|
||||||
toBottleQuantity,
|
toBottleQuantity,
|
||||||
toMinSaleQuantity,
|
toMinSaleQuantity,
|
||||||
validateBusinessHours,
|
validateBusinessHours,
|
||||||
@@ -35,54 +34,36 @@ 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, mins).ok).toBe(false);
|
expect(validateMinPurchase('LOCAL', 1, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('LOCAL', 2, mins).ok).toBe(true);
|
expect(validateMinPurchase('LOCAL', 2, 2, 6).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cross city requires 6 bottles', () => {
|
it('cross city requires 6 bottles', () => {
|
||||||
expect(validateMinPurchase('CROSS_CITY', 5, mins).ok).toBe(false);
|
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('CROSS_CITY', 6, mins).ok).toBe(true);
|
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on-site pickup requires pickup min (default 2 bottles)', () => {
|
it('on-site pickup requires at least local min (2 bottles)', () => {
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, mins).ok).toBe(false);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins).ok).toBe(false);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, mins).ok).toBe(true);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on-site pickup uses pickupMinQty independently of localMinQty', () => {
|
it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => {
|
||||||
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, mins, opts).ok).toBe(true);
|
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true);
|
||||||
expect(validateMinPurchase('CROSS_CITY', 1, mins, opts).ok).toBe(true);
|
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true);
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins, opts).ok).toBe(true);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('box SKU message uses 箱', () => {
|
it('box SKU message uses 箱', () => {
|
||||||
const r = validateMinPurchase('LOCAL', 0, mins, { bottlesPerUnit: 6, saleUnit: 'BOX' });
|
const r = validateMinPurchase('LOCAL', 0, 2, 6, { 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,48 +33,36 @@ 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: DeliveryFulfillmentType,
|
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||||
quantity: number,
|
quantity: number,
|
||||||
mins: MinPurchaseMins,
|
localMinQty: number,
|
||||||
|
crossMinQty: number,
|
||||||
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
|
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
|
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
|
||||||
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
|
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
|
||||||
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
|
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
|
||||||
const unit = saleUnitLabel(saleUnit);
|
const unit = saleUnitLabel(saleUnit);
|
||||||
const minBottles = minBottleQtyForDelivery(deliveryType, mins);
|
|
||||||
|
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||||
|
const minBottles = localMinQty > 0 ? localMinQty : 2;
|
||||||
|
if (bottleQty < minBottles) {
|
||||||
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
|
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||||||
if (bottleQty < minBottles) {
|
if (bottleQty < minBottles) {
|
||||||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
const message =
|
return {
|
||||||
deliveryType === 'ON_SITE_PICKUP'
|
ok: false,
|
||||||
? `现场提货至少购买 ${minSale}${unit}`
|
message:
|
||||||
: deliveryType === 'LOCAL'
|
deliveryType === 'LOCAL'
|
||||||
? `同城配送至少购买 ${minSale}${unit}`
|
? `同城配送至少购买 ${minSale}${unit}`
|
||||||
: `跨城配送至少购买 ${minSale}${unit}`;
|
: `跨城配送至少购买 ${minSale}${unit}`,
|
||||||
return { ok: false, message };
|
};
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"test": "vitest run",
|
|
||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5"
|
||||||
"vitest": "^1.6.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export interface ProductDto {
|
|||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
/** 是否允许跨城配送 */
|
/** 是否允许跨城配送 */
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
/** C 端是否展示规格选择(可售 SKU > 1 且已配规格轴) */
|
/** v3.5.4:是否配置了销售规格(多 SKU) */
|
||||||
specEnabled?: boolean;
|
specEnabled?: boolean;
|
||||||
/** 列表轻量:展示 SKU 销售单位 */
|
/** 列表轻量:展示 SKU 销售单位 */
|
||||||
saleUnit?: ProductSaleUnit;
|
saleUnit?: ProductSaleUnit;
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { isXfxProviderCode, sanitizeDeliveryHintHtml } from './fulfillment-provider';
|
|
||||||
|
|
||||||
describe('isXfxProviderCode', () => {
|
|
||||||
it('匹配标准编码与城市前缀', () => {
|
|
||||||
expect(isXfxProviderCode('XFX')).toBe(true);
|
|
||||||
expect(isXfxProviderCode('xiaofeixia')).toBe(true);
|
|
||||||
expect(isXfxProviderCode('ZZXFX')).toBe(true);
|
|
||||||
expect(isXfxProviderCode('XFX_ZZ')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不匹配普通承运商', () => {
|
|
||||||
expect(isXfxProviderCode('')).toBe(false);
|
|
||||||
expect(isXfxProviderCode('LOGISTICS')).toBe(false);
|
|
||||||
expect(isXfxProviderCode('MANUAL')).toBe(false);
|
|
||||||
expect(isXfxProviderCode('SF')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -114,13 +114,8 @@ export interface WarehouseFulfillmentConfig {
|
|||||||
|
|
||||||
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
|
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
|
||||||
|
|
||||||
/** 小飞侠承运商编码:精确 XFX/XIAOFEIXIA,以及城市前缀如 ZZXFX */
|
|
||||||
export function isXfxProviderCode(code: string): boolean {
|
export function isXfxProviderCode(code: string): boolean {
|
||||||
const c = code.trim().toUpperCase();
|
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
|
||||||
if (!c) return false;
|
|
||||||
if ((XFX_PROVIDER_CODES as readonly string[]).includes(c)) return true;
|
|
||||||
if (c.includes('XIAOFEIXIA')) return true;
|
|
||||||
return c.endsWith('XFX') || c.startsWith('XFX');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 承运商未配置提示时 C 端回退文案 */
|
/** 承运商未配置提示时 C 端回退文案 */
|
||||||
@@ -138,29 +133,6 @@ 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(';')
|
||||||
@@ -179,16 +151,6 @@ 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
|
||||||
@@ -204,7 +166,6 @@ 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, '');
|
||||||
@@ -214,12 +175,18 @@ 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}>`;
|
||||||
const style = sanitizeHintStyle(extractHintAttr(String(attrs), 'style'));
|
let 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') {
|
||||||
color = extractHintAttr(String(attrs), 'color');
|
const colorMatch = String(attrs).match(/\scolor\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
size = extractHintAttr(String(attrs), 'size');
|
if (colorMatch) color = (colorMatch[2] ?? colorMatch[3] ?? colorMatch[4] ?? '').trim();
|
||||||
|
const sizeMatch = String(attrs).match(/\ssize\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
|
if (sizeMatch) size = (sizeMatch[2] ?? sizeMatch[3] ?? sizeMatch[4] ?? '').trim();
|
||||||
}
|
}
|
||||||
const extra: string[] = [];
|
const extra: string[] = [];
|
||||||
if (style) extra.push(`style="${style}"`);
|
if (style) extra.push(`style="${style}"`);
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { buildShopRedeemLandingUrl, parseRedeemTokenFromScan } from './redeem';
|
|
||||||
|
|
||||||
const TOKEN = 'a1b2c3d4e5f6789012345678901234ab';
|
|
||||||
|
|
||||||
describe('buildShopRedeemLandingUrl', () => {
|
|
||||||
it('joins shop H5 origin with /redeem?token=', () => {
|
|
||||||
expect(buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN)).toBe(
|
|
||||||
`https://shop.dukanghaoke.com/redeem?token=${TOKEN}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips trailing slash on the base', () => {
|
|
||||||
expect(buildShopRedeemLandingUrl('https://shop-test.dukanghaoke.com/', TOKEN)).toBe(
|
|
||||||
`https://shop-test.dukanghaoke.com/redeem?token=${TOKEN}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseRedeemTokenFromScan', () => {
|
|
||||||
it('accepts a raw 32-hex token', () => {
|
|
||||||
expect(parseRedeemTokenFromScan(TOKEN.toUpperCase())).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts token from shop landing URL', () => {
|
|
||||||
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
|
|
||||||
expect(parseRedeemTokenFromScan(url)).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips WeChat QR_CODE prefix before parsing URL', () => {
|
|
||||||
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
|
|
||||||
expect(parseRedeemTokenFromScan(`QR_CODE,${url}`)).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for empty or unrelated content', () => {
|
|
||||||
expect(parseRedeemTokenFromScan('')).toBeNull();
|
|
||||||
expect(parseRedeemTokenFromScan('https://shop.dukanghaoke.com/records')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,48 +9,6 @@ export interface RedeemTokenResult {
|
|||||||
expireAt: string;
|
expireAt: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
boundStoreId?: string | null;
|
boundStoreId?: string | null;
|
||||||
/** 门店 H5 核销确认页落地 URL(写入二维码,微信扫一扫可直达) */
|
|
||||||
landingUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REDEEM_TOKEN_HEX = /^[a-f0-9]{32}$/i;
|
|
||||||
|
|
||||||
/** 门店核销确认页落地 URL,供用户端核销码使用 */
|
|
||||||
export function buildShopRedeemLandingUrl(shopH5Url: string, token: string): string {
|
|
||||||
const base = shopH5Url.replace(/\/$/, '');
|
|
||||||
return `${base}/redeem?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 微信扫码偶发 `QR_CODE,payload` 前缀 */
|
|
||||||
function stripScanTypePrefix(raw: string): string {
|
|
||||||
const comma = raw.indexOf(',');
|
|
||||||
if (comma > 0 && comma < 24 && /^[A-Z0-9_]+$/.test(raw.slice(0, comma))) {
|
|
||||||
return raw.slice(comma + 1).trim();
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
|
||||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
|
||||||
const trimmed = stripScanTypePrefix(raw.trim());
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
if (REDEEM_TOKEN_HEX.test(trimmed)) {
|
|
||||||
return trimmed.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
|
||||||
const fromQuery = url.searchParams.get('token');
|
|
||||||
if (fromQuery && REDEEM_TOKEN_HEX.test(fromQuery)) {
|
|
||||||
return fromQuery.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* not a URL */
|
|
||||||
}
|
|
||||||
|
|
||||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
|
||||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedeemPreviewDto {
|
export interface RedeemPreviewDto {
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ export interface OrderTrackDto {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
trackingNo?: string | null;
|
trackingNo?: string | null;
|
||||||
logisticsCompany?: string | null;
|
logisticsCompany?: string | null;
|
||||||
/** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */
|
|
||||||
queryError?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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 业务通知 + v3.5.11 结算通知) */
|
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知) */
|
||||||
export const WECOM_PUSH_CONDITIONS = [
|
export const WECOM_PUSH_CONDITIONS = [
|
||||||
'alert.ops',
|
'alert.ops',
|
||||||
'support_ticket.created',
|
'support_ticket.created',
|
||||||
@@ -11,14 +11,9 @@ 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];
|
||||||
@@ -29,20 +24,15 @@ 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<{
|
||||||
@@ -78,20 +68,9 @@ 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: '开发计划',
|
||||||
@@ -136,20 +115,9 @@ 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 =
|
||||||
@@ -163,12 +131,7 @@ 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];
|
||||||
@@ -180,50 +143,14 @@ 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': [
|
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
|
||||||
'orderNo',
|
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
|
||||||
'payAmount',
|
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
|
||||||
'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',
|
||||||
@@ -254,16 +181,6 @@ 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',
|
||||||
@@ -274,39 +191,6 @@ 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 = {
|
||||||
|
|||||||
@@ -9,6 +9,5 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"]
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Generated
-3
@@ -279,9 +279,6 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
|
||||||
specifier: ^1.6.1
|
|
||||||
version: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
|
|
||||||
|
|
||||||
packages/shared-ui:
|
packages/shared-ui:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ MOCK_WECHAT=true
|
|||||||
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL)
|
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL)
|
||||||
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
|
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
|
||||||
# USER_H5_URL=https://user.runxian.top/user
|
# USER_H5_URL=https://user.runxian.top/user
|
||||||
# 门店 H5 落地页(用户核销码二维码链接前缀,SHOP_H5_URL)
|
|
||||||
# 未配置时默认 https://shop.dukanghaoke.com;本地开发可设为 http://localhost:5174
|
|
||||||
# SHOP_H5_URL=https://shop.dukanghaoke.com
|
|
||||||
|
|
||||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||||
TRUST_PROXY=true
|
TRUST_PROXY=true
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ TRUST_PROXY=true
|
|||||||
|
|
||||||
# C 端 H5 落地页(推广码二维码;生产统一入口)
|
# C 端 H5 落地页(推广码二维码;生产统一入口)
|
||||||
USER_H5_URL=https://user.runxian.top/user
|
USER_H5_URL=https://user.runxian.top/user
|
||||||
# 门店 H5 落地页(用户核销码二维码)
|
|
||||||
SHOP_H5_URL=https://shop.dukanghaoke.com
|
|
||||||
|
|
||||||
MOCK_WECHAT=false
|
MOCK_WECHAT=false
|
||||||
WX_APP_ID=
|
WX_APP_ID=
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ TRUST_PROXY=true
|
|||||||
|
|
||||||
# C 端 H5(测试域)
|
# C 端 H5(测试域)
|
||||||
USER_H5_URL=https://user-test.dukanghaoke.com/user
|
USER_H5_URL=https://user-test.dukanghaoke.com/user
|
||||||
# 门店 H5(测试域,核销码落地页)
|
|
||||||
SHOP_H5_URL=https://shop-test.dukanghaoke.com
|
|
||||||
|
|
||||||
# 正式号配置可与生产相同,但 Mock 打开后不走真实支付
|
# 正式号配置可与生产相同,但 Mock 打开后不走真实支付
|
||||||
WX_APP_ID=
|
WX_APP_ID=
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ 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 '订单+核销佣金合计上限',
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
-- v3.5.11:城市履约起购 — 现场提货独立阈值(瓶当量)
|
|
||||||
ALTER TABLE `common_city`
|
|
||||||
ADD COLUMN `pickup_min_qty` INT NOT NULL DEFAULT 2 AFTER `status`;
|
|
||||||
@@ -1027,7 +1027,6 @@ 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,8 +146,6 @@ async function main() {
|
|||||||
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
|
||||||
pickupMinQty: 2,
|
|
||||||
|
|
||||||
localMinQty: 2,
|
localMinQty: 2,
|
||||||
|
|
||||||
crossMinQty: 6,
|
crossMinQty: 6,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ const fixed = {
|
|||||||
AUTO_APPROVE_STORE: 'true',
|
AUTO_APPROVE_STORE: 'true',
|
||||||
TRUST_PROXY: 'true',
|
TRUST_PROXY: 'true',
|
||||||
USER_H5_URL: 'https://user-test.dukanghaoke.com/user',
|
USER_H5_URL: 'https://user-test.dukanghaoke.com/user',
|
||||||
SHOP_H5_URL: 'https://shop-test.dukanghaoke.com',
|
|
||||||
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
|
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
|
||||||
OSS_UPLOAD_PREFIX: 'staging/uploads',
|
OSS_UPLOAD_PREFIX: 'staging/uploads',
|
||||||
WECOM_AIBOT_ENABLED: 'false',
|
WECOM_AIBOT_ENABLED: 'false',
|
||||||
|
|||||||
@@ -63,20 +63,6 @@ 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,9 +7,7 @@ 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,
|
||||||
@@ -24,7 +22,6 @@ 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,
|
||||||
@@ -69,7 +66,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) {
|
||||||
@@ -144,12 +141,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,36 +181,22 @@ 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;仅当 body 仍为上一版默认文案时升级,不覆盖 HQ 手改 */
|
/** 仅插入缺失 eventKey,不覆盖已有文案 */
|
||||||
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) {
|
if (existing) continue;
|
||||||
await this.prisma.wecomPushTemplate.create({
|
await this.prisma.wecomPushTemplate.create({
|
||||||
data: {
|
data: {
|
||||||
eventKey: def.eventKey,
|
eventKey: def.eventKey,
|
||||||
title: def.title,
|
title: def.title,
|
||||||
body: def.body,
|
body: def.body,
|
||||||
handleLabel: def.handleLabel,
|
handleLabel: def.handleLabel,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,9 +447,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
orderNo: 'DK202608200001',
|
orderNo: 'DK202608200001',
|
||||||
payAmount: '199.00',
|
payAmount: '199.00',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
skuSummary: '杜康原浆 52度 瓶×2',
|
skuSummary: '杜康原浆 ×2',
|
||||||
deliveryLabel: '同城',
|
|
||||||
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
|
|
||||||
phoneMasked: '138****8000',
|
phoneMasked: '138****8000',
|
||||||
},
|
},
|
||||||
handlePath: '/orders?orderNo=DK202608200001',
|
handlePath: '/orders?orderNo=DK202608200001',
|
||||||
@@ -483,8 +458,6 @@ 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',
|
||||||
},
|
},
|
||||||
@@ -492,8 +465,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
submitter: '郑州某某商贸 · 张三',
|
partnerLabel: '示例合伙人',
|
||||||
partnerLabel: '郑州某某商贸 · 张三',
|
|
||||||
action: '新建',
|
action: '新建',
|
||||||
storeId: '1',
|
storeId: '1',
|
||||||
},
|
},
|
||||||
@@ -503,7 +475,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
submitter: '郑州某某商贸 · 张三',
|
submitter: '合伙人',
|
||||||
packageCount: '3',
|
packageCount: '3',
|
||||||
requestId: '1',
|
requestId: '1',
|
||||||
},
|
},
|
||||||
@@ -513,7 +485,7 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
cityName: '郑州',
|
cityName: '郑州',
|
||||||
submitter: '门店 · 李四',
|
submitter: '门店',
|
||||||
changedFields: '门店名称、详细地址',
|
changedFields: '门店名称、详细地址',
|
||||||
requestId: '1',
|
requestId: '1',
|
||||||
},
|
},
|
||||||
@@ -529,16 +501,6 @@ 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',
|
||||||
@@ -549,39 +511,6 @@ 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,8 +18,6 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'实付:¥{{payAmount}}',
|
'实付:¥{{payAmount}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'商品:{{skuSummary}}',
|
'商品:{{skuSummary}}',
|
||||||
'物流:{{deliveryLabel}}',
|
|
||||||
'收货:{{receiverInfo}}',
|
|
||||||
'用户:{{phoneMasked}}',
|
'用户:{{phoneMasked}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -35,7 +33,6 @@ 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'),
|
||||||
@@ -48,7 +45,6 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'**门店审核待处理 · {{action}}**',
|
'**门店审核待处理 · {{action}}**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'提交人:{{submitter}}',
|
|
||||||
'合伙人:{{partnerLabel}}',
|
'合伙人:{{partnerLabel}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -62,7 +58,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'**套餐变更待审核**',
|
'**套餐变更待审核**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'提交人:{{submitter}}',
|
'提交端:{{submitter}}',
|
||||||
'套餐条数:{{packageCount}}',
|
'套餐条数:{{packageCount}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -76,7 +72,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'**门店信息变更待审**',
|
'**门店信息变更待审**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
'提交人:{{submitter}}',
|
'提交端:{{submitter}}',
|
||||||
'变更字段:{{changedFields}}',
|
'变更字段:{{changedFields}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
@@ -97,20 +93,6 @@ 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: '发票申请待开票',
|
||||||
@@ -126,110 +108,8 @@ 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: 'settlement',
|
category: 'job',
|
||||||
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: 'settlement',
|
category: 'job',
|
||||||
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: 'settlement',
|
category: 'job',
|
||||||
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: 'settlement',
|
category: 'job',
|
||||||
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: 'settlement',
|
category: 'job',
|
||||||
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: 'settlement',
|
category: 'job',
|
||||||
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: 'settlement',
|
category: 'job',
|
||||||
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,7 +7,6 @@ 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,
|
||||||
@@ -134,7 +133,7 @@ export class CatalogService {
|
|||||||
allowOnSitePickup: flat.allowOnSitePickup,
|
allowOnSitePickup: flat.allowOnSitePickup,
|
||||||
allowOnlinePurchase: flat.allowOnlinePurchase,
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
specEnabled: catalogSpecPickerEnabled(skus, specAttrs.length),
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
saleUnit: flat.saleUnit,
|
saleUnit: flat.saleUnit,
|
||||||
...media,
|
...media,
|
||||||
};
|
};
|
||||||
@@ -197,7 +196,7 @@ export class CatalogService {
|
|||||||
allowOnlinePurchase: flat.allowOnlinePurchase,
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
saleUnit: flat.saleUnit,
|
saleUnit: flat.saleUnit,
|
||||||
specEnabled: catalogSpecPickerEnabled(skus, specAttrs.length),
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
specAttrs: mapSpecAttrsDto(specAttrs),
|
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||||
skus: cSkus,
|
skus: cSkus,
|
||||||
defaultSkuId: defaultSku?.id.toString(),
|
defaultSkuId: defaultSku?.id.toString(),
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ 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]
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { DEFAULT_XFX_LOGISTICS_PRICING } from '@dukang/shared-types';
|
|
||||||
import { calcDeliveryFreightAmount } from './delivery-freight.util';
|
|
||||||
|
|
||||||
describe('calcDeliveryFreightAmount', () => {
|
|
||||||
it('瓶装按小飞侠默认计价:2瓶6元', () => {
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
deliveryType: 'LOCAL',
|
|
||||||
provider: 'XFX',
|
|
||||||
}),
|
|
||||||
).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('箱装先换算瓶数:1箱6瓶=14元', () => {
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 1,
|
|
||||||
bottlesPerUnit: 6,
|
|
||||||
deliveryType: 'LOCAL',
|
|
||||||
provider: 'XFX',
|
|
||||||
}),
|
|
||||||
).toBe(14);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('现场提货不计运费', () => {
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
deliveryType: 'ON_SITE_PICKUP',
|
|
||||||
provider: 'XFX',
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无承运商计价时返回 null;有自定义规则则用之', () => {
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
deliveryType: 'CROSS_CITY',
|
|
||||||
provider: 'LOGISTICS',
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
deliveryType: 'CROSS_CITY',
|
|
||||||
provider: 'LOGISTICS',
|
|
||||||
pricing: { ...DEFAULT_XFX_LOGISTICS_PRICING },
|
|
||||||
}),
|
|
||||||
).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('同城即使 provider=MANUAL 也按小飞侠默认计价', () => {
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
deliveryType: 'LOCAL',
|
|
||||||
provider: 'MANUAL',
|
|
||||||
}),
|
|
||||||
).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('城市小飞侠编码 ZZXFX 使用默认计价', () => {
|
|
||||||
expect(
|
|
||||||
calcDeliveryFreightAmount({
|
|
||||||
quantity: 4,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
deliveryType: 'LOCAL',
|
|
||||||
provider: 'MANUAL',
|
|
||||||
providerCode: 'ZZXFX',
|
|
||||||
}),
|
|
||||||
).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { calcLogisticsFeeByBottles, toBottleQuantity, type LogisticsPricingRule } from '@dukang/domain';
|
|
||||||
import { DEFAULT_XFX_LOGISTICS_PRICING, isXfxProviderCode } from '@dukang/shared-types';
|
|
||||||
|
|
||||||
export type DeliveryFreightInput = {
|
|
||||||
quantity: number;
|
|
||||||
bottlesPerUnit?: number | null;
|
|
||||||
deliveryType?: string | null;
|
|
||||||
provider?: string | null;
|
|
||||||
providerCode?: string | null;
|
|
||||||
pricing?: LogisticsPricingRule | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 当次应付物流费:按瓶当量 + 承运商计价;现场提货 / 无规则返回 null */
|
|
||||||
export function calcDeliveryFreightAmount(input: DeliveryFreightInput): number | null {
|
|
||||||
if (input.deliveryType === 'ON_SITE_PICKUP') return null;
|
|
||||||
const bottles = toBottleQuantity(
|
|
||||||
input.quantity,
|
|
||||||
input.bottlesPerUnit && input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1,
|
|
||||||
);
|
|
||||||
if (bottles <= 0) return null;
|
|
||||||
const code = (input.providerCode || input.provider || '').trim();
|
|
||||||
const useDefaultXfx =
|
|
||||||
isXfxProviderCode(code) || (input.deliveryType === 'LOCAL' && code.toUpperCase() !== 'LOGISTICS');
|
|
||||||
const rule = input.pricing ?? (useDefaultXfx ? { ...DEFAULT_XFX_LOGISTICS_PRICING } : null);
|
|
||||||
if (!rule) return null;
|
|
||||||
try {
|
|
||||||
return calcLogisticsFeeByBottles(bottles, rule);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,21 +90,22 @@ export class FulfillmentProviderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取第一个启用且凭证完整的小飞侠承运商配置(含 ZZXFX 等城市编码) */
|
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
|
||||||
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
|
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
|
||||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
const row = await this.prisma.fulfillmentProvider.findFirst({
|
||||||
where: { status: 'ACTIVE', type: 'API' },
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
type: 'API',
|
||||||
|
code: { in: ['XFX', 'XIAOFEIXIA'] },
|
||||||
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
for (const row of rows) {
|
if (!row?.configJson) return null;
|
||||||
if (!isXfxProviderCode(row.code) || !row.configJson) continue;
|
try {
|
||||||
try {
|
return await this.resolveXiaofeixiaConfig(row.id);
|
||||||
return await this.resolveXiaofeixiaConfig(row.id);
|
} catch {
|
||||||
} catch {
|
return null;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateFulfillmentProviderInput) {
|
async create(input: CreateFulfillmentProviderInput) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
XFX_AUTO_DISPATCH_MAX_BOXES,
|
XFX_AUTO_DISPATCH_MAX_BOXES,
|
||||||
calcOrderBoxCount,
|
calcOrderBoxCount,
|
||||||
shouldHoldAutoCourierDispatch,
|
shouldHoldAutoCourierDispatch,
|
||||||
toBottleQuantity,
|
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { CourierService } from '../../integrations/courier/courier.service';
|
import { CourierService } from '../../integrations/courier/courier.service';
|
||||||
@@ -16,9 +15,6 @@ import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
|||||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||||
import { buildXfxGoodsPayload } from './xfx-goods.util';
|
|
||||||
|
|
||||||
type OrderForXfxDispatch = Order & { product?: { spec: string } | null };
|
|
||||||
|
|
||||||
export type ManualShipInput = {
|
export type ManualShipInput = {
|
||||||
logisticsCompany: string;
|
logisticsCompany: string;
|
||||||
@@ -46,7 +42,7 @@ export class FulfillmentService {
|
|||||||
async dispatchAfterPay(orderId: bigint) {
|
async dispatchAfterPay(orderId: bigint) {
|
||||||
const order = await this.prisma.order.findUnique({
|
const order = await this.prisma.order.findUnique({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
include: { delivery: true, product: { select: { spec: true } } },
|
include: { delivery: true },
|
||||||
});
|
});
|
||||||
if (!order || order.payStatus !== 'PAID') return;
|
if (!order || order.payStatus !== 'PAID') return;
|
||||||
|
|
||||||
@@ -81,10 +77,7 @@ export class FulfillmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
||||||
const bottleQty = toBottleQuantity(
|
const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
|
||||||
order.quantity,
|
|
||||||
order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1,
|
|
||||||
);
|
|
||||||
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
||||||
const boxes = calcOrderBoxCount(bottleQty);
|
const boxes = calcOrderBoxCount(bottleQty);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -125,7 +118,7 @@ export class FulfillmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchApiAuto(order: OrderForXfxDispatch, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||||
if (!isXfxProviderCode(provider.code)) {
|
if (!isXfxProviderCode(provider.code)) {
|
||||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||||
@@ -144,13 +137,6 @@ export class FulfillmentService {
|
|||||||
|
|
||||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
|
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
|
||||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
|
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
|
||||||
const { goodsName, goodsNum } = buildXfxGoodsPayload({
|
|
||||||
productName: order.productName,
|
|
||||||
productSpec: order.productSpec,
|
|
||||||
physicalSpec: order.product?.spec,
|
|
||||||
quantity: order.quantity,
|
|
||||||
bottlesPerUnit: order.bottlesPerUnit,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.courier.createShipment(
|
const result = await this.courier.createShipment(
|
||||||
@@ -169,8 +155,8 @@ export class FulfillmentService {
|
|||||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||||
addressDetail: order.receiverAddress,
|
addressDetail: order.receiverAddress,
|
||||||
},
|
},
|
||||||
goodsName,
|
goodsName: order.productName,
|
||||||
goodsNum,
|
goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
|
||||||
weight: 2,
|
weight: 2,
|
||||||
payMode: CourierPayMode.SENDER,
|
payMode: CourierPayMode.SENDER,
|
||||||
remark: `仓配自动发货 ${order.orderNo}`,
|
remark: `仓配自动发货 ${order.orderNo}`,
|
||||||
@@ -266,12 +252,8 @@ export class FulfillmentService {
|
|||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
include: {
|
include: {
|
||||||
delivery: {
|
delivery: {
|
||||||
include: {
|
include: { signPhotoResource: true },
|
||||||
signPhotoResource: true,
|
|
||||||
fulfillmentProvider: { select: { id: true, code: true } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
fulfillmentWarehouse: { select: { fulfillmentProviderId: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const base = {
|
const base = {
|
||||||
@@ -282,44 +264,35 @@ export class FulfillmentService {
|
|||||||
provider: order?.delivery?.provider,
|
provider: order?.delivery?.provider,
|
||||||
trackingNo: order?.delivery?.trackingNo ?? null,
|
trackingNo: order?.delivery?.trackingNo ?? null,
|
||||||
logisticsCompany: order?.delivery?.logisticsCompany ?? null,
|
logisticsCompany: order?.delivery?.logisticsCompany ?? null,
|
||||||
queryError: null as string | null,
|
|
||||||
};
|
};
|
||||||
if (!order?.delivery) {
|
if (!order?.delivery) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerCode = order.delivery.fulfillmentProvider?.code || String(order.delivery.provider || '');
|
|
||||||
const isXfx =
|
const isXfx =
|
||||||
order.delivery.provider === 'XFX' ||
|
order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || ''));
|
||||||
isXfxProviderCode(providerCode) ||
|
const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo);
|
||||||
order.deliveryType === 'LOCAL';
|
|
||||||
const canQueryCourier = isXfx && !!(order.delivery.trackingNo || order.orderNo);
|
|
||||||
|
|
||||||
if (canQueryCourier) {
|
if (canQueryCourier) {
|
||||||
try {
|
try {
|
||||||
const xiaofeixia = await this.resolveTrackXiaofeixiaConfig({
|
const options = order.delivery.fulfillmentProviderId
|
||||||
delivery: order.delivery,
|
? {
|
||||||
fulfillmentWarehouse: order.fulfillmentWarehouse,
|
xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
|
||||||
});
|
order.delivery.fulfillmentProviderId,
|
||||||
const options = xiaofeixia ? { xiaofeixia } : undefined;
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const shipmentQuery = {
|
const shipmentQuery = {
|
||||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||||
outNumber: order.orderNo,
|
outNumber: order.orderNo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [trackResult, signPhotoDataUris] = await Promise.all([
|
const [nodes, signPhotoDataUris] = await Promise.all([
|
||||||
this.courier
|
this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]),
|
||||||
.getTrack(shipmentQuery, options)
|
|
||||||
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
|
||||||
.catch((err: unknown) => ({
|
|
||||||
nodes: [] as TrackNode[],
|
|
||||||
error: err instanceof Error ? err.message : '查询路由失败',
|
|
||||||
})),
|
|
||||||
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
|
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
base.nodes = this.sortTrackNodesOldestFirst(nodes);
|
||||||
base.queryError = trackResult.error;
|
|
||||||
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
|
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
|
||||||
|
|
||||||
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
||||||
@@ -336,8 +309,8 @@ export class FulfillmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
base.queryError = err instanceof Error ? err.message : '查询路由失败';
|
// fall through
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,28 +327,6 @@ export class FulfillmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveTrackXiaofeixiaConfig(order: {
|
|
||||||
delivery: { fulfillmentProviderId: bigint | null } | null;
|
|
||||||
fulfillmentWarehouse?: { fulfillmentProviderId: bigint | null } | null;
|
|
||||||
}): Promise<XiaofeixiaConfig | null> {
|
|
||||||
const ids = [
|
|
||||||
order.delivery?.fulfillmentProviderId,
|
|
||||||
order.fulfillmentWarehouse?.fulfillmentProviderId,
|
|
||||||
].filter((id): id is bigint => id != null);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const id of ids) {
|
|
||||||
const key = String(id);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
try {
|
|
||||||
return await this.fulfillmentProviderService.resolveXiaofeixiaConfig(id);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
|
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
|
||||||
return [...nodes].sort((a, b) => {
|
return [...nodes].sort((a, b) => {
|
||||||
const ta = new Date(a.createTime).getTime();
|
const ta = new Date(a.createTime).getTime();
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { buildXfxGoodsPayload } from './xfx-goods.util';
|
|
||||||
|
|
||||||
describe('buildXfxGoodsPayload', () => {
|
|
||||||
it('瓶装:品名 + 酒精度,件数等于购买瓶数', () => {
|
|
||||||
expect(
|
|
||||||
buildXfxGoodsPayload({
|
|
||||||
productName: '杜康老窖',
|
|
||||||
productSpec: '单瓶',
|
|
||||||
physicalSpec: '500ml | 53度',
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
goodsName: '杜康老窖 500ml | 53度 单瓶',
|
|
||||||
goodsNum: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('箱装:追加包装规格,件数换算为瓶当量', () => {
|
|
||||||
expect(
|
|
||||||
buildXfxGoodsPayload({
|
|
||||||
productName: '杜康老窖',
|
|
||||||
productSpec: '整箱',
|
|
||||||
physicalSpec: '500ml | 53度',
|
|
||||||
quantity: 2,
|
|
||||||
bottlesPerUnit: 6,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
goodsName: '杜康老窖 500ml | 53度 整箱',
|
|
||||||
goodsNum: 12,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无度数:回落 SKU 规格;再缺失则只用品名', () => {
|
|
||||||
expect(
|
|
||||||
buildXfxGoodsPayload({
|
|
||||||
productName: '杜康老窖',
|
|
||||||
productSpec: '单瓶',
|
|
||||||
physicalSpec: null,
|
|
||||||
quantity: 3,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
goodsName: '杜康老窖 单瓶',
|
|
||||||
goodsNum: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
buildXfxGoodsPayload({
|
|
||||||
productName: '杜康老窖',
|
|
||||||
productSpec: ' ',
|
|
||||||
physicalSpec: undefined,
|
|
||||||
quantity: 1,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
goodsName: '杜康老窖',
|
|
||||||
goodsNum: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('规格重复:不把相同文案拼两次', () => {
|
|
||||||
expect(
|
|
||||||
buildXfxGoodsPayload({
|
|
||||||
productName: '杜康老窖',
|
|
||||||
productSpec: '500ml | 53度',
|
|
||||||
physicalSpec: '500ml | 53度',
|
|
||||||
quantity: 1,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
goodsName: '杜康老窖 500ml | 53度',
|
|
||||||
goodsNum: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
buildXfxGoodsPayload({
|
|
||||||
productName: '杜康老窖',
|
|
||||||
productSpec: '53度',
|
|
||||||
physicalSpec: '500ml | 53度',
|
|
||||||
quantity: 1,
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
}).goodsName,
|
|
||||||
).toBe('杜康老窖 500ml | 53度');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { toBottleQuantity } from '@dukang/domain';
|
|
||||||
|
|
||||||
const GOODS_NAME_MAX_LEN = 128;
|
|
||||||
|
|
||||||
export type XfxGoodsInput = {
|
|
||||||
productName: string;
|
|
||||||
/** SKU 规格快照,如「单瓶 / 整箱」 */
|
|
||||||
productSpec?: string | null;
|
|
||||||
/** SPU 物理规格,如「500ml | 53度」 */
|
|
||||||
physicalSpec?: string | null;
|
|
||||||
quantity: number;
|
|
||||||
bottlesPerUnit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type XfxGoodsPayload = {
|
|
||||||
goodsName: string;
|
|
||||||
goodsNum: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 小飞侠创建运单货品:品名+酒精度规格,件数用瓶当量 */
|
|
||||||
export function buildXfxGoodsPayload(input: XfxGoodsInput): XfxGoodsPayload {
|
|
||||||
const perUnit = input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1;
|
|
||||||
return {
|
|
||||||
goodsName: buildXfxGoodsName(input),
|
|
||||||
goodsNum: toBottleQuantity(input.quantity, perUnit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildXfxGoodsName(input: XfxGoodsInput): string {
|
|
||||||
const name = trimSpec(input.productName);
|
|
||||||
const physical = trimSpec(input.physicalSpec);
|
|
||||||
const skuSpec = trimSpec(input.productSpec);
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (name) parts.push(name);
|
|
||||||
|
|
||||||
if (physical) {
|
|
||||||
parts.push(physical);
|
|
||||||
if (skuSpec && !isRedundantSpec(physical, skuSpec)) {
|
|
||||||
parts.push(skuSpec);
|
|
||||||
}
|
|
||||||
} else if (skuSpec) {
|
|
||||||
parts.push(skuSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, GOODS_NAME_MAX_LEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimSpec(raw?: string | null): string {
|
|
||||||
return (raw ?? '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRedundantSpec(physical: string, skuSpec: string): boolean {
|
|
||||||
const a = physical.replace(/\s+/g, '');
|
|
||||||
const b = skuSpec.replace(/\s+/g, '');
|
|
||||||
if (!b || a === b) return true;
|
|
||||||
return a.includes(b) || b.includes(a);
|
|
||||||
}
|
|
||||||
@@ -143,7 +143,6 @@ 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
|
||||||
|
|||||||
@@ -181,12 +181,7 @@ export class AdminDashboardService {
|
|||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
can('deliveries')
|
can('deliveries')
|
||||||
? this.prisma.orderDelivery.count({
|
? this.prisma.orderDelivery.count({
|
||||||
where: {
|
where: cityFilter ? { order: { cityId: cityFilter } } : undefined,
|
||||||
order: {
|
|
||||||
deliveryType: { not: 'ON_SITE_PICKUP' },
|
|
||||||
...(cityFilter ? { cityId: cityFilter } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
can('finance')
|
can('finance')
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.d
|
|||||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||||
import { buildXfxGoodsPayload } from '../fulfillment/xfx-goods.util';
|
|
||||||
import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util';
|
|
||||||
import { AdminRedeemService } from './admin-redeem.service';
|
import { AdminRedeemService } from './admin-redeem.service';
|
||||||
import {
|
import {
|
||||||
buildExportFilename,
|
buildExportFilename,
|
||||||
@@ -95,16 +93,7 @@ export class AdminOrdersService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
delivery: {
|
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
||||||
select: {
|
|
||||||
provider: true,
|
|
||||||
trackingNo: true,
|
|
||||||
providerOrderNo: true,
|
|
||||||
logisticsCompany: true,
|
|
||||||
manualQueryUrl: true,
|
|
||||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
city: { select: { id: true, name: true, code: true } },
|
city: { select: { id: true, name: true, code: true } },
|
||||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||||
benefitCoupon: {
|
benefitCoupon: {
|
||||||
@@ -115,12 +104,7 @@ export class AdminOrdersService {
|
|||||||
this.prisma.order.count({ where }),
|
this.prisma.order.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({ items, total, page, pageSize });
|
||||||
items: items.map((row) => this.withDeliveryLogisticsFee(row)),
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async previewExport(dto: AdminOrdersExportDto) {
|
async previewExport(dto: AdminOrdersExportDto) {
|
||||||
@@ -256,11 +240,7 @@ export class AdminOrdersService {
|
|||||||
phoneVerifiedAt: true,
|
phoneVerifiedAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
delivery: {
|
delivery: true,
|
||||||
include: {
|
|
||||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
benefitCoupon: {
|
benefitCoupon: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -299,8 +279,7 @@ export class AdminOrdersService {
|
|||||||
? await this.adminRedeemService.buildCouponRedeemTrace(coupon)
|
? await this.adminRedeemService.buildCouponRedeemTrace(coupon)
|
||||||
: { redeemSummary: null, redeemRecords: [] };
|
: { redeemSummary: null, redeemRecords: [] };
|
||||||
|
|
||||||
const withFee = this.withDeliveryLogisticsFee(order);
|
const { benefitCoupon: _coupon, ...orderRest } = order;
|
||||||
const { benefitCoupon: _coupon, ...orderRest } = withFee;
|
|
||||||
|
|
||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
mapOrderCompat({
|
mapOrderCompat({
|
||||||
@@ -341,7 +320,6 @@ export class AdminOrdersService {
|
|||||||
include: {
|
include: {
|
||||||
delivery: true,
|
delivery: true,
|
||||||
fulfillmentWarehouse: true,
|
fulfillmentWarehouse: true,
|
||||||
product: { select: { spec: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
@@ -368,7 +346,7 @@ export class AdminOrdersService {
|
|||||||
});
|
});
|
||||||
order = await this.prisma.order.findUniqueOrThrow({
|
order = await this.prisma.order.findUniqueOrThrow({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { delivery: true, fulfillmentWarehouse: true, product: { select: { spec: true } } },
|
include: { delivery: true, fulfillmentWarehouse: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,13 +371,6 @@ export class AdminOrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaults = this.getShipDefaults(warehouse);
|
const defaults = this.getShipDefaults(warehouse);
|
||||||
const { goodsName, goodsNum } = buildXfxGoodsPayload({
|
|
||||||
productName: order.productName,
|
|
||||||
productSpec: order.productSpec,
|
|
||||||
physicalSpec: order.product?.spec,
|
|
||||||
quantity: order.quantity,
|
|
||||||
bottlesPerUnit: order.bottlesPerUnit,
|
|
||||||
});
|
|
||||||
const shipmentDto: XiaofeixiaCreateShipmentDto = {
|
const shipmentDto: XiaofeixiaCreateShipmentDto = {
|
||||||
outNumber: order.orderNo,
|
outNumber: order.orderNo,
|
||||||
fromName: dto.fromName || defaults.fromName,
|
fromName: dto.fromName || defaults.fromName,
|
||||||
@@ -412,8 +383,8 @@ export class AdminOrdersService {
|
|||||||
toMobile: order.receiverPhone,
|
toMobile: order.receiverPhone,
|
||||||
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||||
toAddressDetail: order.receiverAddress,
|
toAddressDetail: order.receiverAddress,
|
||||||
goodsName,
|
goodsName: order.productName,
|
||||||
goodsNum,
|
goodsNum: order.quantity,
|
||||||
weight: dto.weight ?? defaults.weight,
|
weight: dto.weight ?? defaults.weight,
|
||||||
payMode: dto.payMode || defaults.payMode,
|
payMode: dto.payMode || defaults.payMode,
|
||||||
remark: dto.remark || `HQ发货 ${order.orderNo}`,
|
remark: dto.remark || `HQ发货 ${order.orderNo}`,
|
||||||
@@ -462,34 +433,6 @@ export class AdminOrdersService {
|
|||||||
return this.detail(id);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private withDeliveryLogisticsFee<
|
|
||||||
T extends {
|
|
||||||
quantity: number;
|
|
||||||
bottlesPerUnit: number;
|
|
||||||
deliveryType: string;
|
|
||||||
delivery?: {
|
|
||||||
provider: string;
|
|
||||||
fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null;
|
|
||||||
} | null;
|
|
||||||
},
|
|
||||||
>(order: T): T {
|
|
||||||
if (!order.delivery) return order;
|
|
||||||
const fp = order.delivery.fulfillmentProvider;
|
|
||||||
const logisticsFee = calcDeliveryFreightAmount({
|
|
||||||
quantity: order.quantity,
|
|
||||||
bottlesPerUnit: order.bottlesPerUnit,
|
|
||||||
deliveryType: order.deliveryType,
|
|
||||||
provider: order.delivery.provider,
|
|
||||||
providerCode: fp?.code,
|
|
||||||
pricing: this.fulfillmentProviderService.parsePricingRules(fp?.pricingRulesJson ?? null),
|
|
||||||
});
|
|
||||||
const { fulfillmentProvider: _fp, ...deliveryRest } = order.delivery;
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
delivery: { ...deliveryRest, logisticsFee },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getShipDefaults(warehouse?: {
|
getShipDefaults(warehouse?: {
|
||||||
contactName: string;
|
contactName: string;
|
||||||
contactPhone: string;
|
contactPhone: string;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
|
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
|
||||||
import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util';
|
|
||||||
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
|
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
|
||||||
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
|
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
@@ -336,45 +334,18 @@ export class AdminRedeemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deliveryOrderSelect = {
|
|
||||||
id: true,
|
|
||||||
orderNo: true,
|
|
||||||
status: true,
|
|
||||||
deliveryType: true,
|
|
||||||
productName: true,
|
|
||||||
productSpec: true,
|
|
||||||
barcode69: true,
|
|
||||||
quantity: true,
|
|
||||||
saleUnit: true,
|
|
||||||
bottlesPerUnit: true,
|
|
||||||
payAmount: true,
|
|
||||||
receiverName: true,
|
|
||||||
receiverPhone: true,
|
|
||||||
receiverAddress: true,
|
|
||||||
receiverProvince: true,
|
|
||||||
receiverCity: true,
|
|
||||||
receiverDistrict: true,
|
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
|
||||||
imageResource: { select: { url: true } },
|
|
||||||
} satisfies Prisma.OrderSelect;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminDeliveriesService {
|
export class AdminDeliveriesService {
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list(query: AdminDeliveriesQueryDto) {
|
async list(query: AdminDeliveriesQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const where: Prisma.OrderDeliveryWhereInput = {
|
const where: Prisma.OrderDeliveryWhereInput = {};
|
||||||
order: { deliveryType: { not: 'ON_SITE_PICKUP' } },
|
|
||||||
};
|
|
||||||
if (query.provider) where.provider = query.provider as DeliveryProvider;
|
if (query.provider) where.provider = query.provider as DeliveryProvider;
|
||||||
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
|
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
|
||||||
if (query.orderNo) {
|
if (query.orderNo) {
|
||||||
where.order = { deliveryType: { not: 'ON_SITE_PICKUP' }, orderNo: { contains: query.orderNo } };
|
where.order = { orderNo: { contains: query.orderNo } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -384,53 +355,39 @@ export class AdminDeliveriesService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
order: { select: deliveryOrderSelect },
|
order: {
|
||||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
select: {
|
||||||
|
id: true,
|
||||||
|
orderNo: true,
|
||||||
|
status: true,
|
||||||
|
receiverName: true,
|
||||||
|
receiverPhone: true,
|
||||||
|
deliveryType: true,
|
||||||
|
productName: true,
|
||||||
|
quantity: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.orderDelivery.count({ where }),
|
this.prisma.orderDelivery.count({ where }),
|
||||||
]);
|
]);
|
||||||
return serializeBigInt({
|
return serializeBigInt({ items, total, page, pageSize });
|
||||||
items: items.map((row) => this.withLogisticsFee(row)),
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(id: bigint) {
|
async detail(id: bigint) {
|
||||||
const delivery = await this.prisma.orderDelivery.findUnique({
|
const delivery = await this.prisma.orderDelivery.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
order: { select: deliveryOrderSelect },
|
order: {
|
||||||
fulfillmentProvider: { select: { code: true, pricingRulesJson: true } },
|
include: {
|
||||||
|
user: { select: { id: true, userNo: true, phone: true } },
|
||||||
|
imageResource: { select: { url: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!delivery) throw new NotFoundException('配送单不存在');
|
if (!delivery) throw new NotFoundException('配送单不存在');
|
||||||
return serializeBigInt(this.withLogisticsFee(delivery));
|
return serializeBigInt(delivery);
|
||||||
}
|
|
||||||
|
|
||||||
private withLogisticsFee<
|
|
||||||
T extends {
|
|
||||||
provider: string;
|
|
||||||
order: { quantity: number; bottlesPerUnit: number; deliveryType: string };
|
|
||||||
fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null;
|
|
||||||
},
|
|
||||||
>(row: T) {
|
|
||||||
const { fulfillmentProvider, ...rest } = row;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
logisticsFee: calcDeliveryFreightAmount({
|
|
||||||
quantity: row.order.quantity,
|
|
||||||
bottlesPerUnit: row.order.bottlesPerUnit,
|
|
||||||
deliveryType: row.order.deliveryType,
|
|
||||||
provider: row.provider,
|
|
||||||
providerCode: fulfillmentProvider?.code,
|
|
||||||
pricing: this.fulfillmentProviderService.parsePricingRules(
|
|
||||||
fulfillmentProvider?.pricingRulesJson ?? null,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateDeliveryDto) {
|
async update(id: bigint, dto: UpdateDeliveryDto) {
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export class AdminStoreCategoriesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list() {
|
list() {
|
||||||
return this.categories.listTree({ includeDisabled: true, ensure: false });
|
return this.categories.listTree({ includeDisabled: true, ensure: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('flat')
|
@Get('flat')
|
||||||
listFlat() {
|
listFlat() {
|
||||||
return this.categories.listFlat({ includeDisabled: true, ensure: false });
|
return this.categories.listFlat({ includeDisabled: true, ensure: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('ensure-defaults')
|
@Post('ensure-defaults')
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ 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 {
|
||||||
@@ -58,7 +56,6 @@ 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> {
|
||||||
@@ -779,23 +776,6 @@ 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,18 +554,9 @@ 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,14 +8,12 @@ import {
|
|||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
generateRedeemNo,
|
generateRedeemNo,
|
||||||
generateRedeemPendingNo,
|
generateRedeemPendingNo,
|
||||||
maskContactPhone,
|
|
||||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
resolveSettlementRate,
|
resolveSettlementRate,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
allocateBenefitCoupons,
|
allocateBenefitCoupons,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import {
|
import {
|
||||||
buildShopRedeemLandingUrl,
|
|
||||||
ClientApp,
|
ClientApp,
|
||||||
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
||||||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||||
@@ -26,7 +24,6 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { SettlementService } from '../settlement/settlement.service';
|
import { SettlementService } from '../settlement/settlement.service';
|
||||||
@@ -87,7 +84,6 @@ export class RedeemService {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||||
private readonly wecomPush: WecomMessagePushService,
|
private readonly wecomPush: WecomMessagePushService,
|
||||||
private readonly systemConfig: SystemConfigService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private maskPhoneForStore(phone: string) {
|
private maskPhoneForStore(phone: string) {
|
||||||
@@ -195,10 +191,7 @@ 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({
|
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
|
||||||
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 },
|
||||||
@@ -294,8 +287,6 @@ 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)}`,
|
||||||
@@ -585,13 +576,7 @@ export class RedeemService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
|
||||||
token,
|
|
||||||
expireAt,
|
|
||||||
amount: body.amount,
|
|
||||||
boundStoreId: body.storeId ?? null,
|
|
||||||
landingUrl: buildShopRedeemLandingUrl(this.systemConfig.getAppConfig().shopH5Url, token),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getToken(token: string) {
|
async getToken(token: string) {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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) {
|
||||||
@@ -63,14 +62,6 @@ 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;
|
||||||
@@ -104,59 +95,6 @@ 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(
|
||||||
@@ -646,27 +584,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,7 +651,7 @@ export class SettlementService {
|
|||||||
);
|
);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
level: 'P1',
|
level: 'P1',
|
||||||
category: 'settlement',
|
category: 'finance',
|
||||||
title: '门店提现超时未审',
|
title: '门店提现超时未审',
|
||||||
detail: [
|
detail: [
|
||||||
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
||||||
@@ -930,7 +847,7 @@ export class SettlementService {
|
|||||||
withdrawItem: { is: null },
|
withdrawItem: { is: null },
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
},
|
},
|
||||||
include: { store: { select: { id: true, name: true, settlementRate: true } } },
|
include: { store: { select: { id: true, settlementRate: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const byStore = new Map<string, typeof payouts>();
|
const byStore = new Map<string, typeof payouts>();
|
||||||
@@ -943,10 +860,8 @@ 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 } },
|
||||||
});
|
});
|
||||||
@@ -958,14 +873,13 @@ 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: updatedTotal,
|
payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount),
|
||||||
settlementRate: rate,
|
settlementRate: rate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -975,7 +889,6 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
createdBills.push({ storeId, storeName, amount: updatedTotal });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,31 +914,9 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
createdBills.push({ storeId, storeName, amount: payoutAmount });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const period = billDate.toISOString().slice(0, 10);
|
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1469,43 +1360,17 @@ 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: {
|
select: { id: true, companyName: true },
|
||||||
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 {
|
||||||
const bill = await this.generatePartnerBill(
|
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 });
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.push({
|
results.push({
|
||||||
partnerId: p.id.toString(),
|
partnerId: p.id.toString(),
|
||||||
@@ -1515,10 +1380,6 @@ 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,
|
||||||
@@ -1542,10 +1403,7 @@ export class SettlementService {
|
|||||||
return serializeBigInt(bill);
|
return serializeBigInt(bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generatePartnerBill(
|
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
||||||
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);
|
||||||
@@ -1627,24 +1485,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1935,21 +1775,8 @@ 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: period,
|
billDate: billDate.toISOString().slice(0, 10),
|
||||||
skipped: false,
|
skipped: false,
|
||||||
bill,
|
bill,
|
||||||
});
|
});
|
||||||
@@ -2170,25 +1997,14 @@ 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,
|
||||||
@@ -2203,16 +2019,6 @@ 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(),
|
||||||
@@ -2224,11 +2030,6 @@ 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,
|
||||||
@@ -2237,10 +2038,7 @@ export class SettlementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateLogisticsBill(
|
async generateLogisticsBill(body: { providerId: string; year: number; month: number }) {
|
||||||
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 },
|
||||||
@@ -2392,22 +2190,6 @@ 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),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user