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-025 | 分享 | 微信商品分享+渠道参数 |
|
||||
| REQ-U-026 | 门店规则 | 附件一完整展示 |
|
||||
| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 |
|
||||
|
||||
## 门店端 REQ-S
|
||||
|
||||
@@ -58,6 +59,7 @@
|
||||
| REQ-S-018 | 权限 | §4.6 矩阵 |
|
||||
| REQ-S-019 | 多店选店 | 记住上次所选 |
|
||||
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
|
||||
| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 |
|
||||
|
||||
## 合伙人端 REQ-P
|
||||
|
||||
@@ -89,6 +91,7 @@
|
||||
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
|
||||
| REQ-P-025 | 合同同日 | 同日提交校验 |
|
||||
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
|
||||
| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 |
|
||||
|
||||
## 总部端 REQ-H
|
||||
|
||||
@@ -122,3 +125,4 @@
|
||||
| REQ-H-022 | 签约主体 | 山西领势酒业 |
|
||||
| REQ-H-023 | 热力图 | 腾讯位置服务 |
|
||||
| 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 UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||
@@ -79,6 +80,7 @@ export default function App() {
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-package-audits" element={<StorePackageAuditsPage />} />
|
||||
<Route path="/store-ratings" element={<StoreRatingsPage />} />
|
||||
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||
<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 OssUpload from '../components/OssUpload';
|
||||
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
{ title: '基本信息' },
|
||||
@@ -1214,6 +1215,11 @@ export default function StoresPage() {
|
||||
label: '审核材料',
|
||||
children: <StoreAuditMediaSection detail={detail} />,
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: '套餐',
|
||||
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function TicketsPage() {
|
||||
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||
{ value: 'PACKAGE_DISPUTE', label: '套餐异议' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import SubAccountLayout from './layouts/SubAccountLayout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import StorePackagesPage from './pages/StorePackagesPage';
|
||||
import StoreCreatePage from './pages/StoreCreatePage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
@@ -31,6 +32,7 @@ function PrimaryRoutes() {
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||
<Route path="/center/staff" element={<StaffListPage />} />
|
||||
@@ -68,6 +70,7 @@ function SubAccountRoutes() {
|
||||
{!isWarehouse && (
|
||||
<>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||
<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;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
/** 门店套餐(拓店第 4 步,可空) */
|
||||
packages: import('@dukang/shared-types').StorePackageItemDto[];
|
||||
};
|
||||
|
||||
export type StoreDraft = {
|
||||
@@ -68,6 +70,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
packages: [],
|
||||
});
|
||||
|
||||
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),
|
||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||
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,
|
||||
|
||||
} from '../lib/storeDraft';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||
|
||||
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
||||
|
||||
type StoreCategoryNode = {
|
||||
id: string;
|
||||
@@ -111,7 +111,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function submit() {
|
||||
async function submit(skipPackages = false) {
|
||||
|
||||
const msg = validateStoreStep3(form);
|
||||
|
||||
@@ -337,6 +349,15 @@ export default function StoreCreatePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const packagesToSubmit = skipPackages ? [] : form.packages || [];
|
||||
if (!skipPackages) {
|
||||
const pkgMsg = validatePackageFormItems(packagesToSubmit);
|
||||
if (pkgMsg) {
|
||||
reportFormError(pkgMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
|
||||
if (step1Msg) {
|
||||
@@ -472,6 +493,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
draftSaveDisabledRef.current = true;
|
||||
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());
|
||||
setFieldErrors({});
|
||||
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;
|
||||
|
||||
@@ -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">
|
||||
@@ -1110,7 +1154,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
{step < 4 ? (
|
||||
|
||||
<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 ? '提交中…' : '提交'}
|
||||
|
||||
</button>
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
@@ -277,6 +277,18 @@ export default function StoreDetailPage() {
|
||||
)}
|
||||
</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 && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<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 MinePage from './pages/MinePage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import WithdrawPage from './pages/WithdrawPage';
|
||||
import PackagesPage from './pages/PackagesPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -24,6 +24,7 @@ export default function App() {
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||
<Route path="/packages" element={<PackagesPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<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>
|
||||
结算提现
|
||||
</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 ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
||||
<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 { View, Text, Image } from '@tarojs/components';
|
||||
import { View, Text, Image, Textarea } from '@tarojs/components';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
useLoad,
|
||||
@@ -28,6 +28,15 @@ type StoreMedia = {
|
||||
mediaType?: string | null;
|
||||
};
|
||||
|
||||
type StorePackage = {
|
||||
name: string;
|
||||
price: string | number;
|
||||
dishes: string;
|
||||
usableTime?: string | null;
|
||||
otherNotes?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +51,7 @@ type Store = {
|
||||
coverUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
media?: StoreMedia[] | null;
|
||||
packages?: StorePackage[] | null;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
@@ -142,6 +152,9 @@ export default function StoreDetailPage() {
|
||||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [disputeOpen, setDisputeOpen] = useState(false);
|
||||
const [disputeRemark, setDisputeRemark] = useState('');
|
||||
const [disputeSubmitting, setDisputeSubmitting] = useState(false);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const storeRef = useRef<Store | null>(null);
|
||||
storeRef.current = store;
|
||||
@@ -263,6 +276,27 @@ export default function StoreDetailPage() {
|
||||
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() {
|
||||
if (!store) return;
|
||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||
@@ -389,6 +423,29 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : 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 ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
@@ -420,6 +477,33 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : 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="u-btn u-btn--block"
|
||||
|
||||
@@ -234,3 +234,85 @@
|
||||
flex: 1;
|
||||
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 './store-log';
|
||||
export * from './store-package';
|
||||
export * from './partner-log';
|
||||
export * from './promo';
|
||||
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 =
|
||||
| 'REFUND'
|
||||
| 'RESHIPMENT'
|
||||
| 'ALERT'
|
||||
| 'DAMAGE_RETURN'
|
||||
| 'RETURN_REFUND';
|
||||
| 'RETURN_REFUND'
|
||||
| 'PACKAGE_DISPUTE';
|
||||
|
||||
/** 用户可发起的售后类型 */
|
||||
export const AFTER_SALE_TICKET_TYPES = [
|
||||
@@ -12,6 +13,7 @@ export const AFTER_SALE_TICKET_TYPES = [
|
||||
'RESHIPMENT',
|
||||
'DAMAGE_RETURN',
|
||||
'RETURN_REFUND',
|
||||
'PACKAGE_DISPUTE',
|
||||
] as const;
|
||||
|
||||
export type AfterSaleTicketType = (typeof AFTER_SALE_TICKET_TYPES)[number];
|
||||
@@ -22,6 +24,7 @@ export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
|
||||
ALERT: '异常',
|
||||
DAMAGE_RETURN: '破损退货',
|
||||
RETURN_REFUND: '退货退款',
|
||||
PACKAGE_DISPUTE: '套餐异议',
|
||||
};
|
||||
|
||||
export interface TicketDto {
|
||||
|
||||
@@ -92,6 +92,7 @@ enum TicketType {
|
||||
ALERT
|
||||
DAMAGE_RETURN
|
||||
RETURN_REFUND
|
||||
PACKAGE_DISPUTE
|
||||
}
|
||||
|
||||
enum SupportTicketType {
|
||||
@@ -256,6 +257,17 @@ enum StoreAuditStatus {
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum StorePackageChangeStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum StorePackageSubmitterType {
|
||||
PARTNER
|
||||
SHOP
|
||||
}
|
||||
|
||||
enum UserSourceType {
|
||||
ORGANIC
|
||||
PROMO_CODE
|
||||
@@ -1093,6 +1105,8 @@ model Store {
|
||||
storeBills StoreBill[]
|
||||
withdrawRequests StoreWithdrawRequest[]
|
||||
visibilityPhones StoreVisibilityPhone[]
|
||||
packages StorePackage[]
|
||||
packageChangeRequests StorePackageChangeRequest[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
@@ -1114,6 +1128,43 @@ model StoreVisibilityPhone {
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
|
||||
@@ -143,7 +143,7 @@ export class CreateEventDto {
|
||||
|
||||
export class CreateTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@@ -172,7 +172,7 @@ export class CreateTicketDto {
|
||||
|
||||
export class AdminCreateTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||
ticketType: string;
|
||||
|
||||
@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,
|
||||
ShopStoreController,
|
||||
} from './store.controller';
|
||||
import {
|
||||
AdminStorePackageAuditController,
|
||||
AdminStorePackageController,
|
||||
PartnerStorePackageController,
|
||||
PublicStorePackageController,
|
||||
ShopStorePackageController,
|
||||
} from './store-package.controller';
|
||||
import { StorePackageService } from './store-package.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,15 +35,20 @@ import {
|
||||
],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PublicStorePackageController,
|
||||
PublicStoreCategoriesController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PartnerStorePackageController,
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
ShopStoreController,
|
||||
ShopStorePackageController,
|
||||
ShopDashboardController,
|
||||
AdminStorePackageController,
|
||||
AdminStorePackageAuditController,
|
||||
],
|
||||
providers: [StoreService, StoreCategoryService],
|
||||
exports: [StoreService, StoreCategoryService],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
@@ -208,6 +208,10 @@ export class StoreService {
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
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;
|
||||
return serializeBigInt(
|
||||
mapStoreCompat({
|
||||
@@ -215,6 +219,14 @@ export class StoreService {
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
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 {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||
ticketType: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -21,6 +21,21 @@ export class CreateAfterSaleTicketDto {
|
||||
evidenceUrls?: string[];
|
||||
}
|
||||
|
||||
export class CreatePackageDisputeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
storeId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redeemRecordId?: string;
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsString()
|
||||
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PartnerProxyOrderPreviewDto,
|
||||
} from './dto/partner-proxy-order.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')
|
||||
@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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeAfterSaleTicketController {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PartnerReshipmentController,
|
||||
TradeAfterSaleTicketController,
|
||||
TradeInvoiceController,
|
||||
TradePackageDisputeController,
|
||||
} from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@@ -32,6 +33,7 @@ import { TradeService } from './trade.service';
|
||||
],
|
||||
controllers: [
|
||||
TradeController,
|
||||
TradePackageDisputeController,
|
||||
TradeAfterSaleTicketController,
|
||||
TradeInvoiceController,
|
||||
PartnerOrderController,
|
||||
|
||||
@@ -730,6 +730,40 @@ export class TradeService {
|
||||
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() {
|
||||
return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
+57
-8
@@ -26,6 +26,12 @@
|
||||
| 核销码 TTL 3 分钟 | 编码手册写 5 分钟 | **以本 PRD 3 分钟为准** |
|
||||
| 订单 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. 背景与目标
|
||||
@@ -234,13 +240,50 @@
|
||||
- Wave 3:连续**网络类**失败 5 次→拍照兜底→上传→总部 T+0 补核销
|
||||
- 业务错误(码过期、余额不足等)不计入 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 索引)
|
||||
|
||||
> 完整 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分钟 |
|
||||
| 权益核销 | 1:1 发放;出码 3 分钟;核销前规则弹窗(OPT-011);附件一规则详情 |
|
||||
| 门店 | 列表/详情/搜索/省市区筛选;仅营业中展示;立即核销 |
|
||||
| 增长售后 | 四类型工单;发票四组合;成交问卷(无激励);推广码;微信商品分享 |
|
||||
| **门店套餐(3.4.10)** | 门店详情展示生效套餐(标题+内容);套餐异议申诉入口(REQ-U-027) |
|
||||
| 增长售后 | 四类型工单 + **套餐异议**;发票四组合;成交问卷(无激励);推广码;微信商品分享 |
|
||||
| 评价 | 核销后门店评价 |
|
||||
|
||||
**SKU 价格锚点**
|
||||
@@ -260,7 +304,7 @@
|
||||
| 酒祖杜康(国标特级20)53° | ¥298 | ¥1788 |
|
||||
| 酒祖杜康(国标特级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 出账 |
|
||||
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 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 推广员菜单裁剪 |
|
||||
| 拓店 | 三步录入;一号多店确认;负责人复核;试核销100元;附件一 |
|
||||
| 拓店 | 三步录入 + **套餐页(3.4.10,可跳过)**;一号多店确认;负责人复核;试核销100元;附件一 |
|
||||
| 经营 | 首页三卡;排行;订单/权益/工单;佣金快照下钻 |
|
||||
| 财务 | 本合伙人月账对账(Wave 2/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 权益;签约主体:山西领势酒业有限责任公司 |
|
||||
| 城市合伙人 | 开通城市;创建合伙人(全城/区域+佣金);仓库管理(Wave 3) |
|
||||
| 审核交易 | 门店审核;全量订单/权益/工单;发票 2 工作日;补核销 |
|
||||
| 审核交易 | 门店审核;**套餐变更审核(3.4.10)**;全量订单/权益/工单;发票 2 工作日;补核销 |
|
||||
| 结算 | 门店+合伙人双 Tab;提现审;白名单配置;**物流对账**(按承运商月结) |
|
||||
| 增长 | 推广码;问卷;评价;腾讯位置热力图 |
|
||||
| **门店套餐(3.4.10)** | 门店详情「套餐」Tab 直存;套餐变更审核通过/驳回(REQ-H-025) |
|
||||
|
||||
### 4.5 端职责矩阵
|
||||
|
||||
@@ -300,6 +347,7 @@
|
||||
| 工单 | ●发起 | 查看 | 查看 | ●决策 |
|
||||
| 结算提现 | | ● | 对账确认 | ●审打款 |
|
||||
| 推广码/问卷/评价 | ● | 被评价 | 看数据 | 配置/看板 |
|
||||
| **门店套餐(3.4.10)** | 看生效套餐 | 编辑提审 | 编辑提审 | ●直存/●审核 |
|
||||
|
||||
---
|
||||
|
||||
@@ -324,7 +372,7 @@
|
||||
|
||||
### 6.1 核心实体(TECH-001 ~ 019)
|
||||
|
||||
用户、商品、订单(含佣金快照)、好客权益账本、门店(主账号/店员)、核销单、工单、结算/提现、合伙人(管辖+佣金)、推广码、仓库(W3)、待处理核销单(W3)、门店账号(W2)。
|
||||
用户、商品、订单(含佣金快照)、好客权益账本、门店(主账号/店员)、**门店套餐(3.4.10)**、核销单、工单、结算/提现、合伙人(管辖+佣金)、推广码、仓库(W3)、待处理核销单(W3)、门店账号(W2)。
|
||||
|
||||
### 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.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 |
|
||||
|
||||
@@ -216,6 +216,18 @@
|
||||
| **REQ-H-018~019** 问卷/评价总部看板 | 评价有 DB;总部看板无 |
|
||||
| **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. 按端汇总矩阵
|
||||
|
||||
@@ -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