22 Commits

Author SHA1 Message Date
jacy 8f2ebbfef4 merge(dev): 补承运商删除操作常量
CI / verify (push) Waiting to run
2026-08-25 14:32:32 +08:00
jacy f2fbf95c53 merge(dev): v3.5.10 同城配送提示与企微客服
CI / verify (push) Waiting to run
2026-08-25 14:30:43 +08:00
jacy 43de361e61 merge(dev): 列设置靠右与订单状态多选
CI / verify (push) Waiting to run
2026-08-25 14:20:39 +08:00
jacy 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 40e8a12596 Merge pull request 'Dev' (#47) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/47
2026-08-25 10:10:20 +08:00
jacy 3d819dc10b Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#45) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/45
2026-08-25 09:23:12 +08:00
jacy 8984465893 Merge pull request 'Dev' (#43) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/43
2026-08-23 19:27:28 +08:00
jacy 3f208d27a9 Merge pull request 'Dev' (#41) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/41
2026-08-23 17:34:40 +08:00
jacy fa69ced448 Merge pull request 'Dev v3.5.6' (#39) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/39
2026-08-23 16:43:51 +08:00
jacy 18c5abea79 Merge pull request 'v3.5.5版本合并' (#37) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/37
2026-08-23 12:30:04 +08:00
jacy 5d4ed566de merge(dev): HQ product list as SPU with expandable SKUs
CI / verify (push) Has been cancelled
2026-08-21 23:52:41 +08:00
jacy 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +08:00
jacy 22c0b03a47 Merge pull request 'Dev' (#35) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/35
2026-08-21 16:24:07 +08:00
jacy f2d03e2595 Merge pull request 'v3.5.4' (#33) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/33
2026-08-21 15:50:30 +08:00
jacy 0c28c204b2 Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
2026-08-20 20:23:20 +08:00
jacy e7f49a9639 Merge pull request 'v3.5.3版本更新1' (#28) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/28
2026-08-20 18:55:18 +08:00
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +08:00
jacy 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +08:00
jacy 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +08:00
jacy 1440a59fc8 Merge pull request 'Dev' (#21) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/21
2026-08-20 00:54:54 +08:00
jacy 11659484c6 Merge pull request 'v3.5.1 版本更新' (#19) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/19
2026-08-19 16:01:31 +08:00
jacy 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +08:00
44 changed files with 218 additions and 1029 deletions
@@ -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
? '暂无实时路由节点,可使用上方物流查询链接' ? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新' : '暂无路由信息,请稍后刷新'
} }
+2 -12
View File
@@ -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>
-2
View File
@@ -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;
}; };
}; };
+33 -205
View File
@@ -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>
-12
View File
@@ -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 -5
View File
@@ -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 }} />;
+22 -1
View File
@@ -1 +1,22 @@
export { parseRedeemTokenFromScan } from '@dukang/shared-types'; /** 从微信扫码结果解析核销 token32 位 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;
}
-14
View File
@@ -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]);
+3 -6
View File
@@ -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>
+15 -10
View File
@@ -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 });
} }
@@ -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">
-8
View File
@@ -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,
}; };
} }
+6 -6
View File
@@ -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(
} }
/** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */ /** H5Data 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)}`;
} }
} }
@@ -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>
+2 -5
View File
@@ -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 : '生成失败');
+1 -3
View File
@@ -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 端回退文案 */
-39
View File
@@ -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();
});
});
-42
View File
@@ -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;
}
/** 从微信扫码结果解析核销 token32 位 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 {
-2
View File
@@ -95,8 +95,6 @@ export interface OrderTrackDto {
provider?: string; provider?: string;
trackingNo?: string | null; trackingNo?: string | null;
logisticsCompany?: string | null; logisticsCompany?: string | null;
/** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */
queryError?: string | null;
} }
/** 大单拦截原因:≥10 箱不自动推小飞侠 */ /** 大单拦截原因:≥10 箱不自动推小飞侠 */
+1 -2
View File
@@ -9,6 +9,5 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["src/**/*.test.ts"]
} }
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
},
});
-3
View File
@@ -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:
-3
View File
@@ -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=
-2
View File
@@ -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,21 +90,22 @@ export class FulfillmentProviderService {
}; };
} }
/** 取第一个启用且凭证完整的小飞侠承运商配置(含 ZZXFX 等城市编码 */ /** 取第一个启用的小飞侠承运商配置(联调/兼容 */
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> { async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
const rows = await this.prisma.fulfillmentProvider.findMany({ const row = await this.prisma.fulfillmentProvider.findFirst({
where: { status: 'ACTIVE', type: 'API' }, where: {
status: 'ACTIVE',
type: 'API',
code: { in: ['XFX', 'XIAOFEIXIA'] },
},
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}); });
for (const row of rows) { if (!row?.configJson) return null;
if (!isXfxProviderCode(row.code) || !row.configJson) continue; try {
try { return await this.resolveXiaofeixiaConfig(row.id);
return await this.resolveXiaofeixiaConfig(row.id); } catch {
} catch { return null;
continue;
}
} }
return null;
} }
async create(input: CreateFulfillmentProviderInput) { async create(input: CreateFulfillmentProviderInput) {
@@ -6,7 +6,6 @@ import {
XFX_AUTO_DISPATCH_MAX_BOXES, XFX_AUTO_DISPATCH_MAX_BOXES,
calcOrderBoxCount, calcOrderBoxCount,
shouldHoldAutoCourierDispatch, shouldHoldAutoCourierDispatch,
toBottleQuantity,
} from '@dukang/domain'; } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service'; import { CourierService } from '../../integrations/courier/courier.service';
@@ -16,9 +15,6 @@ import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface'; import type { IOssProvider } from '../../integrations/oss/oss.interface';
import { TradeService } from '../trade/trade.service'; import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service'; import { FulfillmentProviderService } from './fulfillment-provider.service';
import { buildXfxGoodsPayload } from './xfx-goods.util';
type OrderForXfxDispatch = Order & { product?: { spec: string } | null };
export type ManualShipInput = { export type ManualShipInput = {
logisticsCompany: string; logisticsCompany: string;
@@ -46,7 +42,7 @@ export class FulfillmentService {
async dispatchAfterPay(orderId: bigint) { async dispatchAfterPay(orderId: bigint) {
const order = await this.prisma.order.findUnique({ const order = await this.prisma.order.findUnique({
where: { id: orderId }, where: { id: orderId },
include: { delivery: true, product: { select: { spec: true } } }, include: { delivery: true },
}); });
if (!order || order.payStatus !== 'PAID') return; if (!order || order.payStatus !== 'PAID') return;
@@ -81,10 +77,7 @@ export class FulfillmentService {
} }
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量) // 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
const bottleQty = toBottleQuantity( const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
order.quantity,
order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1,
);
if (shouldHoldAutoCourierDispatch(bottleQty)) { if (shouldHoldAutoCourierDispatch(bottleQty)) {
const boxes = calcOrderBoxCount(bottleQty); const boxes = calcOrderBoxCount(bottleQty);
this.logger.warn( this.logger.warn(
@@ -125,7 +118,7 @@ export class FulfillmentService {
}); });
} }
async dispatchApiAuto(order: OrderForXfxDispatch, warehouse: CityWarehouse, provider: FulfillmentProvider) { async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!isXfxProviderCode(provider.code)) { if (!isXfxProviderCode(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
@@ -144,13 +137,6 @@ export class FulfillmentService {
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665; const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757; const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
const { goodsName, goodsNum } = buildXfxGoodsPayload({
productName: order.productName,
productSpec: order.productSpec,
physicalSpec: order.product?.spec,
quantity: order.quantity,
bottlesPerUnit: order.bottlesPerUnit,
});
try { try {
const result = await this.courier.createShipment( const result = await this.courier.createShipment(
@@ -169,8 +155,8 @@ export class FulfillmentService {
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
addressDetail: order.receiverAddress, addressDetail: order.receiverAddress,
}, },
goodsName, goodsName: order.productName,
goodsNum, goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
weight: 2, weight: 2,
payMode: CourierPayMode.SENDER, payMode: CourierPayMode.SENDER,
remark: `仓配自动发货 ${order.orderNo}`, remark: `仓配自动发货 ${order.orderNo}`,
@@ -266,12 +252,8 @@ export class FulfillmentService {
where: { id: orderId }, where: { id: orderId },
include: { include: {
delivery: { delivery: {
include: { include: { signPhotoResource: true },
signPhotoResource: true,
fulfillmentProvider: { select: { id: true, code: true } },
},
}, },
fulfillmentWarehouse: { select: { fulfillmentProviderId: true } },
}, },
}); });
const base = { const base = {
@@ -282,44 +264,35 @@ export class FulfillmentService {
provider: order?.delivery?.provider, provider: order?.delivery?.provider,
trackingNo: order?.delivery?.trackingNo ?? null, trackingNo: order?.delivery?.trackingNo ?? null,
logisticsCompany: order?.delivery?.logisticsCompany ?? null, logisticsCompany: order?.delivery?.logisticsCompany ?? null,
queryError: null as string | null,
}; };
if (!order?.delivery) { if (!order?.delivery) {
return base; return base;
} }
const providerCode = order.delivery.fulfillmentProvider?.code || String(order.delivery.provider || '');
const isXfx = const isXfx =
order.delivery.provider === 'XFX' || order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || ''));
isXfxProviderCode(providerCode) || const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo);
order.deliveryType === 'LOCAL';
const canQueryCourier = isXfx && !!(order.delivery.trackingNo || order.orderNo);
if (canQueryCourier) { if (canQueryCourier) {
try { try {
const xiaofeixia = await this.resolveTrackXiaofeixiaConfig({ const options = order.delivery.fulfillmentProviderId
delivery: order.delivery, ? {
fulfillmentWarehouse: order.fulfillmentWarehouse, xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
}); order.delivery.fulfillmentProviderId,
const options = xiaofeixia ? { xiaofeixia } : undefined; ),
}
: undefined;
const shipmentQuery = { const shipmentQuery = {
trackingNumber: order.delivery.trackingNo ?? undefined, trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo, outNumber: order.orderNo,
}; };
const [trackResult, signPhotoDataUris] = await Promise.all([ const [nodes, signPhotoDataUris] = await Promise.all([
this.courier this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]),
.getTrack(shipmentQuery, options)
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
.catch((err: unknown) => ({
nodes: [] as TrackNode[],
error: err instanceof Error ? err.message : '查询路由失败',
})),
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]), this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
]); ]);
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes); base.nodes = this.sortTrackNodesOldestFirst(nodes);
base.queryError = trackResult.error;
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris); base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) { if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
@@ -336,8 +309,8 @@ export class FulfillmentService {
} }
} }
} }
} catch (err) { } catch {
base.queryError = err instanceof Error ? err.message : '查询路由失败'; // fall through
} }
} }
@@ -354,28 +327,6 @@ export class FulfillmentService {
}; };
} }
private async resolveTrackXiaofeixiaConfig(order: {
delivery: { fulfillmentProviderId: bigint | null } | null;
fulfillmentWarehouse?: { fulfillmentProviderId: bigint | null } | null;
}): Promise<XiaofeixiaConfig | null> {
const ids = [
order.delivery?.fulfillmentProviderId,
order.fulfillmentWarehouse?.fulfillmentProviderId,
].filter((id): id is bigint => id != null);
const seen = new Set<string>();
for (const id of ids) {
const key = String(id);
if (seen.has(key)) continue;
seen.add(key);
try {
return await this.fulfillmentProviderService.resolveXiaofeixiaConfig(id);
} catch {
continue;
}
}
return this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
}
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] { private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
return [...nodes].sort((a, b) => { return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime(); const ta = new Date(a.createTime).getTime();
@@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildXfxGoodsPayload } from './xfx-goods.util';
describe('buildXfxGoodsPayload', () => {
it('瓶装:品名 + 酒精度,件数等于购买瓶数', () => {
expect(
buildXfxGoodsPayload({
productName: '杜康老窖',
productSpec: '单瓶',
physicalSpec: '500ml | 53度',
quantity: 2,
bottlesPerUnit: 1,
}),
).toEqual({
goodsName: '杜康老窖 500ml | 53度 单瓶',
goodsNum: 2,
});
});
it('箱装:追加包装规格,件数换算为瓶当量', () => {
expect(
buildXfxGoodsPayload({
productName: '杜康老窖',
productSpec: '整箱',
physicalSpec: '500ml | 53度',
quantity: 2,
bottlesPerUnit: 6,
}),
).toEqual({
goodsName: '杜康老窖 500ml | 53度 整箱',
goodsNum: 12,
});
});
it('无度数:回落 SKU 规格;再缺失则只用品名', () => {
expect(
buildXfxGoodsPayload({
productName: '杜康老窖',
productSpec: '单瓶',
physicalSpec: null,
quantity: 3,
bottlesPerUnit: 1,
}),
).toEqual({
goodsName: '杜康老窖 单瓶',
goodsNum: 3,
});
expect(
buildXfxGoodsPayload({
productName: '杜康老窖',
productSpec: ' ',
physicalSpec: undefined,
quantity: 1,
bottlesPerUnit: 1,
}),
).toEqual({
goodsName: '杜康老窖',
goodsNum: 1,
});
});
it('规格重复:不把相同文案拼两次', () => {
expect(
buildXfxGoodsPayload({
productName: '杜康老窖',
productSpec: '500ml | 53度',
physicalSpec: '500ml | 53度',
quantity: 1,
bottlesPerUnit: 1,
}),
).toEqual({
goodsName: '杜康老窖 500ml | 53度',
goodsNum: 1,
});
expect(
buildXfxGoodsPayload({
productName: '杜康老窖',
productSpec: '53度',
physicalSpec: '500ml | 53度',
quantity: 1,
bottlesPerUnit: 1,
}).goodsName,
).toBe('杜康老窖 500ml | 53度');
});
});
@@ -1,58 +0,0 @@
import { toBottleQuantity } from '@dukang/domain';
const GOODS_NAME_MAX_LEN = 128;
export type XfxGoodsInput = {
productName: string;
/** SKU 规格快照,如「单瓶 / 整箱」 */
productSpec?: string | null;
/** SPU 物理规格,如「500ml | 53度」 */
physicalSpec?: string | null;
quantity: number;
bottlesPerUnit: number;
};
export type XfxGoodsPayload = {
goodsName: string;
goodsNum: number;
};
/** 小飞侠创建运单货品:品名+酒精度规格,件数用瓶当量 */
export function buildXfxGoodsPayload(input: XfxGoodsInput): XfxGoodsPayload {
const perUnit = input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1;
return {
goodsName: buildXfxGoodsName(input),
goodsNum: toBottleQuantity(input.quantity, perUnit),
};
}
function buildXfxGoodsName(input: XfxGoodsInput): string {
const name = trimSpec(input.productName);
const physical = trimSpec(input.physicalSpec);
const skuSpec = trimSpec(input.productSpec);
const parts: string[] = [];
if (name) parts.push(name);
if (physical) {
parts.push(physical);
if (skuSpec && !isRedundantSpec(physical, skuSpec)) {
parts.push(skuSpec);
}
} else if (skuSpec) {
parts.push(skuSpec);
}
return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, GOODS_NAME_MAX_LEN);
}
function trimSpec(raw?: string | null): string {
return (raw ?? '').trim();
}
function isRedundantSpec(physical: string, skuSpec: string): boolean {
const a = physical.replace(/\s+/g, '');
const b = skuSpec.replace(/\s+/g, '');
if (!b || a === b) return true;
return a.includes(b) || b.includes(a);
}
@@ -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,13 +619,11 @@ 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' }, });
});
}
} }
}); });
@@ -2629,13 +2625,11 @@ 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' }, });
});
}
} }
}); });
+1 -2
View File
@@ -19,6 +19,5 @@
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["src/**/*.test.ts"]
} }