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 |
@@ -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/
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||||
: '暂无路由信息,请稍后刷新'
|
: '暂无路由信息,请稍后刷新'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +28,6 @@ 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'] = [
|
||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -674,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,
|
||||||
@@ -1165,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">
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +140,17 @@ 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 ?? [];
|
||||||
@@ -317,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;
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { isXfxProviderCode } from './fulfillment-provider';
|
|
||||||
|
|
||||||
describe('isXfxProviderCode', () => {
|
|
||||||
it('匹配标准编码与城市前缀', () => {
|
|
||||||
expect(isXfxProviderCode('XFX')).toBe(true);
|
|
||||||
expect(isXfxProviderCode('xiaofeixia')).toBe(true);
|
|
||||||
expect(isXfxProviderCode('ZZXFX')).toBe(true);
|
|
||||||
expect(isXfxProviderCode('XFX_ZZ')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不匹配普通承运商', () => {
|
|
||||||
expect(isXfxProviderCode('')).toBe(false);
|
|
||||||
expect(isXfxProviderCode('LOGISTICS')).toBe(false);
|
|
||||||
expect(isXfxProviderCode('MANUAL')).toBe(false);
|
|
||||||
expect(isXfxProviderCode('SF')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -114,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 端回退文案 */
|
||||||
|
|||||||
@@ -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 箱不自动推小飞侠 */
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,22 +90,23 @@ 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 {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: CreateFulfillmentProviderInput) {
|
async create(input: CreateFulfillmentProviderInput) {
|
||||||
const code = input.code.trim().toUpperCase();
|
const code = input.code.trim().toUpperCase();
|
||||||
|
|||||||
@@ -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,13 +252,9 @@ 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 = {
|
||||||
nodes: [] as TrackNode[],
|
nodes: [] as TrackNode[],
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
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,
|
||||||
@@ -25,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';
|
||||||
@@ -86,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) {
|
||||||
@@ -579,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) {
|
||||||
|
|||||||
@@ -243,7 +243,8 @@ export class StorePackageService {
|
|||||||
await this.hqPermissions.assertStoreIdInScope(actorId, storeId);
|
await this.hqPermissions.assertStoreIdInScope(actorId, storeId);
|
||||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||||
if (!store) throw new NotFoundException('门店不存在');
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
return this.getPackagesWithPending(storeId);
|
const live = await this.listLivePackages(storeId);
|
||||||
|
return serializeBigInt({ live });
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[], actorId: bigint) {
|
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[], actorId: bigint) {
|
||||||
|
|||||||
@@ -465,11 +465,9 @@ export class TradeService {
|
|||||||
operator: 'MOCK_PAY',
|
operator: 'MOCK_PAY',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (order.deliveryType !== 'ON_SITE_PICKUP') {
|
|
||||||
await tx.orderDelivery.create({
|
await tx.orderDelivery.create({
|
||||||
data: { orderId: order.id, provider: 'MANUAL' },
|
data: { orderId: order.id, provider: 'MANUAL' },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.afterOrderPaid(order.id);
|
await this.afterOrderPaid(order.id);
|
||||||
@@ -518,12 +516,6 @@ export class TradeService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.deliveryType === 'ON_SITE_PICKUP') {
|
|
||||||
// 现场取货:支付即完成,不建配送单、不推仓配
|
|
||||||
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||||
if (!delivery) {
|
if (!delivery) {
|
||||||
await this.prisma.orderDelivery.create({
|
await this.prisma.orderDelivery.create({
|
||||||
@@ -531,6 +523,12 @@ export class TradeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (order.deliveryType === 'ON_SITE_PICKUP') {
|
||||||
|
// 现场取货:支付后即向微信录入「用户自提」发货信息
|
||||||
|
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.fulfillmentService.dispatchAfterPay(orderId);
|
await this.fulfillmentService.dispatchAfterPay(orderId);
|
||||||
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||||
if (refreshed?.status === 'PENDING_SHIP') {
|
if (refreshed?.status === 'PENDING_SHIP') {
|
||||||
@@ -621,14 +619,12 @@ export class TradeService {
|
|||||||
operator: 'WECHAT_PAY',
|
operator: 'WECHAT_PAY',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (order.deliveryType !== 'ON_SITE_PICKUP') {
|
|
||||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||||
if (!delivery) {
|
if (!delivery) {
|
||||||
await tx.orderDelivery.create({
|
await tx.orderDelivery.create({
|
||||||
data: { orderId: order.id, provider: 'MANUAL' },
|
data: { orderId: order.id, provider: 'MANUAL' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
||||||
@@ -2629,14 +2625,12 @@ export class TradeService {
|
|||||||
operator,
|
operator,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (order.deliveryType !== 'ON_SITE_PICKUP') {
|
|
||||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||||
if (!delivery) {
|
if (!delivery) {
|
||||||
await tx.orderDelivery.create({
|
await tx.orderDelivery.create({
|
||||||
data: { orderId: order.id, provider: 'MANUAL' },
|
data: { orderId: order.id, provider: 'MANUAL' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.afterOrderPaid(order.id);
|
await this.afterOrderPaid(order.id);
|
||||||
|
|||||||
@@ -19,6 +19,5 @@
|
|||||||
"forceConsistentCasingInFileNames": false,
|
"forceConsistentCasingInFileNames": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"]
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user