feat(store): 门店套餐 v3.4.10 全端实现
新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
|
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
|
||||||
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
|
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
|
||||||
| REQ-U-026 | 门店规则 | 附件一完整展示 |
|
| REQ-U-026 | 门店规则 | 附件一完整展示 |
|
||||||
|
| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 |
|
||||||
|
|
||||||
## 门店端 REQ-S
|
## 门店端 REQ-S
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
| REQ-S-018 | 权限 | §4.6 矩阵 |
|
| REQ-S-018 | 权限 | §4.6 矩阵 |
|
||||||
| REQ-S-019 | 多店选店 | 记住上次所选 |
|
| REQ-S-019 | 多店选店 | 记住上次所选 |
|
||||||
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
|
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
|
||||||
|
| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 |
|
||||||
|
|
||||||
## 合伙人端 REQ-P
|
## 合伙人端 REQ-P
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
|
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
|
||||||
| REQ-P-025 | 合同同日 | 同日提交校验 |
|
| REQ-P-025 | 合同同日 | 同日提交校验 |
|
||||||
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
|
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
|
||||||
|
| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 |
|
||||||
|
|
||||||
## 总部端 REQ-H
|
## 总部端 REQ-H
|
||||||
|
|
||||||
@@ -122,3 +125,4 @@
|
|||||||
| REQ-H-022 | 签约主体 | 山西领势酒业 |
|
| REQ-H-022 | 签约主体 | 山西领势酒业 |
|
||||||
| REQ-H-023 | 热力图 | 腾讯位置服务 |
|
| REQ-H-023 | 热力图 | 腾讯位置服务 |
|
||||||
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
|
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
|
||||||
|
| REQ-H-025 | 门店套餐 **3.4.10** | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import OrdersPage from './pages/OrdersPage';
|
import OrdersPage from './pages/OrdersPage';
|
||||||
|
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||||
import StoresPage from './pages/StoresPage';
|
import StoresPage from './pages/StoresPage';
|
||||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||||
@@ -79,6 +80,7 @@ export default function App() {
|
|||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
<Route path="/stores" element={<StoresPage />} />
|
<Route path="/stores" element={<StoresPage />} />
|
||||||
|
<Route path="/store-package-audits" element={<StorePackageAuditsPage />} />
|
||||||
<Route path="/store-ratings" element={<StoreRatingsPage />} />
|
<Route path="/store-ratings" element={<StoreRatingsPage />} />
|
||||||
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Space, Typography, message } from 'antd';
|
||||||
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
|
function emptyRow(index = 0): PackageRow {
|
||||||
|
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
||||||
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => {
|
||||||
|
setItems(
|
||||||
|
data.live?.length
|
||||||
|
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
||||||
|
: [emptyRow()],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const filled = items
|
||||||
|
.map((item, index) => ({
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: item.price.trim(),
|
||||||
|
dishes: item.dishes.trim(),
|
||||||
|
usableTime: item.usableTime?.trim() || null,
|
||||||
|
otherNotes: item.otherNotes?.trim() || null,
|
||||||
|
sortOrder: index,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name || item.dishes || item.price);
|
||||||
|
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const item = filled[i];
|
||||||
|
if (!item.name) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item.dishes) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/stores/${storeId}/packages`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('套餐已保存并生效');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
总部直存立即生效,无需审核。最多 {STORE_PACKAGE_MAX_COUNT} 条。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12, marginBottom: 12 }}>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<Typography.Text strong>套餐 {index + 1}</Typography.Text>
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<input
|
||||||
|
placeholder="套餐名称"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="价格(元)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={item.price}
|
||||||
|
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="菜品"
|
||||||
|
rows={2}
|
||||||
|
value={item.dishes}
|
||||||
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="使用时间"
|
||||||
|
value={item.usableTime || ''}
|
||||||
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="其他说明"
|
||||||
|
value={item.otherNotes || ''}
|
||||||
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 12 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
|
保存套餐
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { StorePackageChangeRequestDto } from '@dukang/shared-types';
|
||||||
|
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
export default function StorePackageAuditsPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [status, setStatus] = useState<string>('PENDING');
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
page: String(nextPage),
|
||||||
|
pageSize: '20',
|
||||||
|
});
|
||||||
|
if (nextStatus) qs.set('status', nextStatus);
|
||||||
|
const data = await request<Paginated<StorePackageChangeRequestDto>>(
|
||||||
|
`/admin/store-package-audits?${qs}`,
|
||||||
|
);
|
||||||
|
setItems(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
setPage(data.page);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload(1, status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-package-audits/${id}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(
|
||||||
|
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||||
|
void reload(page, status);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (v: StorePackageChangeRequestDto['status']) => (
|
||||||
|
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '套餐数',
|
||||||
|
render: (_, row) => row.packages?.length ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交方',
|
||||||
|
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'),
|
||||||
|
},
|
||||||
|
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
render: (_, row) =>
|
||||||
|
row.status === 'PENDING' ? (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setActiveId(row.id);
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
row.rejectReason || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>套餐变更审核</Typography.Title>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||||
|
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => void reload(p, status),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="驳回套餐变更"
|
||||||
|
open={rejectOpen}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
if (!activeId) return;
|
||||||
|
if (!rejectReason.trim()) {
|
||||||
|
message.warning('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void audit(activeId, 'REJECT', rejectReason.trim());
|
||||||
|
setRejectOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={rejectReason}
|
||||||
|
placeholder="驳回原因"
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import { resolveRegionBinding } from '../lib/china-region';
|
|||||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||||
|
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
{ title: '基本信息' },
|
{ title: '基本信息' },
|
||||||
@@ -1214,6 +1215,11 @@ export default function StoresPage() {
|
|||||||
label: '审核材料',
|
label: '审核材料',
|
||||||
children: <StoreAuditMediaSection detail={detail} />,
|
children: <StoreAuditMediaSection detail={detail} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'packages',
|
||||||
|
label: '套餐',
|
||||||
|
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export default function TicketsPage() {
|
|||||||
{ value: 'RESHIPMENT', label: '破损补发' },
|
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||||
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||||
{ value: 'RETURN_REFUND', label: '退货退款' },
|
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||||
|
{ value: 'PACKAGE_DISPUTE', label: '套餐异议' },
|
||||||
{ value: 'ALERT', label: '异常' },
|
{ value: 'ALERT', label: '异常' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SubAccountLayout from './layouts/SubAccountLayout';
|
|||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import StoreListPage from './pages/StoreListPage';
|
import StoreListPage from './pages/StoreListPage';
|
||||||
import StoreDetailPage from './pages/StoreDetailPage';
|
import StoreDetailPage from './pages/StoreDetailPage';
|
||||||
|
import StorePackagesPage from './pages/StorePackagesPage';
|
||||||
import StoreCreatePage from './pages/StoreCreatePage';
|
import StoreCreatePage from './pages/StoreCreatePage';
|
||||||
import OrderListPage from './pages/OrderListPage';
|
import OrderListPage from './pages/OrderListPage';
|
||||||
import OrderDetailPage from './pages/OrderDetailPage';
|
import OrderDetailPage from './pages/OrderDetailPage';
|
||||||
@@ -31,6 +32,7 @@ function PrimaryRoutes() {
|
|||||||
<Route path="/center" element={<CenterPage />} />
|
<Route path="/center" element={<CenterPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
|
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||||
<Route path="/center/bills" element={<BillsPage />} />
|
<Route path="/center/bills" element={<BillsPage />} />
|
||||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||||
<Route path="/center/staff" element={<StaffListPage />} />
|
<Route path="/center/staff" element={<StaffListPage />} />
|
||||||
@@ -68,6 +70,7 @@ function SubAccountRoutes() {
|
|||||||
{!isWarehouse && (
|
{!isWarehouse && (
|
||||||
<>
|
<>
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
|
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { PackageFormItem } from '../lib/storePackages';
|
||||||
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import { emptyPackage } from '../lib/storePackages';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: PackageFormItem[];
|
||||||
|
onChange: (items: PackageFormItem[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StorePackagesForm({ items, onChange, disabled }: Props) {
|
||||||
|
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||||
|
const next = items.map((item, i) => (i === index ? { ...item, ...patch } : item));
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
onChange([...items, emptyPackage(items.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items.length ? items : [emptyPackage(0)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="partner-packages-form">
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<div key={index} className="partner-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-card-title" style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>套餐 {index + 1}</span>
|
||||||
|
{!disabled && list.length > 1 ? (
|
||||||
|
<button type="button" className="partner-link-btn" onClick={() => removeAt(index)}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="partner-field">
|
||||||
|
<span className="partner-label">套餐名称</span>
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
value={item.name}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="如:套餐A"
|
||||||
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="partner-field">
|
||||||
|
<span className="partner-label">价格(元)</span>
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={item.price}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="198"
|
||||||
|
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="partner-field">
|
||||||
|
<span className="partner-label">菜品</span>
|
||||||
|
<textarea
|
||||||
|
className="partner-input"
|
||||||
|
rows={2}
|
||||||
|
value={item.dishes}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||||
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="partner-field">
|
||||||
|
<span className="partner-label">使用时间</span>
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
value={item.usableTime || ''}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="节假日除外"
|
||||||
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="partner-field">
|
||||||
|
<span className="partner-label">其他说明</span>
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
value={item.otherNotes || ''}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="不可叠加"
|
||||||
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||||
|
<button type="button" className="partner-btn-outline" onClick={addItem}>
|
||||||
|
+ 添加套餐
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export type StoreDraftForm = {
|
|||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
|
/** 门店套餐(拓店第 4 步,可空) */
|
||||||
|
packages: import('@dukang/shared-types').StorePackageItemDto[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StoreDraft = {
|
export type StoreDraft = {
|
||||||
@@ -68,6 +70,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
bankAccountName: '',
|
bankAccountName: '',
|
||||||
bankAccountNo: '',
|
bankAccountNo: '',
|
||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
|
packages: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
@@ -102,6 +105,16 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||||
|
packages: Array.isArray(raw.packages)
|
||||||
|
? raw.packages.map((p, i) => ({
|
||||||
|
name: String((p as { name?: string }).name ?? ''),
|
||||||
|
price: String((p as { price?: string | number }).price ?? ''),
|
||||||
|
dishes: String((p as { dishes?: string }).dishes ?? ''),
|
||||||
|
usableTime: (p as { usableTime?: string }).usableTime ?? '',
|
||||||
|
otherNotes: (p as { otherNotes?: string }).otherNotes ?? '',
|
||||||
|
sortOrder: i,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export type PackageFormItem = StorePackageItemDto;
|
||||||
|
|
||||||
|
export function emptyPackage(index = 0): PackageFormItem {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
price: '',
|
||||||
|
dishes: '',
|
||||||
|
usableTime: '',
|
||||||
|
otherNotes: '',
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||||
|
return raw
|
||||||
|
.map((item, index) => ({
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: item.price.trim(),
|
||||||
|
dishes: item.dishes.trim(),
|
||||||
|
usableTime: item.usableTime?.trim() || '',
|
||||||
|
otherNotes: item.otherNotes?.trim() || '',
|
||||||
|
sortOrder: index,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||||
|
const filled = normalizePackageFormItems(items);
|
||||||
|
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
|
||||||
|
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const item = filled[i];
|
||||||
|
if (!item.name) return `第 ${i + 1} 条套餐名称不能为空`;
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||||
|
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPackagePrice(price: string | number) {
|
||||||
|
const n = typeof price === 'number' ? price : Number(price);
|
||||||
|
if (!Number.isFinite(n)) return String(price);
|
||||||
|
return n % 1 === 0 ? String(n) : n.toFixed(2);
|
||||||
|
}
|
||||||
@@ -42,10 +42,10 @@ import {
|
|||||||
patchEnvPhotoAt,
|
patchEnvPhotoAt,
|
||||||
|
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
|
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||||
|
|
||||||
|
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
||||||
|
|
||||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
|
||||||
|
|
||||||
type StoreCategoryNode = {
|
type StoreCategoryNode = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -111,7 +111,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const stepFromUrl = Number(params.get('step') || 0);
|
const stepFromUrl = Number(params.get('step') || 0);
|
||||||
|
|
||||||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
|
const step = stepFromUrl >= 1 && stepFromUrl <= 4 ? stepFromUrl : (saved?.step ?? 1);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -318,13 +318,25 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (step === 3) {
|
||||||
|
const msg = validateStoreStep3(form);
|
||||||
|
if (msg) {
|
||||||
|
if (isPhoneValidationMessage(msg)) {
|
||||||
|
setFieldErrors({ phone: msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reportFormError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
goStep(step + 1);
|
goStep(step + 1);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit(skipPackages = false) {
|
||||||
|
|
||||||
const msg = validateStoreStep3(form);
|
const msg = validateStoreStep3(form);
|
||||||
|
|
||||||
@@ -337,6 +349,15 @@ export default function StoreCreatePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packagesToSubmit = skipPackages ? [] : form.packages || [];
|
||||||
|
if (!skipPackages) {
|
||||||
|
const pkgMsg = validatePackageFormItems(packagesToSubmit);
|
||||||
|
if (pkgMsg) {
|
||||||
|
reportFormError(pkgMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const step1Msg = validateStoreStep1(form);
|
const step1Msg = validateStoreStep1(form);
|
||||||
|
|
||||||
if (step1Msg) {
|
if (step1Msg) {
|
||||||
@@ -472,6 +493,15 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
draftSaveDisabledRef.current = true;
|
draftSaveDisabledRef.current = true;
|
||||||
clearAllStoreDrafts(accountId);
|
clearAllStoreDrafts(accountId);
|
||||||
|
const packages = normalizePackageFormItems(packagesToSubmit);
|
||||||
|
if (packages.length) {
|
||||||
|
await request('PARTNER_H5', `/partner/stores/${result.store.id}/packages`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
packages: packages.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
setForm(defaultStoreForm());
|
setForm(defaultStoreForm());
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
@@ -496,7 +526,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
||||||
|
|
||||||
const nextDisabled = submitting;
|
const nextDisabled = submitting;
|
||||||
|
|
||||||
@@ -1096,6 +1126,20 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<>
|
||||||
|
<section className="partner-section">
|
||||||
|
<h2 className="partner-section-title">门店套餐(可选)</h2>
|
||||||
|
<p className="partner-form-hint">可不填;提交后将进入总部审核。也可跳过此步。</p>
|
||||||
|
<StorePackagesForm
|
||||||
|
items={form.packages?.length ? form.packages : [{ name: '', price: '', dishes: '', usableTime: '', otherNotes: '', sortOrder: 0 }]}
|
||||||
|
onChange={(packages) => setForm((prev) => ({ ...prev, packages }))}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<footer className="partner-sticky-footer">
|
<footer className="partner-sticky-footer">
|
||||||
@@ -1110,7 +1154,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step < 3 ? (
|
{step < 4 ? (
|
||||||
|
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||||
|
|
||||||
@@ -1122,11 +1166,16 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void submit()} disabled={submitting}>
|
<>
|
||||||
|
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
|
||||||
|
跳过
|
||||||
|
</button>
|
||||||
|
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
|
||||||
|
|
||||||
{submitting ? '提交中…' : '提交'}
|
{submitting ? '提交中…' : '提交'}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,18 @@ export default function StoreDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{canMutate && !auditPending && status !== 'CLOSED' && (
|
||||||
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
|
||||||
|
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}>
|
||||||
|
编辑套餐
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>维护可核销套餐信息,提交后由总部审核。</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{canMutate && (
|
{canMutate && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||||
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
import {
|
||||||
|
emptyPackage,
|
||||||
|
normalizePackageFormItems,
|
||||||
|
validatePackageFormItems,
|
||||||
|
type PackageFormItem,
|
||||||
|
} from '../lib/storePackages';
|
||||||
|
|
||||||
|
export default function StorePackagesPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
|
||||||
|
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
request<StorePackagesResponse>('PARTNER_H5', `/partner/stores/${id}/packages`)
|
||||||
|
.then((data) => {
|
||||||
|
const base = data.pendingRequest?.packages?.length
|
||||||
|
? data.pendingRequest.packages
|
||||||
|
: data.live?.length
|
||||||
|
? data.live
|
||||||
|
: [emptyPackage()];
|
||||||
|
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||||
|
setPending(data.pendingRequest ?? null);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
if (!id) return;
|
||||||
|
const msg = validatePackageFormItems(items);
|
||||||
|
if (msg) {
|
||||||
|
toastError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending?.status === 'PENDING') {
|
||||||
|
toastError('已有套餐变更审核中,请等待总部处理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const packages = normalizePackageFormItems(items).map((p) => ({
|
||||||
|
...p,
|
||||||
|
price: Number(p.price).toFixed(2),
|
||||||
|
}));
|
||||||
|
await request('PARTNER_H5', `/partner/stores/${id}/packages`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ packages }),
|
||||||
|
});
|
||||||
|
toastSuccess('已提交审核');
|
||||||
|
navigate(`/stores/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="partner-page">
|
||||||
|
<p className="partner-muted">加载中…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="partner-page partner-page-sticky">
|
||||||
|
<header className="partner-subheader">
|
||||||
|
<button type="button" className="partner-back" onClick={() => navigate(`/stores/${id}`)}>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<h1 className="partner-subheader-title">门店套餐</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? <p className="partner-form-error">{error}</p> : null}
|
||||||
|
{pending?.status === 'PENDING' ? (
|
||||||
|
<p className="partner-form-hint">套餐变更审核中,C 端仍展示上一版生效套餐。</p>
|
||||||
|
) : null}
|
||||||
|
{pending?.status === 'REJECTED' && pending.rejectReason ? (
|
||||||
|
<p className="partner-form-error">上次驳回:{pending.rejectReason}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<StorePackagesForm
|
||||||
|
items={items}
|
||||||
|
onChange={setItems}
|
||||||
|
disabled={pending?.status === 'PENDING' || submitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="partner-sticky-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
disabled={submitting || pending?.status === 'PENDING'}
|
||||||
|
onClick={() => void submitAudit()}
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : '提交审核'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import RecordsPage from './pages/RecordsPage';
|
|||||||
import StatusPage from './pages/StatusPage';
|
import StatusPage from './pages/StatusPage';
|
||||||
import MinePage from './pages/MinePage';
|
import MinePage from './pages/MinePage';
|
||||||
import StaffPage from './pages/StaffPage';
|
import StaffPage from './pages/StaffPage';
|
||||||
import WithdrawPage from './pages/WithdrawPage';
|
import PackagesPage from './pages/PackagesPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +24,7 @@ export default function App() {
|
|||||||
<Route path="/select-store" element={<SelectStorePage />} />
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/withdraw" element={<WithdrawPage />} />
|
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||||
|
<Route path="/packages" element={<PackagesPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export type PackageFormItem = StorePackageItemDto;
|
||||||
|
|
||||||
|
export function emptyPackage(index = 0): PackageFormItem {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
price: '',
|
||||||
|
dishes: '',
|
||||||
|
usableTime: '',
|
||||||
|
otherNotes: '',
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||||
|
return raw
|
||||||
|
.map((item, index) => ({
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: item.price.trim(),
|
||||||
|
dishes: item.dishes.trim(),
|
||||||
|
usableTime: item.usableTime?.trim() || '',
|
||||||
|
otherNotes: item.otherNotes?.trim() || '',
|
||||||
|
sortOrder: index,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||||
|
const filled = normalizePackageFormItems(items);
|
||||||
|
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
|
||||||
|
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const item = filled[i];
|
||||||
|
if (!item.name) return `第 ${i + 1} 条套餐名称不能为空`;
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||||
|
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -145,6 +145,12 @@ export default function MinePage() {
|
|||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
||||||
结算提现
|
结算提现
|
||||||
</button>
|
</button>
|
||||||
|
{profile?.isPrimary ? (
|
||||||
|
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
|
||||||
|
门店套餐
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{profile?.isPrimary ? (
|
{profile?.isPrimary ? (
|
||||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||||
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import {
|
||||||
|
emptyPackage,
|
||||||
|
normalizePackageFormItems,
|
||||||
|
validatePackageFormItems,
|
||||||
|
type PackageFormItem,
|
||||||
|
} from '../lib/storePackages';
|
||||||
|
|
||||||
|
export default function PackagesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
|
||||||
|
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<StorePackagesResponse>('SHOP_H5', '/shop/store/packages')
|
||||||
|
.then((data) => {
|
||||||
|
const base = data.pendingRequest?.packages?.length
|
||||||
|
? data.pendingRequest.packages
|
||||||
|
: data.live?.length
|
||||||
|
? data.live
|
||||||
|
: [emptyPackage()];
|
||||||
|
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||||
|
setPending(data.pendingRequest ?? null);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
setItems((prev) => [...prev, emptyPackage(prev.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
const msg = validatePackageFormItems(items);
|
||||||
|
if (msg) {
|
||||||
|
setError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending?.status === 'PENDING') {
|
||||||
|
setError('已有套餐变更审核中,请等待总部处理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const packages = normalizePackageFormItems(items).map((p) => ({
|
||||||
|
...p,
|
||||||
|
price: Number(p.price).toFixed(2),
|
||||||
|
}));
|
||||||
|
await request('SHOP_H5', '/shop/store/packages', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ packages }),
|
||||||
|
});
|
||||||
|
navigate('/mine');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items.length ? items : [emptyPackage()];
|
||||||
|
const disabled = pending?.status === 'PENDING' || submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-page">
|
||||||
|
<header className="shop-subheader">
|
||||||
|
<button type="button" className="shop-back" onClick={() => navigate('/mine')}>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<h1>门店套餐</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? <p className="shop-muted">加载中…</p> : null}
|
||||||
|
{error ? <p className="shop-error">{error}</p> : null}
|
||||||
|
{pending?.status === 'PENDING' ? (
|
||||||
|
<p className="shop-hint">套餐变更审核中,用户端仍展示上一版生效套餐。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading
|
||||||
|
? list.map((item, index) => (
|
||||||
|
<div key={index} className="shop-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="shop-card-head">
|
||||||
|
<strong>套餐 {index + 1}</strong>
|
||||||
|
{!disabled && list.length > 1 ? (
|
||||||
|
<button type="button" onClick={() => removeAt(index)}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input
|
||||||
|
value={item.name}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
价格(元)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={item.price}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
菜品
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={item.dishes}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
使用时间
|
||||||
|
<input
|
||||||
|
value={item.usableTime || ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
其他说明
|
||||||
|
<input
|
||||||
|
value={item.otherNotes || ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||||
|
<button type="button" className="shop-btn-outline" onClick={addItem}>
|
||||||
|
+ 添加套餐
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-btn-primary"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => void submitAudit()}
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : '提交审核'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image, Textarea } from '@tarojs/components';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
useLoad,
|
useLoad,
|
||||||
@@ -28,6 +28,15 @@ type StoreMedia = {
|
|||||||
mediaType?: string | null;
|
mediaType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StorePackage = {
|
||||||
|
name: string;
|
||||||
|
price: string | number;
|
||||||
|
dishes: string;
|
||||||
|
usableTime?: string | null;
|
||||||
|
otherNotes?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Store = {
|
type Store = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,6 +51,7 @@ type Store = {
|
|||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
carouselUrls?: string[] | null;
|
carouselUrls?: string[] | null;
|
||||||
media?: StoreMedia[] | null;
|
media?: StoreMedia[] | null;
|
||||||
|
packages?: StorePackage[] | null;
|
||||||
openTime?: string | null;
|
openTime?: string | null;
|
||||||
closeTime?: string | null;
|
closeTime?: string | null;
|
||||||
openTime2?: string | null;
|
openTime2?: string | null;
|
||||||
@@ -142,6 +152,9 @@ export default function StoreDetailPage() {
|
|||||||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [disputeOpen, setDisputeOpen] = useState(false);
|
||||||
|
const [disputeRemark, setDisputeRemark] = useState('');
|
||||||
|
const [disputeSubmitting, setDisputeSubmitting] = useState(false);
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
@@ -263,6 +276,27 @@ export default function StoreDetailPage() {
|
|||||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitPackageDispute() {
|
||||||
|
if (!storeId) return;
|
||||||
|
setDisputeSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request('/trade/package-disputes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
storeId,
|
||||||
|
remark: disputeRemark.trim() || '用户对门店套餐有异议',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
toast('已提交套餐异议');
|
||||||
|
setDisputeOpen(false);
|
||||||
|
setDisputeRemark('');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setDisputeSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openMap() {
|
function openMap() {
|
||||||
if (!store) return;
|
if (!store) return;
|
||||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||||
@@ -389,6 +423,29 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{store.packages && store.packages.length > 0 ? (
|
||||||
|
<View className="store-detail-section">
|
||||||
|
<Text className="store-detail-section-title">门店套餐</Text>
|
||||||
|
{store.packages.map((pkg, index) => (
|
||||||
|
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
|
||||||
|
<Text className="store-detail-package-name">{pkg.name}</Text>
|
||||||
|
<Text className="store-detail-package-body">
|
||||||
|
{formatRedeemAmountYuan(pkg.price)} 元 · {pkg.dishes}
|
||||||
|
</Text>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<Text className="store-detail-package-meta">使用时间:{pkg.usableTime}</Text>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<Text className="store-detail-package-meta">说明:{pkg.otherNotes}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Text className="store-detail-package-dispute" onClick={() => setDisputeOpen(true)}>
|
||||||
|
对套餐有异议?提交反馈
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{intro ? (
|
{intro ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店详情</Text>
|
<Text className="store-detail-section-title">门店详情</Text>
|
||||||
@@ -420,6 +477,33 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{disputeOpen ? (
|
||||||
|
<View className="store-detail-dispute-mask" onClick={() => !disputeSubmitting && setDisputeOpen(false)}>
|
||||||
|
<View className="store-detail-dispute-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Text className="store-detail-section-title">套餐异议</Text>
|
||||||
|
<Text className="store-detail-package-meta">请描述您对门店套餐内容的异议,客服将跟进处理。</Text>
|
||||||
|
<Textarea
|
||||||
|
className="store-detail-dispute-input"
|
||||||
|
value={disputeRemark}
|
||||||
|
maxlength={500}
|
||||||
|
placeholder="请填写异议说明(选填)"
|
||||||
|
onInput={(e) => setDisputeRemark(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<View className="store-detail-dispute-actions">
|
||||||
|
<View className="u-btn u-btn--ghost" onClick={() => setDisputeOpen(false)}>
|
||||||
|
<Text>取消</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="u-btn"
|
||||||
|
onClick={() => !disputeSubmitting && void submitPackageDispute()}
|
||||||
|
>
|
||||||
|
<Text>{disputeSubmitting ? '提交中…' : '提交'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View className="store-detail-bar">
|
<View className="store-detail-bar">
|
||||||
<View
|
<View
|
||||||
className="u-btn u-btn--block"
|
className="u-btn u-btn--block"
|
||||||
|
|||||||
@@ -234,3 +234,85 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-detail-package-card {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-card:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary, #1a1a1a);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-body {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-meta {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-tertiary, #999);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-dispute {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-dispute-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-dispute-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-dispute-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-dispute-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-dispute-actions .u-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-dispute-actions .u-btn--ghost {
|
||||||
|
background: rgba(166, 29, 36, 0.08);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from './invoice';
|
|||||||
export * from './user-log';
|
export * from './user-log';
|
||||||
|
|
||||||
export * from './store-log';
|
export * from './store-log';
|
||||||
|
export * from './store-package';
|
||||||
export * from './partner-log';
|
export * from './partner-log';
|
||||||
export * from './promo';
|
export * from './promo';
|
||||||
export * from './hq-permissions';
|
export * from './hq-permissions';
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/** 门店套餐(v3.4.10) */
|
||||||
|
export interface StorePackageItemDto {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
dishes: string;
|
||||||
|
usableTime?: string | null;
|
||||||
|
otherNotes?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorePackageViewDto extends StorePackageItemDto {
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorePackageChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||||
|
|
||||||
|
export type StorePackageSubmitterType = 'PARTNER' | 'SHOP';
|
||||||
|
|
||||||
|
export const STORE_PACKAGE_CHANGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
|
PENDING: '审核中',
|
||||||
|
APPROVED: '已通过',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORE_PACKAGE_MAX_COUNT = 10;
|
||||||
|
|
||||||
|
export interface StorePackagesResponse {
|
||||||
|
live: StorePackageViewDto[];
|
||||||
|
pendingRequest?: {
|
||||||
|
id: string;
|
||||||
|
status: StorePackageChangeStatus;
|
||||||
|
packages: StorePackageItemDto[];
|
||||||
|
rejectReason?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorePackageChangeRequestDto {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName?: string;
|
||||||
|
status: StorePackageChangeStatus;
|
||||||
|
packages: StorePackageItemDto[];
|
||||||
|
submitterType: StorePackageSubmitterType;
|
||||||
|
submitterId: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorePackageAuditAction {
|
||||||
|
action: 'APPROVE' | 'REJECT';
|
||||||
|
rejectReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePackageDisputeRequest {
|
||||||
|
storeId: string;
|
||||||
|
remark?: string;
|
||||||
|
redeemRecordId?: string;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/** 售后工单类型(PRD 四类型 + 系统 ALERT) */
|
/** 售后工单类型(PRD 四类型 + 系统 ALERT + 套餐异议) */
|
||||||
export type TicketTypeDto =
|
export type TicketTypeDto =
|
||||||
| 'REFUND'
|
| 'REFUND'
|
||||||
| 'RESHIPMENT'
|
| 'RESHIPMENT'
|
||||||
| 'ALERT'
|
| 'ALERT'
|
||||||
| 'DAMAGE_RETURN'
|
| 'DAMAGE_RETURN'
|
||||||
| 'RETURN_REFUND';
|
| 'RETURN_REFUND'
|
||||||
|
| 'PACKAGE_DISPUTE';
|
||||||
|
|
||||||
/** 用户可发起的售后类型 */
|
/** 用户可发起的售后类型 */
|
||||||
export const AFTER_SALE_TICKET_TYPES = [
|
export const AFTER_SALE_TICKET_TYPES = [
|
||||||
@@ -12,6 +13,7 @@ export const AFTER_SALE_TICKET_TYPES = [
|
|||||||
'RESHIPMENT',
|
'RESHIPMENT',
|
||||||
'DAMAGE_RETURN',
|
'DAMAGE_RETURN',
|
||||||
'RETURN_REFUND',
|
'RETURN_REFUND',
|
||||||
|
'PACKAGE_DISPUTE',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AfterSaleTicketType = (typeof AFTER_SALE_TICKET_TYPES)[number];
|
export type AfterSaleTicketType = (typeof AFTER_SALE_TICKET_TYPES)[number];
|
||||||
@@ -22,6 +24,7 @@ export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
|
|||||||
ALERT: '异常',
|
ALERT: '异常',
|
||||||
DAMAGE_RETURN: '破损退货',
|
DAMAGE_RETURN: '破损退货',
|
||||||
RETURN_REFUND: '退货退款',
|
RETURN_REFUND: '退货退款',
|
||||||
|
PACKAGE_DISPUTE: '套餐异议',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TicketDto {
|
export interface TicketDto {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ enum TicketType {
|
|||||||
ALERT
|
ALERT
|
||||||
DAMAGE_RETURN
|
DAMAGE_RETURN
|
||||||
RETURN_REFUND
|
RETURN_REFUND
|
||||||
|
PACKAGE_DISPUTE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SupportTicketType {
|
enum SupportTicketType {
|
||||||
@@ -256,6 +257,17 @@ enum StoreAuditStatus {
|
|||||||
REJECTED
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StorePackageChangeStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StorePackageSubmitterType {
|
||||||
|
PARTNER
|
||||||
|
SHOP
|
||||||
|
}
|
||||||
|
|
||||||
enum UserSourceType {
|
enum UserSourceType {
|
||||||
ORGANIC
|
ORGANIC
|
||||||
PROMO_CODE
|
PROMO_CODE
|
||||||
@@ -1093,6 +1105,8 @@ model Store {
|
|||||||
storeBills StoreBill[]
|
storeBills StoreBill[]
|
||||||
withdrawRequests StoreWithdrawRequest[]
|
withdrawRequests StoreWithdrawRequest[]
|
||||||
visibilityPhones StoreVisibilityPhone[]
|
visibilityPhones StoreVisibilityPhone[]
|
||||||
|
packages StorePackage[]
|
||||||
|
packageChangeRequests StorePackageChangeRequest[]
|
||||||
|
|
||||||
@@index([cityId, status])
|
@@index([cityId, status])
|
||||||
@@index([partnerAccountId])
|
@@index([partnerAccountId])
|
||||||
@@ -1114,6 +1128,43 @@ model StoreVisibilityPhone {
|
|||||||
@@map("store_visibility_phone")
|
@@map("store_visibility_phone")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StorePackage {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||||
|
name String @db.VarChar(128)
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
dishes String @db.Text
|
||||||
|
usableTime String? @map("usable_time") @db.VarChar(256)
|
||||||
|
otherNotes String? @map("other_notes") @db.VarChar(512)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([storeId, sortOrder])
|
||||||
|
@@map("store_package")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StorePackageChangeRequest {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||||
|
status StorePackageChangeStatus @default(PENDING)
|
||||||
|
packagesJson Json @map("packages_json")
|
||||||
|
submitterType StorePackageSubmitterType @map("submitter_type")
|
||||||
|
submitterId BigInt @map("submitter_id") @db.UnsignedBigInt
|
||||||
|
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||||
|
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||||
|
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([storeId, status])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("store_package_change_request")
|
||||||
|
}
|
||||||
|
|
||||||
model StoreAccount {
|
model StoreAccount {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
phone String @unique @db.VarChar(20)
|
phone String @unique @db.VarChar(20)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class CreateEventDto {
|
|||||||
|
|
||||||
export class CreateTicketDto {
|
export class CreateTicketDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||||
ticketType: string;
|
ticketType: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -172,7 +172,7 @@ export class CreateTicketDto {
|
|||||||
|
|
||||||
export class AdminCreateTicketDto {
|
export class AdminCreateTicketDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||||
ticketType: string;
|
ticketType: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { StorePackageService } from './store-package.service';
|
||||||
|
|
||||||
|
@Controller('partner/stores')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PartnerStorePackageController {
|
||||||
|
constructor(private readonly packages: StorePackageService) {}
|
||||||
|
|
||||||
|
@Get(':storeId/packages')
|
||||||
|
getPackages(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
|
||||||
|
return this.packages.getPartnerPackages(user.actorId, BigInt(storeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':storeId/packages')
|
||||||
|
submitPackages(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Body() body: { packages: unknown },
|
||||||
|
) {
|
||||||
|
const normalized = this.packages.normalizePackages(body.packages);
|
||||||
|
return this.packages.submitPartnerChangeRequest(user.actorId, BigInt(storeId), normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId/package-change-requests')
|
||||||
|
listRequests(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
|
||||||
|
return this.packages.listPartnerChangeRequests(user.actorId, BigInt(storeId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('shop/store')
|
||||||
|
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||||
|
export class ShopStorePackageController {
|
||||||
|
constructor(private readonly packages: StorePackageService) {}
|
||||||
|
|
||||||
|
@Get('packages')
|
||||||
|
getPackages(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.packages.getShopPackages(user.actorId, user.storeId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('packages')
|
||||||
|
submitPackages(@CurrentUser() user: AuthUser, @Body() body: { packages: unknown }) {
|
||||||
|
const normalized = this.packages.normalizePackages(body.packages);
|
||||||
|
return this.packages.submitShopChangeRequest(user.actorId, user.storeId!, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/stores')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminStorePackageController {
|
||||||
|
constructor(private readonly packages: StorePackageService) {}
|
||||||
|
|
||||||
|
@Get(':storeId/packages')
|
||||||
|
getPackages(@Param('storeId') storeId: string) {
|
||||||
|
return this.packages.adminGetPackages(BigInt(storeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':storeId/packages')
|
||||||
|
savePackages(@Param('storeId') storeId: string, @Body() body: { packages: unknown }) {
|
||||||
|
const normalized = this.packages.normalizePackages(body.packages);
|
||||||
|
return this.packages.adminDirectSave(BigInt(storeId), normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/store-package-audits')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminStorePackageAuditController {
|
||||||
|
constructor(private readonly packages: StorePackageService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.packages.adminListAudits({
|
||||||
|
status: status || undefined,
|
||||||
|
page: page ? Number(page) : undefined,
|
||||||
|
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':requestId/audit')
|
||||||
|
audit(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('requestId') requestId: string,
|
||||||
|
@Body() body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string },
|
||||||
|
) {
|
||||||
|
return this.packages.adminAuditRequest(BigInt(requestId), user.actorId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('stores')
|
||||||
|
export class PublicStorePackageController {
|
||||||
|
constructor(private readonly packages: StorePackageService) {}
|
||||||
|
|
||||||
|
@Get(':storeId/packages')
|
||||||
|
list(@Param('storeId') storeId: string) {
|
||||||
|
return this.packages.listLivePackages(BigInt(storeId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { STORE_PACKAGE_MAX_COUNT, type StorePackageItemDto } from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { StoreService } from './store.service';
|
||||||
|
|
||||||
|
type PackageInput = Record<string, unknown>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorePackageService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeService: StoreService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
normalizePackages(raw: unknown): StorePackageItemDto[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new BadRequestException('packages 须为数组');
|
||||||
|
}
|
||||||
|
if (raw.length > STORE_PACKAGE_MAX_COUNT) {
|
||||||
|
throw new BadRequestException(`套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`);
|
||||||
|
}
|
||||||
|
return raw.map((item, index) => this.normalizeOne(item as PackageInput, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeOne(item: PackageInput, index: number): StorePackageItemDto {
|
||||||
|
const name = String(item.name ?? '').trim();
|
||||||
|
if (!name) throw new BadRequestException(`第 ${index + 1} 条套餐名称不能为空`);
|
||||||
|
const priceRaw = item.price;
|
||||||
|
const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(String(priceRaw ?? '').trim());
|
||||||
|
if (!Number.isFinite(priceNum) || priceNum < 0) {
|
||||||
|
throw new BadRequestException(`第 ${index + 1} 条套餐价格须为非负数字`);
|
||||||
|
}
|
||||||
|
const dishes = String(item.dishes ?? '').trim();
|
||||||
|
if (!dishes) throw new BadRequestException(`第 ${index + 1} 条套餐菜品不能为空`);
|
||||||
|
const usableTime = item.usableTime != null && String(item.usableTime).trim()
|
||||||
|
? String(item.usableTime).trim()
|
||||||
|
: null;
|
||||||
|
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
|
||||||
|
? String(item.otherNotes).trim()
|
||||||
|
: null;
|
||||||
|
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
price: priceNum.toFixed(2),
|
||||||
|
dishes,
|
||||||
|
usableTime,
|
||||||
|
otherNotes,
|
||||||
|
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapLiveRow(row: {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
price: Prisma.Decimal;
|
||||||
|
dishes: string;
|
||||||
|
usableTime: string | null;
|
||||||
|
otherNotes: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
name: row.name,
|
||||||
|
price: row.price.toFixed(2),
|
||||||
|
dishes: row.dishes,
|
||||||
|
usableTime: row.usableTime,
|
||||||
|
otherNotes: row.otherNotes,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLivePackages(storeId: bigint) {
|
||||||
|
const rows = await this.prisma.storePackage.findMany({
|
||||||
|
where: { storeId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return rows.map((r) => this.mapLiveRow(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPartnerPackages(partnerAccountId: bigint, storeId: bigint) {
|
||||||
|
await this.storeService.partnerGetStore(partnerAccountId, storeId);
|
||||||
|
return this.getPackagesWithPending(storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShopPackages(storeAccountId: bigint, storeId: bigint) {
|
||||||
|
await this.storeService.getShopStore(storeAccountId, storeId);
|
||||||
|
return this.getPackagesWithPending(storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPackagesWithPending(storeId: bigint) {
|
||||||
|
const [live, pendingRequest] = await Promise.all([
|
||||||
|
this.listLivePackages(storeId),
|
||||||
|
this.prisma.storePackageChangeRequest.findFirst({
|
||||||
|
where: { storeId, status: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({
|
||||||
|
live,
|
||||||
|
pendingRequest: pendingRequest
|
||||||
|
? {
|
||||||
|
id: pendingRequest.id.toString(),
|
||||||
|
status: pendingRequest.status,
|
||||||
|
packages: pendingRequest.packagesJson as unknown as StorePackageItemDto[],
|
||||||
|
rejectReason: pendingRequest.rejectReason,
|
||||||
|
createdAt: pendingRequest.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPartnerChangeRequest(
|
||||||
|
partnerAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
packages: StorePackageItemDto[],
|
||||||
|
) {
|
||||||
|
await this.storeService.partnerGetStore(partnerAccountId, storeId);
|
||||||
|
return this.submitChangeRequest(storeId, packages, 'PARTNER', partnerAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitShopChangeRequest(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
packages: StorePackageItemDto[],
|
||||||
|
) {
|
||||||
|
await this.storeService.getShopStore(storeAccountId, storeId);
|
||||||
|
return this.submitChangeRequest(storeId, packages, 'SHOP', storeAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submitChangeRequest(
|
||||||
|
storeId: bigint,
|
||||||
|
packages: StorePackageItemDto[],
|
||||||
|
submitterType: 'PARTNER' | 'SHOP',
|
||||||
|
submitterId: bigint,
|
||||||
|
) {
|
||||||
|
const existing = await this.prisma.storePackageChangeRequest.findFirst({
|
||||||
|
where: { storeId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException('该门店已有套餐变更审核中,请等待总部处理');
|
||||||
|
}
|
||||||
|
const req = await this.prisma.storePackageChangeRequest.create({
|
||||||
|
data: {
|
||||||
|
storeId,
|
||||||
|
status: 'PENDING',
|
||||||
|
packagesJson: packages as unknown as Prisma.InputJsonValue,
|
||||||
|
submitterType,
|
||||||
|
submitterId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt({
|
||||||
|
id: req.id.toString(),
|
||||||
|
status: req.status,
|
||||||
|
createdAt: req.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPartnerChangeRequests(partnerAccountId: bigint, storeId: bigint) {
|
||||||
|
await this.storeService.partnerGetStore(partnerAccountId, storeId);
|
||||||
|
return this.listChangeRequests(storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listChangeRequests(storeId: bigint) {
|
||||||
|
const rows = await this.prisma.storePackageChangeRequest.findMany({
|
||||||
|
where: { storeId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
return serializeBigInt(
|
||||||
|
rows.map((r) => ({
|
||||||
|
id: r.id.toString(),
|
||||||
|
storeId: r.storeId.toString(),
|
||||||
|
status: r.status,
|
||||||
|
packages: r.packagesJson as unknown as StorePackageItemDto[],
|
||||||
|
submitterType: r.submitterType,
|
||||||
|
submitterId: r.submitterId.toString(),
|
||||||
|
rejectReason: r.rejectReason,
|
||||||
|
reviewedAt: r.reviewedAt?.toISOString() ?? null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminGetPackages(storeId: bigint) {
|
||||||
|
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
const live = await this.listLivePackages(storeId);
|
||||||
|
return serializeBigInt({ live });
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[]) {
|
||||||
|
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
await this.replaceLivePackages(storeId, packages);
|
||||||
|
const live = await this.listLivePackages(storeId);
|
||||||
|
return serializeBigInt({ live });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceLivePackages(storeId: bigint, packages: StorePackageItemDto[]) {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.storePackage.deleteMany({ where: { storeId } }),
|
||||||
|
...packages.map((pkg, index) =>
|
||||||
|
this.prisma.storePackage.create({
|
||||||
|
data: {
|
||||||
|
storeId,
|
||||||
|
name: pkg.name,
|
||||||
|
price: pkg.price,
|
||||||
|
dishes: pkg.dishes,
|
||||||
|
usableTime: pkg.usableTime ?? null,
|
||||||
|
otherNotes: pkg.otherNotes ?? null,
|
||||||
|
sortOrder: pkg.sortOrder ?? index,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: Prisma.StorePackageChangeRequestWhereInput = {};
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status as Prisma.EnumStorePackageChangeStatusFilter['equals'];
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.storePackageChangeRequest.findMany({
|
||||||
|
where,
|
||||||
|
include: { store: { select: { id: true, name: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.storePackageChangeRequest.count({ where }),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((r) => ({
|
||||||
|
id: r.id.toString(),
|
||||||
|
storeId: r.storeId.toString(),
|
||||||
|
storeName: r.store.name,
|
||||||
|
status: r.status,
|
||||||
|
packages: r.packagesJson as unknown as StorePackageItemDto[],
|
||||||
|
submitterType: r.submitterType,
|
||||||
|
submitterId: r.submitterId.toString(),
|
||||||
|
rejectReason: r.rejectReason,
|
||||||
|
reviewedAt: r.reviewedAt?.toISOString() ?? null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminAuditRequest(
|
||||||
|
requestId: bigint,
|
||||||
|
reviewerId: bigint,
|
||||||
|
body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string },
|
||||||
|
) {
|
||||||
|
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
});
|
||||||
|
if (!req) throw new NotFoundException('审核记录不存在');
|
||||||
|
if (req.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('该记录已处理');
|
||||||
|
}
|
||||||
|
if (body.action === 'REJECT') {
|
||||||
|
const reason = String(body.rejectReason ?? '').trim();
|
||||||
|
if (!reason) throw new BadRequestException('请填写驳回原因');
|
||||||
|
const updated = await this.prisma.storePackageChangeRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectReason: reason,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt({ id: updated.id.toString(), status: updated.status });
|
||||||
|
}
|
||||||
|
const packages = req.packagesJson as unknown as StorePackageItemDto[];
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.storePackage.deleteMany({ where: { storeId: req.storeId } });
|
||||||
|
for (let i = 0; i < packages.length; i++) {
|
||||||
|
const pkg = packages[i];
|
||||||
|
await tx.storePackage.create({
|
||||||
|
data: {
|
||||||
|
storeId: req.storeId,
|
||||||
|
name: pkg.name,
|
||||||
|
price: pkg.price,
|
||||||
|
dishes: pkg.dishes,
|
||||||
|
usableTime: pkg.usableTime ?? null,
|
||||||
|
otherNotes: pkg.otherNotes ?? null,
|
||||||
|
sortOrder: pkg.sortOrder ?? i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.storePackageChangeRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewerId,
|
||||||
|
rejectReason: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ import {
|
|||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
ShopStoreController,
|
ShopStoreController,
|
||||||
} from './store.controller';
|
} from './store.controller';
|
||||||
|
import {
|
||||||
|
AdminStorePackageAuditController,
|
||||||
|
AdminStorePackageController,
|
||||||
|
PartnerStorePackageController,
|
||||||
|
PublicStorePackageController,
|
||||||
|
ShopStorePackageController,
|
||||||
|
} from './store-package.controller';
|
||||||
|
import { StorePackageService } from './store-package.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,15 +35,20 @@ import {
|
|||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
|
PublicStorePackageController,
|
||||||
PublicStoreCategoriesController,
|
PublicStoreCategoriesController,
|
||||||
PartnerStoreCategoriesController,
|
PartnerStoreCategoriesController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
|
PartnerStorePackageController,
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
PartnerReportController,
|
PartnerReportController,
|
||||||
ShopStoreController,
|
ShopStoreController,
|
||||||
|
ShopStorePackageController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
|
AdminStorePackageController,
|
||||||
|
AdminStorePackageAuditController,
|
||||||
],
|
],
|
||||||
providers: [StoreService, StoreCategoryService],
|
providers: [StoreService, StoreCategoryService, StorePackageService],
|
||||||
exports: [StoreService, StoreCategoryService],
|
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
@@ -208,6 +208,10 @@ export class StoreService {
|
|||||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
});
|
});
|
||||||
|
const packageRows = await this.prisma.storePackage.findMany({
|
||||||
|
where: { storeId: id },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
mapStoreCompat({
|
mapStoreCompat({
|
||||||
@@ -215,6 +219,14 @@ export class StoreService {
|
|||||||
latitude: coords?.latitude ?? store.latitude,
|
latitude: coords?.latitude ?? store.latitude,
|
||||||
longitude: coords?.longitude ?? store.longitude,
|
longitude: coords?.longitude ?? store.longitude,
|
||||||
media,
|
media,
|
||||||
|
packages: packageRows.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
price: p.price.toFixed(2),
|
||||||
|
dishes: p.dishes,
|
||||||
|
usableTime: p.usableTime,
|
||||||
|
otherNotes: p.otherNotes,
|
||||||
|
sortOrder: p.sortOrder,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
export class CreateAfterSaleTicketDto {
|
export class CreateAfterSaleTicketDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||||
ticketType: string;
|
ticketType: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -21,6 +21,21 @@ export class CreateAfterSaleTicketDto {
|
|||||||
evidenceUrls?: string[];
|
evidenceUrls?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreatePackageDisputeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
storeId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redeemRecordId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateInvoiceDto {
|
export class CreateInvoiceDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
PartnerProxyOrderPreviewDto,
|
PartnerProxyOrderPreviewDto,
|
||||||
} from './dto/partner-proxy-order.dto';
|
} from './dto/partner-proxy-order.dto';
|
||||||
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
||||||
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
|
import { CreateAfterSaleTicketDto, CreateInvoiceDto, CreatePackageDisputeDto } from './dto/after-sale.dto';
|
||||||
|
|
||||||
@Controller('trade/orders')
|
@Controller('trade/orders')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -103,6 +103,17 @@ export class TradeController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('trade/package-disputes')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TradePackageDisputeController {
|
||||||
|
constructor(private readonly tradeService: TradeService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: CreatePackageDisputeDto) {
|
||||||
|
return this.tradeService.createPackageDispute(user.actorId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('trade/after-sale-tickets')
|
@Controller('trade/after-sale-tickets')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class TradeAfterSaleTicketController {
|
export class TradeAfterSaleTicketController {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PartnerReshipmentController,
|
PartnerReshipmentController,
|
||||||
TradeAfterSaleTicketController,
|
TradeAfterSaleTicketController,
|
||||||
TradeInvoiceController,
|
TradeInvoiceController,
|
||||||
|
TradePackageDisputeController,
|
||||||
} from './trade.controller';
|
} from './trade.controller';
|
||||||
import { TradeService } from './trade.service';
|
import { TradeService } from './trade.service';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ import { TradeService } from './trade.service';
|
|||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
TradeController,
|
TradeController,
|
||||||
|
TradePackageDisputeController,
|
||||||
TradeAfterSaleTicketController,
|
TradeAfterSaleTicketController,
|
||||||
TradeInvoiceController,
|
TradeInvoiceController,
|
||||||
PartnerOrderController,
|
PartnerOrderController,
|
||||||
|
|||||||
@@ -730,6 +730,40 @@ export class TradeService {
|
|||||||
return serializeBigInt({ ...ticket, orderNo: order.orderNo, orderStatus: order.status });
|
return serializeBigInt({ ...ticket, orderNo: order.orderNo, orderStatus: order.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createPackageDispute(
|
||||||
|
userId: bigint,
|
||||||
|
body: { storeId: string; remark?: string; redeemRecordId?: string },
|
||||||
|
) {
|
||||||
|
const storeId = BigInt(body.storeId);
|
||||||
|
const store = await this.prisma.store.findFirst({
|
||||||
|
where: { id: storeId, status: 'OPEN' },
|
||||||
|
});
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
|
||||||
|
const pending = await this.prisma.commonTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
ticketType: 'PACKAGE_DISPUTE',
|
||||||
|
refType: 'STORE',
|
||||||
|
refId: storeId,
|
||||||
|
status: { in: ['PENDING', 'OPEN'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pending) throw new BadRequestException('该门店套餐异议已在处理中');
|
||||||
|
|
||||||
|
const extraJson: Record<string, unknown> = { userId: userId.toString() };
|
||||||
|
if (body.redeemRecordId) {
|
||||||
|
extraJson.redeemRecordId = body.redeemRecordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ticketService.create({
|
||||||
|
ticketType: 'PACKAGE_DISPUTE',
|
||||||
|
refType: 'STORE',
|
||||||
|
refId: storeId.toString(),
|
||||||
|
remark: body.remark ?? '用户对门店套餐有异议',
|
||||||
|
extraJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private generateInvoiceNo() {
|
private generateInvoiceNo() {
|
||||||
return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-8
@@ -26,6 +26,12 @@
|
|||||||
| 核销码 TTL 3 分钟 | 编码手册写 5 分钟 | **以本 PRD 3 分钟为准** |
|
| 核销码 TTL 3 分钟 | 编码手册写 5 分钟 | **以本 PRD 3 分钟为准** |
|
||||||
| 订单 Tab 三态 | preV1 五 Tab | **以本 PRD 待付款/已付款/已完成为准** |
|
| 订单 Tab 三态 | preV1 五 Tab | **以本 PRD 待付款/已付款/已完成为准** |
|
||||||
|
|
||||||
|
### 0.2 变更:3.4.10 门店套餐
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **3.4.10** | 2026-08-03 | 新增 §3.9 门店套餐;REQ-P-027 / REQ-S-021 / REQ-H-025 / REQ-U-027;开发设计见 [`杜康好客-门店套餐功能开发文档-v3.4.10.md`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
@@ -234,13 +240,50 @@
|
|||||||
- Wave 3:连续**网络类**失败 5 次→拍照兜底→上传→总部 T+0 补核销
|
- Wave 3:连续**网络类**失败 5 次→拍照兜底→上传→总部 T+0 补核销
|
||||||
- 业务错误(码过期、余额不足等)不计入 5 次;幂等防重复扣款
|
- 业务错误(码过期、余额不足等)不计入 5 次;幂等防重复扣款
|
||||||
|
|
||||||
|
### 3.9 门店套餐(引入于 **3.4.10**)
|
||||||
|
|
||||||
|
> 实现设计见 [`杜康好客-门店套餐功能开发文档-v3.4.10.md`](./杜康好客-门店套餐功能开发文档-v3.4.10.md)
|
||||||
|
|
||||||
|
门店可向 C 端展示餐饮套餐信息(与酒水 SKU 无关),用于核销前用户了解可核销内容;**套餐变更审核与整店入驻审核独立**。
|
||||||
|
|
||||||
|
#### 3.9.1 套餐数据结构
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 套餐名称 | 必填(单条内) | 套餐A |
|
||||||
|
| 价格 | 元 | 198 |
|
||||||
|
| 菜品 | 文本 | 红烧肉、红烧鱼、油焖茄子 |
|
||||||
|
| 使用时间 | 文本 | 节假日除外 |
|
||||||
|
| 其他说明 | 文本 | 不可叠加 |
|
||||||
|
|
||||||
|
- 可不填;可多条;**同一门店最多 10 条**
|
||||||
|
- C 端仅展示**已审核通过(生效中)**的套餐,自上而下标题+内容形式
|
||||||
|
|
||||||
|
#### 3.9.2 维护与审核
|
||||||
|
|
||||||
|
| 端 | 能力 | 审核 |
|
||||||
|
|----|------|------|
|
||||||
|
| 合伙人拓店 | 新建流程增加「套餐」页(可跳过) | 随门店或独立提审(见开发文档) |
|
||||||
|
| 合伙人门店详情 | 套餐列表 → 编辑 → **提交审核** | 是 |
|
||||||
|
| 门店端 | 套餐列表 → 编辑 → **提交审核** | 是 |
|
||||||
|
| 总部 | 门店详情新增「套餐」Tab | **直接保存生效,无需审核** |
|
||||||
|
|
||||||
|
- 合伙/门店提交后 → 总部收到套餐变更审核 → **通过/驳回**
|
||||||
|
- **通过**:自动替换该店生效套餐;**驳回**:保留上一版生效套餐
|
||||||
|
- 提审中:C 端仍展示上一版已通过套餐(从未通过则不展示)
|
||||||
|
|
||||||
|
#### 3.9.3 客服异议
|
||||||
|
|
||||||
|
- 用户对核销过程中套餐内容有异议 → 可提交客服申诉
|
||||||
|
- 工单/客服类型新增:**套餐异议**(`PACKAGE_DISPUTE`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 功能需求(REQ 索引)
|
## 4. 功能需求(REQ 索引)
|
||||||
|
|
||||||
> 完整 REQ 结论见 [`.cursor/skills/dukang-v3/reference-req-index.md`](./.cursor/skills/dukang-v3/reference-req-index.md)
|
> 完整 REQ 结论见 [`.cursor/skills/dukang-v3/reference-req-index.md`](./.cursor/skills/dukang-v3/reference-req-index.md)
|
||||||
|
|
||||||
### 4.1 用户端(小程序)— REQ-U-001 ~ 026
|
### 4.1 用户端(小程序)— REQ-U-001 ~ 027
|
||||||
|
|
||||||
| 模块 | 要点 |
|
| 模块 | 要点 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -248,7 +291,8 @@
|
|||||||
| 商品下单 | 4 款酒祖杜康 SKU;同城≥2瓶免运费;跨城≥1箱;现场提货隐藏;锁单30分钟 |
|
| 商品下单 | 4 款酒祖杜康 SKU;同城≥2瓶免运费;跨城≥1箱;现场提货隐藏;锁单30分钟 |
|
||||||
| 权益核销 | 1:1 发放;出码 3 分钟;核销前规则弹窗(OPT-011);附件一规则详情 |
|
| 权益核销 | 1:1 发放;出码 3 分钟;核销前规则弹窗(OPT-011);附件一规则详情 |
|
||||||
| 门店 | 列表/详情/搜索/省市区筛选;仅营业中展示;立即核销 |
|
| 门店 | 列表/详情/搜索/省市区筛选;仅营业中展示;立即核销 |
|
||||||
| 增长售后 | 四类型工单;发票四组合;成交问卷(无激励);推广码;微信商品分享 |
|
| **门店套餐(3.4.10)** | 门店详情展示生效套餐(标题+内容);套餐异议申诉入口(REQ-U-027) |
|
||||||
|
| 增长售后 | 四类型工单 + **套餐异议**;发票四组合;成交问卷(无激励);推广码;微信商品分享 |
|
||||||
| 评价 | 核销后门店评价 |
|
| 评价 | 核销后门店评价 |
|
||||||
|
|
||||||
**SKU 价格锚点**
|
**SKU 价格锚点**
|
||||||
@@ -260,7 +304,7 @@
|
|||||||
| 酒祖杜康(国标特级20)53° | ¥298 | ¥1788 |
|
| 酒祖杜康(国标特级20)53° | ¥298 | ¥1788 |
|
||||||
| 酒祖杜康(国标特级30)53° | ¥498 | ¥2988 |
|
| 酒祖杜康(国标特级30)53° | ¥498 | ¥2988 |
|
||||||
|
|
||||||
### 4.2 门店端(H5)— REQ-S-001 ~ 020
|
### 4.2 门店端(H5)— REQ-S-001 ~ 021
|
||||||
|
|
||||||
| 模块 | 要点 |
|
| 模块 | 要点 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -269,26 +313,29 @@
|
|||||||
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
||||||
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
||||||
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
||||||
|
| **门店套餐(3.4.10)** | 套餐列表/编辑;提交审核(REQ-S-021) |
|
||||||
|
|
||||||
### 4.3 合伙人端(H5)— REQ-P-001 ~ 026
|
### 4.3 合伙人端(H5)— REQ-P-001 ~ 027
|
||||||
|
|
||||||
| 模块 | 要点 |
|
| 模块 | 要点 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 权限 | 管理员 vs 推广员菜单裁剪 |
|
| 权限 | 管理员 vs 推广员菜单裁剪 |
|
||||||
| 拓店 | 三步录入;一号多店确认;负责人复核;试核销100元;附件一 |
|
| 拓店 | 三步录入 + **套餐页(3.4.10,可跳过)**;一号多店确认;负责人复核;试核销100元;附件一 |
|
||||||
| 经营 | 首页三卡;排行;订单/权益/工单;佣金快照下钻 |
|
| 经营 | 首页三卡;排行;订单/权益/工单;佣金快照下钻 |
|
||||||
| 财务 | 本合伙人月账对账(Wave 2/3) |
|
| 财务 | 本合伙人月账对账(Wave 2/3) |
|
||||||
| 扩展 | 代下单(Wave 3);管仓只读(Wave 3) |
|
| 扩展 | 代下单(Wave 3);管仓只读(Wave 3) |
|
||||||
|
| **门店套餐(3.4.10)** | 拓店套餐页;门店详情套餐列表/编辑/提审(REQ-P-027) |
|
||||||
|
|
||||||
### 4.4 总部端(H5)— REQ-H-001 ~ 024
|
### 4.4 总部端(H5)— REQ-H-001 ~ 025
|
||||||
|
|
||||||
| 模块 | 要点 |
|
| 模块 | 要点 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 基础 | 商品管理;营销规则 1:1 权益;签约主体:山西领势酒业有限责任公司 |
|
| 基础 | 商品管理;营销规则 1:1 权益;签约主体:山西领势酒业有限责任公司 |
|
||||||
| 城市合伙人 | 开通城市;创建合伙人(全城/区域+佣金);仓库管理(Wave 3) |
|
| 城市合伙人 | 开通城市;创建合伙人(全城/区域+佣金);仓库管理(Wave 3) |
|
||||||
| 审核交易 | 门店审核;全量订单/权益/工单;发票 2 工作日;补核销 |
|
| 审核交易 | 门店审核;**套餐变更审核(3.4.10)**;全量订单/权益/工单;发票 2 工作日;补核销 |
|
||||||
| 结算 | 门店+合伙人双 Tab;提现审;白名单配置;**物流对账**(按承运商月结) |
|
| 结算 | 门店+合伙人双 Tab;提现审;白名单配置;**物流对账**(按承运商月结) |
|
||||||
| 增长 | 推广码;问卷;评价;腾讯位置热力图 |
|
| 增长 | 推广码;问卷;评价;腾讯位置热力图 |
|
||||||
|
| **门店套餐(3.4.10)** | 门店详情「套餐」Tab 直存;套餐变更审核通过/驳回(REQ-H-025) |
|
||||||
|
|
||||||
### 4.5 端职责矩阵
|
### 4.5 端职责矩阵
|
||||||
|
|
||||||
@@ -300,6 +347,7 @@
|
|||||||
| 工单 | ●发起 | 查看 | 查看 | ●决策 |
|
| 工单 | ●发起 | 查看 | 查看 | ●决策 |
|
||||||
| 结算提现 | | ● | 对账确认 | ●审打款 |
|
| 结算提现 | | ● | 对账确认 | ●审打款 |
|
||||||
| 推广码/问卷/评价 | ● | 被评价 | 看数据 | 配置/看板 |
|
| 推广码/问卷/评价 | ● | 被评价 | 看数据 | 配置/看板 |
|
||||||
|
| **门店套餐(3.4.10)** | 看生效套餐 | 编辑提审 | 编辑提审 | ●直存/●审核 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -324,7 +372,7 @@
|
|||||||
|
|
||||||
### 6.1 核心实体(TECH-001 ~ 019)
|
### 6.1 核心实体(TECH-001 ~ 019)
|
||||||
|
|
||||||
用户、商品、订单(含佣金快照)、好客权益账本、门店(主账号/店员)、核销单、工单、结算/提现、合伙人(管辖+佣金)、推广码、仓库(W3)、待处理核销单(W3)、门店账号(W2)。
|
用户、商品、订单(含佣金快照)、好客权益账本、门店(主账号/店员)、**门店套餐(3.4.10)**、核销单、工单、结算/提现、合伙人(管辖+佣金)、推广码、仓库(W3)、待处理核销单(W3)、门店账号(W2)。
|
||||||
|
|
||||||
### 6.2 外部对接
|
### 6.2 外部对接
|
||||||
|
|
||||||
@@ -432,6 +480,7 @@
|
|||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
| 版本 | 日期 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| **3.4.10** | 2026-08-03 | 新增 §3.9 门店套餐;REQ-P-027 / REQ-S-021 / REQ-H-025 / REQ-U-027;套餐异议工单类型 |
|
||||||
| v3.0.2 | 2026-08-02 | OPT-002/SC-07:代下单改为在线支付(收款码/合伙人微信代付);去掉线下已收款直完成;配送单总部履约、合伙人可看物流 |
|
| v3.0.2 | 2026-08-02 | OPT-002/SC-07:代下单改为在线支付(收款码/合伙人微信代付);去掉线下已收款直完成;配送单总部履约、合伙人可看物流 |
|
||||||
| v3.0.1 | 2026-07-27 | ACC-012/OPT-002/SC-07:明确合伙人代下单双短信、线下完成、来源 PARTNER_PROXY、C 端展示代下单人 |
|
| v3.0.1 | 2026-07-27 | ACC-012/OPT-002/SC-07:明确合伙人代下单双短信、线下完成、来源 PARTNER_PROXY、C 端展示代下单人 |
|
||||||
| v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent |
|
| v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent |
|
||||||
|
|||||||
@@ -216,6 +216,18 @@
|
|||||||
| **REQ-H-018~019** 问卷/评价总部看板 | 评价有 DB;总部看板无 |
|
| **REQ-H-018~019** 问卷/评价总部看板 | 评价有 DB;总部看板无 |
|
||||||
| **REQ-H-021 OPT-012** 话术对齐 | 无运营文档交付机制 |
|
| **REQ-H-021 OPT-012** 话术对齐 | 无运营文档交付机制 |
|
||||||
|
|
||||||
|
### 3.4 门店套餐(目标版本 **3.4.10**,未实现)
|
||||||
|
|
||||||
|
> 需求:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) §3.9 · 开发设计:[`杜康好客-门店套餐功能开发文档-v3.4.10.md`](./杜康好客-门店套餐功能开发文档-v3.4.10.md)
|
||||||
|
|
||||||
|
| REQ / 能力 | 说明 |
|
||||||
|
|------------|------|
|
||||||
|
| **REQ-P-027** 合伙人拓店套餐页 + 门店套餐编辑提审 | 拓店仍三步;无套餐页;无 `StorePackage` 实体 |
|
||||||
|
| **REQ-S-021** 门店端套餐列表/编辑/提审 | 门店端仅营业状态;无资料编辑 API |
|
||||||
|
| **REQ-H-025** 总部套餐 Tab 直存 + 套餐变更审核 | 门店 Drawer 无套餐 Tab;无独立套餐审核流 |
|
||||||
|
| **REQ-U-027** C 端展示生效套餐 + 套餐异议 | 门店详情无套餐区块;工单无 `PACKAGE_DISPUTE` |
|
||||||
|
| **数据模型** | 无 `store_package` / `store_package_change_request` 表 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 按端汇总矩阵
|
## 4. 按端汇总矩阵
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# 杜康好客 · 门店套餐功能开发文档
|
||||||
|
|
||||||
|
> **版本**:3.4.10
|
||||||
|
> **日期**:2026-08-03
|
||||||
|
> **状态**:需求已定 / 待实现
|
||||||
|
> **关联**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) §3.9 · REQ-P-027 / REQ-S-021 / REQ-H-025 / REQ-U-027
|
||||||
|
|
||||||
|
## 变更记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **3.4.10** | 2026-08-03 | 新增门店套餐需求与开发设计 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [背景与目标](#1-背景与目标)
|
||||||
|
2. [数据模型](#2-数据模型)
|
||||||
|
3. [API 草案](#3-api-草案)
|
||||||
|
4. [四端 UI](#4-四端-ui)
|
||||||
|
5. [审核与展示状态机](#5-审核与展示状态机)
|
||||||
|
6. [客服「套餐异议」](#6-客服套餐异议)
|
||||||
|
7. [验收清单(ACC)](#7-验收清单acc)
|
||||||
|
8. [实现分期建议](#8-实现分期建议)
|
||||||
|
|
||||||
|
### 流程概览
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
partnerEdit[PartnerOrShopEdit]
|
||||||
|
draft[PackageChangeRequest]
|
||||||
|
hqReview[HqApproveOrReject]
|
||||||
|
live[LiveStorePackages]
|
||||||
|
mini[MiniStoreDetail]
|
||||||
|
dispute[PackageDisputeTicket]
|
||||||
|
partnerEdit --> draft
|
||||||
|
draft --> hqReview
|
||||||
|
hqReview -->|APPROVED| live
|
||||||
|
live --> mini
|
||||||
|
mini --> dispute
|
||||||
|
hqDirect[HqDirectSave] --> live
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
门店可向 C 端用户展示餐饮套餐信息,帮助用户在核销前了解可核销内容。套餐与酒水 SKU 无关,需独立数据模型与审核流。
|
||||||
|
|
||||||
|
### 1.1 需求原文(10 条)
|
||||||
|
|
||||||
|
1. **合伙人新建门店**:在现有拓店流程(基本信息 / 照片 / 结算资质)后增加「套餐」页;可不填、可跳过。
|
||||||
|
2. **套餐字段**:套餐名称、价格(元)、菜品、使用时间、其他说明。
|
||||||
|
3. **C 端门店详情**:在「门店详情」区块附近,以纵向列表展示套餐;每条为「标题 + 内容」形式。
|
||||||
|
4. **数量约束**:可不填;可多条;**同一门店最多 10 条**。
|
||||||
|
5. **门店端**:新增套餐列表页,可编辑并**提交审核**。
|
||||||
|
6. **合伙人端**:选择门店 → 套餐列表 → 编辑 → **提交审核**。
|
||||||
|
7. **总部端**:门店详情新增「套餐」Tab,**直接保存生效、无需审核**。
|
||||||
|
8. **审核流**:合伙/门店提交 → 总部通过/驳回;**通过后自动替换生效套餐**。
|
||||||
|
9. **C 端展示**:仅展示**已审核通过(生效中)**的套餐。
|
||||||
|
10. **客服异议**:用户对核销中套餐有异议 → 客服申诉类型新增 **套餐异议**。
|
||||||
|
|
||||||
|
### 1.2 现状对齐
|
||||||
|
|
||||||
|
| 现状 | 影响 |
|
||||||
|
|------|------|
|
||||||
|
| 无门店套餐实体;`Store.tags` Json 未承载套餐 | 需新建表,不复用酒水 SKU |
|
||||||
|
| 合伙人拓店三步:基本信息 / 照片 / 结算资质 | 新增「套餐」页(第 4 步或独立页) |
|
||||||
|
| 门店端仅改营业状态,**无资料编辑 API** | 需新增门店端套餐 CRUD + 提审 API |
|
||||||
|
| 门店审核仅 `NEW`/`RESUBMIT` 整店审,**无「套餐变更」独立审核** | 需独立「套餐变更审核」模型 |
|
||||||
|
| 售后工单四类型无「套餐异议」 | 扩展工单/客服类型 |
|
||||||
|
| C 端详情已有 intro / 权益规则 / 环境图 | 在「门店详情」区块附近增加套餐纵向列表 |
|
||||||
|
|
||||||
|
### 1.3 明确不做(本文档阶段)
|
||||||
|
|
||||||
|
- 不改 Prisma / 不写 API / 不改四端业务代码(本文档仅设计)
|
||||||
|
- 不把套餐挂到商品目录 `CommonProductItem`
|
||||||
|
- 不合并进整店 `Store.auditStatus`(套餐变更独立提审)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
### 2.1 生效数据 `StorePackage`(表名 `store_package`)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | String (cuid) | 主键 |
|
||||||
|
| `storeId` | String | 门店 ID,FK → `Store` |
|
||||||
|
| `name` | String | 套餐名称,如「套餐A」 |
|
||||||
|
| `price` | Decimal | 价格(元) |
|
||||||
|
| `dishes` | Text | 菜品文案;前端按顿号/换行展示;或 Json 字符串数组 |
|
||||||
|
| `usableTime` | String? | 使用时间,如「节假日除外」 |
|
||||||
|
| `otherNotes` | String? | 其他说明,如「不可叠加」 |
|
||||||
|
| `sortOrder` | Int | 展示序 0~9 |
|
||||||
|
| `createdAt` | DateTime | |
|
||||||
|
| `updatedAt` | DateTime | |
|
||||||
|
|
||||||
|
**约束**
|
||||||
|
|
||||||
|
- 同一 `storeId` 下生效套餐 **≤ 10** 条
|
||||||
|
- 删除/替换由审核通过或总部直存事务完成
|
||||||
|
|
||||||
|
### 2.2 变更提审 `StorePackageChangeRequest`(表名 `store_package_change_request`)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | String (cuid) | 主键 |
|
||||||
|
| `storeId` | String | 门店 ID |
|
||||||
|
| `status` | Enum | `PENDING` / `APPROVED` / `REJECTED` |
|
||||||
|
| `packagesJson` | Json | 提审快照(完整套餐数组,最多 10 条) |
|
||||||
|
| `submitterType` | Enum | `PARTNER` / `SHOP` |
|
||||||
|
| `submitterId` | String | 提交人 ID |
|
||||||
|
| `rejectReason` | String? | 驳回原因 |
|
||||||
|
| `reviewedAt` | DateTime? | |
|
||||||
|
| `reviewerId` | String? | 总部审核人 |
|
||||||
|
| `createdAt` | DateTime | |
|
||||||
|
|
||||||
|
**规则**
|
||||||
|
|
||||||
|
- 同门店同时仅允许 **一条 `PENDING`**
|
||||||
|
- 审核 **通过**:事务内删除该店全部 `StorePackage`,按 `packagesJson` 重建
|
||||||
|
- 审核 **驳回**:不改动生效表;提审方可修改后再提
|
||||||
|
|
||||||
|
### 2.3 提审快照 JSON 结构(`packagesJson` 数组元素)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "套餐A",
|
||||||
|
"price": "198.00",
|
||||||
|
"dishes": "红烧肉、红烧鱼、油焖茄子",
|
||||||
|
"usableTime": "节假日除外",
|
||||||
|
"otherNotes": "不可叠加",
|
||||||
|
"sortOrder": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 总部直存
|
||||||
|
|
||||||
|
- 写 `StorePackage`,**不写** `StorePackageChangeRequest`
|
||||||
|
- 可选记 `CommonEvent`(如 `STORE_PACKAGE_UPDATE`)留痕
|
||||||
|
|
||||||
|
### 2.5 枚举(建议写入 `packages/shared-types`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum StorePackageChangeStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
APPROVED = 'APPROVED',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StorePackageSubmitterType {
|
||||||
|
PARTNER = 'PARTNER',
|
||||||
|
SHOP = 'SHOP',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展工单类型
|
||||||
|
enum TicketType {
|
||||||
|
// ...existing
|
||||||
|
PACKAGE_DISPUTE = 'PACKAGE_DISPUTE',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 草案
|
||||||
|
|
||||||
|
> 路径前缀均为 `/api/v1`;响应 `{ code, message, data }`。
|
||||||
|
|
||||||
|
### 3.1 合伙人端
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/partner/stores/:storeId/packages` | 读草稿/生效套餐(见实现约定) | 管辖门店 |
|
||||||
|
| PUT | `/partner/stores/:storeId/packages` | 提交套餐变更审核(body=套餐数组) | 管辖门店 |
|
||||||
|
| GET | `/partner/stores/:storeId/package-change-requests` | 历史提审记录 | 管辖门店 |
|
||||||
|
|
||||||
|
**拓店草稿**:新建流程中套餐可先写入 `storeDraft.packages`,门店创建成功后随首次提审或总部审核入库。
|
||||||
|
|
||||||
|
### 3.2 门店端(新增能力)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/shop/store/packages` | 当前门店套餐(草稿+生效) | 门店主账号/店员 |
|
||||||
|
| PUT | `/shop/store/packages` | 提交套餐变更审核 | 门店主账号 |
|
||||||
|
|
||||||
|
> 现状:门店端无资料编辑 API,本模块为 **新增** Shop Store API。
|
||||||
|
|
||||||
|
### 3.3 总部端
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/admin/stores/:storeId/packages` | 读生效套餐 | HQ |
|
||||||
|
| PUT | `/admin/stores/:storeId/packages` | **直存**生效(无需审核) | HQ |
|
||||||
|
| GET | `/admin/store-package-audits` | 待审/历史列表 | HQ |
|
||||||
|
| PUT | `/admin/store-package-audits/:requestId/audit` | 通过/驳回 | HQ |
|
||||||
|
|
||||||
|
**审核 body 示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "APPROVE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "REJECT",
|
||||||
|
"rejectReason": "价格描述不清晰"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 C 端(公开)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/stores/:storeId` | 门店详情 **嵌入** `packages[]`(仅生效) |
|
||||||
|
| GET | `/stores/:storeId/packages` | 可选独立接口,仅返回生效套餐 |
|
||||||
|
|
||||||
|
**响应字段(单条)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "套餐A",
|
||||||
|
"price": "198.00",
|
||||||
|
"dishes": "红烧肉、红烧鱼、油焖茄子",
|
||||||
|
"usableTime": "节假日除外",
|
||||||
|
"otherNotes": "不可叠加",
|
||||||
|
"sortOrder": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 客服异议
|
||||||
|
|
||||||
|
扩展创建售后/客服工单:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `ticketType` | `PACKAGE_DISPUTE` |
|
||||||
|
| `storeId` | 必填 |
|
||||||
|
| `redeemRecordId` | 可选,关联核销单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 四端 UI
|
||||||
|
|
||||||
|
### 4.1 合伙人端(H5)
|
||||||
|
|
||||||
|
| 页面 | 路径建议 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 拓店·套餐 | `/stores/create/packages` | 第 4 步;可跳过;数据进 `storeDraft` |
|
||||||
|
| 门店详情·套餐入口 | 门店详情页 | 跳转套餐列表 |
|
||||||
|
| 套餐列表 | `/stores/:id/packages` | 展示当前生效 + 待审状态 |
|
||||||
|
| 套餐编辑 | `/stores/:id/packages/edit` | 表单:名称/价格/菜品/时间/说明;最多 10 条 |
|
||||||
|
| 提交审核 | 编辑页底部 | PUT 提审;同店有 PENDING 时禁用 |
|
||||||
|
|
||||||
|
参考:`apps/h5-partner/src/pages/StoreCreatePage.tsx`
|
||||||
|
|
||||||
|
### 4.2 门店端(H5)
|
||||||
|
|
||||||
|
| 页面 | 路径建议 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 套餐列表 | `/packages` | 新入口(门店信息 Tab 或独立菜单) |
|
||||||
|
| 套餐编辑 | `/packages/edit` | 同合伙人表单 |
|
||||||
|
| 提交审核 | 编辑页底部 | PUT `/shop/store/packages` |
|
||||||
|
|
||||||
|
### 4.3 总部端(admin-web)
|
||||||
|
|
||||||
|
| 页面 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 门店详情 Drawer · Tab「套餐」 | 在 `StoresPage.tsx` Drawer 增加 Tab;表单直存 |
|
||||||
|
| 套餐变更审核列表 | 独立页或门店内待审提示;通过/驳回 |
|
||||||
|
|
||||||
|
参考:`apps/admin-web/src/pages/StoresPage.tsx`
|
||||||
|
|
||||||
|
### 4.4 用户端(mini-user)
|
||||||
|
|
||||||
|
| 位置 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 门店详情 · 「门店详情」区块上方或下方 | 纵向卡片列表 |
|
||||||
|
| 单条样式 | 标题 = 套餐名;内容 = 价格 / 菜品 / 使用时间 / 其他说明 |
|
||||||
|
| 空态 | 无生效套餐时不展示该区块 |
|
||||||
|
|
||||||
|
参考:`apps/mini-user/src/pages/store-detail/index.tsx`
|
||||||
|
|
||||||
|
### 4.5 C 端展示示例
|
||||||
|
|
||||||
|
```
|
||||||
|
套餐A
|
||||||
|
198 元 · 红烧肉、红烧鱼、油焖茄子
|
||||||
|
使用时间:节假日除外
|
||||||
|
说明:不可叠加
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 审核与展示状态机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> NoLive: 从未通过
|
||||||
|
NoLive --> Live: 首次审核通过 / 总部直存
|
||||||
|
Live --> Pending: 合伙/门店提审
|
||||||
|
Pending --> Live: 审核通过(覆盖)
|
||||||
|
Pending --> Live: 审核驳回(保留旧 Live)
|
||||||
|
Live --> Live: 总部直存(覆盖)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 状态 | C 端展示 | 编辑方可见 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| 无生效套餐 | 不展示套餐区块 | 可编辑、可提审 |
|
||||||
|
| 有生效 + 无待审 | 展示生效版 | 可编辑、可提审 |
|
||||||
|
| 有生效 + 待审中 | **仍展示上一版生效** | 显示「审核中」;不可重复提审 |
|
||||||
|
| 审核通过 | 展示新版 | 可再次编辑提审 |
|
||||||
|
| 审核驳回 | 仍展示旧版 | 可修改后再提;展示驳回原因 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 客服「套餐异议」
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 类型文案 | 套餐异议 |
|
||||||
|
| 枚举 | `PACKAGE_DISPUTE` |
|
||||||
|
| 入口 | C 端核销相关页 / 门店详情 / 客服页可选该类型 |
|
||||||
|
| 关联 | `storeId` 必填;可选 `redeemRecordId` |
|
||||||
|
| 总部 | 客服工单列表可按类型筛选处理 |
|
||||||
|
|
||||||
|
与现有四类型工单并列,不替换原有类型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收清单(ACC)
|
||||||
|
|
||||||
|
| ID | 对应需求 | 验收项 |
|
||||||
|
|----|----------|--------|
|
||||||
|
| ACC-PKG-01 | #1 | 合伙人拓店流程有「套餐」页,可跳过;跳过后门店可无套餐 |
|
||||||
|
| ACC-PKG-02 | #2 | 单条套餐含名称、价格、菜品、使用时间、其他说明五字段 |
|
||||||
|
| ACC-PKG-03 | #3 | C 端门店详情以纵向标题+内容展示套餐 |
|
||||||
|
| ACC-PKG-04 | #4 | 同一门店生效套餐 ≤10;第 11 条保存/提审被拒绝 |
|
||||||
|
| ACC-PKG-05 | #5 | 门店端可列表、编辑、提交审核 |
|
||||||
|
| ACC-PKG-06 | #6 | 合伙人可选门店、编辑套餐、提交审核 |
|
||||||
|
| ACC-PKG-07 | #7 | 总部门店详情「套餐」Tab 直存后 C 端立即可见(无需审核) |
|
||||||
|
| ACC-PKG-08 | #8 | 合伙/门店提审 → 总部通过后生效表被完整替换;驳回后生效表不变 |
|
||||||
|
| ACC-PKG-09 | #9 | C 端 never 展示未通过/待审套餐;提审中仍展示旧版 |
|
||||||
|
| ACC-PKG-10 | #10 | 用户可创建「套餐异议」工单;总部可按类型筛选 |
|
||||||
|
| ACC-PKG-11 | — | 同门店仅一条 PENDING;重复提审返回业务错误 |
|
||||||
|
| ACC-PKG-12 | — | 空套餐数组提审合法;通过后 C 端不展示套餐区块 |
|
||||||
|
| ACC-PKG-13 | — | 总部直存记事件或审计日志(若启用 CommonEvent) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 实现分期建议
|
||||||
|
|
||||||
|
| 阶段 | 范围 | 交付 |
|
||||||
|
|------|------|------|
|
||||||
|
| **M1** | 表结构 + 总部直存 + C 端展示 | Prisma 迁移、`StorePackage` CRUD(admin)、mini-user 展示 |
|
||||||
|
| **M2** | 合伙人新建/编辑提审 + 总部审核 | `StorePackageChangeRequest`、partner 页面、admin 审核 |
|
||||||
|
| **M3** | 门店端提审 | Shop API + h5-shop 页面 |
|
||||||
|
| **M4** | 套餐异议工单类型 | `PACKAGE_DISPUTE` 枚举、C 端入口、admin 筛选 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:REQ 映射
|
||||||
|
|
||||||
|
| REQ | 端 | 内容 |
|
||||||
|
|-----|----|------|
|
||||||
|
| REQ-P-027 | 合伙人 | 拓店套餐页 + 门店套餐列表/编辑/提审 |
|
||||||
|
| REQ-S-021 | 门店 | 套餐列表/编辑/提审 |
|
||||||
|
| REQ-H-025 | 总部 | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 |
|
||||||
|
| REQ-U-027 | 用户 | 门店详情展示生效套餐;套餐异议申诉入口 |
|
||||||
Reference in New Issue
Block a user