feat(store): 门店套餐 v3.4.10 全端实现

新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 19:30:59 +08:00
parent 7e3dc131ad
commit 3c19b7dd97
35 changed files with 2128 additions and 28 deletions
@@ -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 直存;套餐变更审核通过/驳回 |
+2
View File
@@ -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>
);
}
+6
View File
@@ -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>
+1
View File
@@ -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: '异常' },
]}
/>
+3
View File
@@ -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>
);
}
+13
View File
@@ -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,
}))
: [],
};
}
+49
View File
@@ -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);
}
+57 -8
View File
@@ -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>
);
}
+2 -1
View File
@@ -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 />} />
+43
View File
@@ -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;
}
+6
View File
@@ -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>
+172
View File
@@ -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);
}
+1
View File
@@ -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;
}
+5 -2
View File
@@ -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 {
+51
View File
@@ -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
View File
@@ -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 @@
| 酒祖杜康(国标特级2053° | ¥298 | ¥1788 |
| 酒祖杜康(国标特级3053° | ¥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 |
+12
View File
@@ -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 | 门店 IDFK → `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` CRUDadmin)、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 | 用户 | 门店详情展示生效套餐;套餐异议申诉入口 |