feat(store): add two-level store categories for admin and partner open-store
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Admin CRUD under stores menu; partner picks leaf category on create. Default sync only inserts missing rows and never updates existing ones. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage';
|
|||||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
import StoreMediaPage from './pages/StoreMediaPage';
|
||||||
|
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||||
import PromoCodesPage from './pages/PromoCodesPage';
|
import PromoCodesPage from './pages/PromoCodesPage';
|
||||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
<Route path="/stores" element={<StoresPage />} />
|
<Route path="/stores" element={<StoresPage />} />
|
||||||
|
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||||
<Route path="/resources" element={<ResourcesPage />} />
|
<Route path="/resources" element={<ResourcesPage />} />
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '门店',
|
label: '门店',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/stores', label: '门店列表' },
|
{ key: '/stores', label: '门店列表' },
|
||||||
|
{ key: '/store-categories', label: '门店分类' },
|
||||||
{ key: '/store-accounts', label: '门店账户' },
|
{ key: '/store-accounts', label: '门店账户' },
|
||||||
{ key: '/store-media', label: '门店资源' },
|
{ key: '/store-media', label: '门店资源' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||||
|
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||||
|
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||||
|
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||||
|
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
|
||||||
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
||||||
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
||||||
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type CategoryNode = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: string | null;
|
||||||
|
status: string;
|
||||||
|
children?: CategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
|
||||||
|
|
||||||
|
function flattenTree(tree: CategoryNode[]): FlatRow[] {
|
||||||
|
const rows: FlatRow[] = [];
|
||||||
|
for (const root of tree) {
|
||||||
|
rows.push({ ...root, level: 1, children: undefined });
|
||||||
|
for (const child of root.children ?? []) {
|
||||||
|
rows.push({
|
||||||
|
...child,
|
||||||
|
level: 2,
|
||||||
|
parentName: root.name,
|
||||||
|
children: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoreCategoriesPage() {
|
||||||
|
const [tree, setTree] = useState<CategoryNode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||||
|
const rootOptions = useMemo(
|
||||||
|
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
|
||||||
|
[tree],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<CategoryNode[]>('/admin/store-categories');
|
||||||
|
setTree(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openCreate(parentId?: string) {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
sort: 0,
|
||||||
|
parentId: parentId || undefined,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: FlatRow) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sort: row.sort,
|
||||||
|
parentId: row.parentId || undefined,
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
code: String(values.code).trim().toUpperCase(),
|
||||||
|
name: String(values.name).trim(),
|
||||||
|
sort: Number(values.sort ?? 0),
|
||||||
|
parentId: values.parentId || null,
|
||||||
|
status: values.status || 'ACTIVE',
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/store-categories/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/store-categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<FlatRow> = [
|
||||||
|
{
|
||||||
|
title: '层级',
|
||||||
|
dataIndex: 'level',
|
||||||
|
width: 80,
|
||||||
|
render: (level) => (level === 1 ? <Tag color="blue">一级</Tag> : <Tag>二级</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (name, row) => (
|
||||||
|
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||||
|
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||||
|
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (s) => (
|
||||||
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||||
|
{row.level === 1 ? (
|
||||||
|
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||||
|
) : null}
|
||||||
|
<Popconfirm
|
||||||
|
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已处理');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||||
|
message.success('已同步默认分类');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
同步默认分类
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑分类' : '新增分类'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
|
||||||
|
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input placeholder="分类名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="parentId" label="上级分类(空=一级)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="不选则为一级分类"
|
||||||
|
options={rootOptions.filter((o) => o.value !== editing?.id)}
|
||||||
|
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'ACTIVE', label: '启用' },
|
||||||
|
{ value: 'DISABLED', label: '停用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export type StoreDraftForm = {
|
|||||||
address: string;
|
address: string;
|
||||||
openTime: string;
|
openTime: string;
|
||||||
closeTime: string;
|
closeTime: string;
|
||||||
|
categoryParentId: string;
|
||||||
|
categoryId: string;
|
||||||
intro: string;
|
intro: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
envPhotoUrls: string[];
|
envPhotoUrls: string[];
|
||||||
@@ -41,6 +43,8 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
address: '',
|
address: '',
|
||||||
openTime: '10:00',
|
openTime: '10:00',
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
|
categoryParentId: '',
|
||||||
|
categoryId: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
@@ -74,6 +78,8 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
|||||||
address: String(raw.address ?? base.address),
|
address: String(raw.address ?? base.address),
|
||||||
openTime: String(raw.openTime ?? base.openTime),
|
openTime: String(raw.openTime ?? base.openTime),
|
||||||
closeTime: String(raw.closeTime ?? base.closeTime),
|
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||||
|
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
|
||||||
|
categoryId: String(raw.categoryId ?? base.categoryId),
|
||||||
intro: String(raw.intro ?? base.intro),
|
intro: String(raw.intro ?? base.intro),
|
||||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||||
@@ -135,7 +141,14 @@ function timeToMinutes(value: string): number {
|
|||||||
export function validateStoreStep1(
|
export function validateStoreStep1(
|
||||||
form: Pick<
|
form: Pick<
|
||||||
StoreDraftForm,
|
StoreDraftForm,
|
||||||
'regionCodes' | 'cityId' | 'name' | 'address' | 'openTime' | 'closeTime' | 'intro'
|
| 'regionCodes'
|
||||||
|
| 'cityId'
|
||||||
|
| 'name'
|
||||||
|
| 'address'
|
||||||
|
| 'openTime'
|
||||||
|
| 'closeTime'
|
||||||
|
| 'categoryId'
|
||||||
|
| 'intro'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
@@ -149,6 +162,7 @@ export function validateStoreStep1(
|
|||||||
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||||
return '营业结束时间须晚于开始时间';
|
return '营业结束时间须晚于开始时间';
|
||||||
}
|
}
|
||||||
|
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||||
if (form.intro.trim()) {
|
if (form.intro.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||||
|
|||||||
@@ -45,14 +45,15 @@ import {
|
|||||||
|
|
||||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||||
|
|
||||||
|
type StoreCategoryNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children?: StoreCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
type FieldErrors = {
|
type FieldErrors = {
|
||||||
|
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
storeSmsCode?: string;
|
storeSmsCode?: string;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +102,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const [smsHint, setSmsHint] = useState('');
|
const [smsHint, setSmsHint] = useState('');
|
||||||
|
|
||||||
|
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||||
|
|
||||||
const draftSaveDisabledRef = useRef(false);
|
const draftSaveDisabledRef = useRef(false);
|
||||||
|
|
||||||
function reportFormError(message: string) {
|
function reportFormError(message: string) {
|
||||||
@@ -151,6 +154,26 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
|
||||||
|
.then((list) => {
|
||||||
|
const tree = Array.isArray(list) ? list : [];
|
||||||
|
setCategoryTree(tree);
|
||||||
|
if (form.categoryId && !form.categoryParentId) {
|
||||||
|
const parent = tree.find((root) =>
|
||||||
|
(root.children ?? []).some((child) => child.id === form.categoryId),
|
||||||
|
);
|
||||||
|
if (parent) patchForm({ categoryParentId: parent.id });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setCategoryTree([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categoryChildren = useMemo(() => {
|
||||||
|
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
|
||||||
|
return Array.isArray(parent?.children) ? parent!.children! : [];
|
||||||
|
}, [categoryTree, form.categoryParentId]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -491,6 +514,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
closeTime: form.closeTime.trim(),
|
closeTime: form.closeTime.trim(),
|
||||||
|
|
||||||
|
categoryId: form.categoryId.trim(),
|
||||||
|
|
||||||
intro: form.intro.trim() || undefined,
|
intro: form.intro.trim() || undefined,
|
||||||
|
|
||||||
coverUrl: form.coverUrl.trim() || undefined,
|
coverUrl: form.coverUrl.trim() || undefined,
|
||||||
@@ -653,6 +678,62 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>店铺类型 <span className="text-primary">*</span></label>
|
||||||
|
|
||||||
|
<div className="partner-input-row" style={{ gap: 8 }}>
|
||||||
|
|
||||||
|
<select
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
value={form.categoryParentId}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
|
||||||
|
|
||||||
|
aria-label="一级店铺类型"
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<option value="">选择大类</option>
|
||||||
|
|
||||||
|
{categoryTree.map((item) => (
|
||||||
|
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
value={form.categoryId}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ categoryId: e.target.value })}
|
||||||
|
|
||||||
|
disabled={!form.categoryParentId}
|
||||||
|
|
||||||
|
aria-label="二级店铺类型"
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
|
||||||
|
|
||||||
|
{categoryChildren.map((item) => (
|
||||||
|
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
|
|
||||||
<label>详细地址 <span className="text-primary">*</span></label>
|
<label>详细地址 <span className="text-primary">*</span></label>
|
||||||
|
|||||||
@@ -477,12 +477,18 @@ model CommonProductDetailTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CommonStoreCategory {
|
model CommonStoreCategory {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
name String @db.VarChar(64)
|
name String @db.VarChar(64)
|
||||||
sort Int @default(0)
|
sort Int @default(0)
|
||||||
stores Store[]
|
parentId BigInt? @map("parent_id") @db.UnsignedBigInt
|
||||||
|
status String @default("ACTIVE") @db.VarChar(16)
|
||||||
|
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||||
|
children CommonStoreCategory[] @relation("StoreCategoryTree")
|
||||||
|
stores Store[]
|
||||||
|
|
||||||
|
@@index([parentId, sort])
|
||||||
|
@@index([status])
|
||||||
@@map("common_store_category")
|
@@map("common_store_category")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -335,12 +335,18 @@ model CommonProductItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CommonStoreCategory {
|
model CommonStoreCategory {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
name String @db.VarChar(64)
|
name String @db.VarChar(64)
|
||||||
sort Int @default(0)
|
sort Int @default(0)
|
||||||
stores Store[]
|
parentId BigInt? @map("parent_id") @db.UnsignedBigInt
|
||||||
|
status String @default("ACTIVE") @db.VarChar(16)
|
||||||
|
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||||
|
children CommonStoreCategory[] @relation("StoreCategoryTree")
|
||||||
|
stores Store[]
|
||||||
|
|
||||||
|
@@index([parentId, sort])
|
||||||
|
@@index([status])
|
||||||
@@map("common_store_category")
|
@@map("common_store_category")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,13 +266,36 @@ async function main() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const categories = await Promise.all([
|
const { DEFAULT_STORE_CATEGORY_TREE } = await import('../src/modules/store/store-category.defaults');
|
||||||
|
const categoryByCode = new Map<string, { id: bigint }>();
|
||||||
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
|
for (const root of DEFAULT_STORE_CATEGORY_TREE) {
|
||||||
|
const parent = await prisma.commonStoreCategory.create({
|
||||||
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
|
data: {
|
||||||
|
code: root.code,
|
||||||
]);
|
name: root.name,
|
||||||
|
sort: root.sort,
|
||||||
|
parentId: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
categoryByCode.set(root.code, parent);
|
||||||
|
for (const child of root.children) {
|
||||||
|
const row = await prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code: child.code,
|
||||||
|
name: child.name,
|
||||||
|
sort: child.sort,
|
||||||
|
parentId: parent.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
categoryByCode.set(child.code, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const categories = [
|
||||||
|
categoryByCode.get('HOTPOT')!,
|
||||||
|
categoryByCode.get('LOCAL')!,
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const HqOperationAction = {
|
|||||||
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
||||||
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
||||||
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
||||||
|
STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE',
|
||||||
|
STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE',
|
||||||
|
STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE',
|
||||||
|
STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE',
|
||||||
PRODUCT_CREATE: 'PRODUCT_CREATE',
|
PRODUCT_CREATE: 'PRODUCT_CREATE',
|
||||||
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
|
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
|
||||||
PRODUCT_DELETE: 'PRODUCT_DELETE',
|
PRODUCT_DELETE: 'PRODUCT_DELETE',
|
||||||
@@ -94,6 +98,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
||||||
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
||||||
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
|
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类',
|
||||||
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
|
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
|
||||||
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
|
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
|
||||||
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
|
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
|
import { StoreCategoryService } from '../store/store-category.service';
|
||||||
|
import {
|
||||||
|
CreateStoreCategoryDto,
|
||||||
|
UpdateStoreCategoryDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
@Controller('admin/store-categories')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminStoreCategoriesController {
|
||||||
|
constructor(private readonly categories: StoreCategoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categories.listTree({ includeDisabled: true, ensure: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('flat')
|
||||||
|
listFlat() {
|
||||||
|
return this.categories.listFlat({ includeDisabled: true, ensure: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ensure-defaults')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_ENSURE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
includeBody: false,
|
||||||
|
})
|
||||||
|
async ensureDefaults() {
|
||||||
|
await this.categories.ensureDefaults();
|
||||||
|
return this.categories.listTree({ includeDisabled: true, ensure: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_CREATE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
refIdField: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
create(@Body() dto: CreateStoreCategoryDto) {
|
||||||
|
return this.categories.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_UPDATE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateStoreCategoryDto) {
|
||||||
|
return this.categories.update(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_DELETE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.categories.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1150,3 +1150,53 @@ export class AdminBenefitGrantDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateStoreCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
/** 空/不传 = 一级分类;传入一级 id = 二级分类 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreCategoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CityScopeModule } from '../city-scope/city-scope.module';
|
|||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
import { TradeModule } from '../trade/trade.module';
|
import { TradeModule } from '../trade/trade.module';
|
||||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||||
|
import { StoreModule } from '../store/store.module';
|
||||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
import { AdminUsersController } from './admin-users.controller';
|
import { AdminUsersController } from './admin-users.controller';
|
||||||
@@ -10,6 +11,7 @@ import { AdminUsersService } from './admin-users.service';
|
|||||||
import { AdminOrdersController } from './admin-orders.controller';
|
import { AdminOrdersController } from './admin-orders.controller';
|
||||||
import { AdminOrdersService } from './admin-orders.service';
|
import { AdminOrdersService } from './admin-orders.service';
|
||||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||||
|
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||||
import { AdminStoresService } from './admin-stores.service';
|
import { AdminStoresService } from './admin-stores.service';
|
||||||
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
||||||
import { AdminPartnersService } from './admin-partners.service';
|
import { AdminPartnersService } from './admin-partners.service';
|
||||||
@@ -59,7 +61,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller';
|
|||||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule, StoreModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminDashboardController,
|
AdminDashboardController,
|
||||||
AdminDeployController,
|
AdminDeployController,
|
||||||
@@ -68,6 +70,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
|||||||
AdminStoresController,
|
AdminStoresController,
|
||||||
AdminStoreAccountsController,
|
AdminStoreAccountsController,
|
||||||
AdminStoreMediaController,
|
AdminStoreMediaController,
|
||||||
|
AdminStoreCategoriesController,
|
||||||
AdminPartnersController,
|
AdminPartnersController,
|
||||||
AdminPartnerAccountsController,
|
AdminPartnerAccountsController,
|
||||||
AdminCitiesController,
|
AdminCitiesController,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** 门店两级分类默认树(一级 + 二级) */
|
||||||
|
export const DEFAULT_STORE_CATEGORY_TREE = [
|
||||||
|
{
|
||||||
|
code: 'DINING',
|
||||||
|
name: '餐饮',
|
||||||
|
sort: 1,
|
||||||
|
children: [
|
||||||
|
{ code: 'LOCAL', name: '地方菜', sort: 1 },
|
||||||
|
{ code: 'WESTERN', name: '西餐', sort: 2 },
|
||||||
|
{ code: 'BBQ', name: '烧烤', sort: 3 },
|
||||||
|
{ code: 'HOTPOT', name: '火锅', sort: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'LODGING',
|
||||||
|
name: '住宿',
|
||||||
|
sort: 2,
|
||||||
|
children: [
|
||||||
|
{ code: 'BUDGET_HOTEL', name: '快捷酒店', sort: 1 },
|
||||||
|
{ code: 'INN', name: '旅馆', sort: 2 },
|
||||||
|
{ code: 'LUXURY_HOTEL', name: '豪华酒店', sort: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENTERTAINMENT',
|
||||||
|
name: '娱乐',
|
||||||
|
sort: 3,
|
||||||
|
children: [
|
||||||
|
{ code: 'KTV', name: 'KTV', sort: 1 },
|
||||||
|
{ code: 'CLUB', name: '会所', sort: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { DEFAULT_STORE_CATEGORY_TREE } from './store-category.defaults';
|
||||||
|
|
||||||
|
export type StoreCategoryNode = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: string | null;
|
||||||
|
status: string;
|
||||||
|
children: StoreCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoreCategoryService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅补齐缺失的默认分类,绝不 update 已有行(避免改动线上历史数据)。
|
||||||
|
* 已存在的 HOTPOT/LOCAL 等保持原样;缺的一级/二级才 create。
|
||||||
|
*/
|
||||||
|
async ensureDefaults() {
|
||||||
|
for (const root of DEFAULT_STORE_CATEGORY_TREE) {
|
||||||
|
let parent = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { code: root.code },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
parent = await this.prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code: root.code,
|
||||||
|
name: root.name,
|
||||||
|
sort: root.sort,
|
||||||
|
parentId: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const child of root.children) {
|
||||||
|
const existing = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { code: child.code },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
await this.prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code: child.code,
|
||||||
|
name: child.name,
|
||||||
|
sort: child.sort,
|
||||||
|
parentId: parent.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapNode(
|
||||||
|
row: {
|
||||||
|
id: bigint;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: bigint | null;
|
||||||
|
status: string;
|
||||||
|
children?: Array<{
|
||||||
|
id: bigint;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: bigint | null;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
includeDisabledChildren: boolean,
|
||||||
|
): StoreCategoryNode {
|
||||||
|
const children = (row.children ?? [])
|
||||||
|
.filter((c) => includeDisabledChildren || c.status === 'ACTIVE')
|
||||||
|
.sort((a, b) => a.sort - b.sort || Number(a.id - b.id))
|
||||||
|
.map((c) => this.mapNode(c, includeDisabledChildren));
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sort: row.sort,
|
||||||
|
parentId: row.parentId != null ? String(row.parentId) : null,
|
||||||
|
status: row.status,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTree(options?: { includeDisabled?: boolean; ensure?: boolean }) {
|
||||||
|
if (options?.ensure !== false) {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
}
|
||||||
|
const includeDisabled = options?.includeDisabled === true;
|
||||||
|
const roots = await this.prisma.commonStoreCategory.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: null,
|
||||||
|
...(includeDisabled ? {} : { status: 'ACTIVE' }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return serializeBigInt(roots.map((r) => this.mapNode(r, includeDisabled)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFlat(options?: { includeDisabled?: boolean; ensure?: boolean }) {
|
||||||
|
if (options?.ensure !== false) {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
}
|
||||||
|
const includeDisabled = options?.includeDisabled === true;
|
||||||
|
const rows = await this.prisma.commonStoreCategory.findMany({
|
||||||
|
where: includeDisabled ? undefined : { status: 'ACTIVE' },
|
||||||
|
orderBy: [{ parentId: 'asc' }, { sort: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return serializeBigInt(
|
||||||
|
rows.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sort: row.sort,
|
||||||
|
parentId: row.parentId != null ? String(row.parentId) : null,
|
||||||
|
status: row.status,
|
||||||
|
level: row.parentId == null ? 1 : 2,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertLeafCategoryId(categoryId: bigint) {
|
||||||
|
const row = await this.prisma.commonStoreCategory.findUnique({ where: { id: categoryId } });
|
||||||
|
if (!row || row.status !== 'ACTIVE') {
|
||||||
|
throw new BadRequestException('店铺类型不存在或已停用');
|
||||||
|
}
|
||||||
|
if (row.parentId == null) {
|
||||||
|
throw new BadRequestException('请选择二级店铺类型');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort?: number;
|
||||||
|
parentId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
const code = dto.code.trim().toUpperCase();
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!code) throw new BadRequestException('请填写分类编码');
|
||||||
|
if (!name) throw new BadRequestException('请填写分类名称');
|
||||||
|
let parentId: bigint | null = null;
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { id: BigInt(dto.parentId) },
|
||||||
|
});
|
||||||
|
if (!parent || parent.parentId != null) {
|
||||||
|
throw new BadRequestException('父级必须是一级分类');
|
||||||
|
}
|
||||||
|
parentId = parent.id;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
parentId,
|
||||||
|
status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(row);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new BadRequestException('分类编码已存在');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: bigint,
|
||||||
|
dto: {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
sort?: number;
|
||||||
|
parentId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('分类不存在');
|
||||||
|
|
||||||
|
let parentId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.parentId !== undefined) {
|
||||||
|
if (dto.parentId == null || dto.parentId === '') {
|
||||||
|
parentId = null;
|
||||||
|
} else {
|
||||||
|
if (BigInt(dto.parentId) === id) {
|
||||||
|
throw new BadRequestException('不能将分类设为自己的子级');
|
||||||
|
}
|
||||||
|
const parent = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { id: BigInt(dto.parentId) },
|
||||||
|
});
|
||||||
|
if (!parent || parent.parentId != null) {
|
||||||
|
throw new BadRequestException('父级必须是一级分类');
|
||||||
|
}
|
||||||
|
// 一级分类若已有子级,不允许变成二级
|
||||||
|
if (existing.parentId == null) {
|
||||||
|
const childCount = await this.prisma.commonStoreCategory.count({
|
||||||
|
where: { parentId: id },
|
||||||
|
});
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException('该一级分类下仍有二级分类,不能改为二级');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parentId = parent.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.commonStoreCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.code !== undefined ? { code: dto.code.trim().toUpperCase() } : {}),
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.sort !== undefined ? { sort: dto.sort } : {}),
|
||||||
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
...(dto.status !== undefined
|
||||||
|
? { status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(row);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new BadRequestException('分类编码已存在');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('分类不存在');
|
||||||
|
const childCount = await this.prisma.commonStoreCategory.count({ where: { parentId: id } });
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException('请先删除或停用下级分类');
|
||||||
|
}
|
||||||
|
const storeCount = await this.prisma.store.count({ where: { categoryId: id } });
|
||||||
|
if (storeCount > 0) {
|
||||||
|
// 软停用,避免破坏已有门店关联
|
||||||
|
const row = await this.prisma.commonStoreCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'DISABLED' },
|
||||||
|
});
|
||||||
|
return serializeBigInt({ ...row, softDisabled: true });
|
||||||
|
}
|
||||||
|
await this.prisma.commonStoreCategory.delete({ where: { id } });
|
||||||
|
return { id: String(id), deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
|
import { StoreCategoryService } from './store-category.service';
|
||||||
import { RedeemService } from '../redeem/redeem.service';
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
@@ -23,6 +24,17 @@ export class PublicStoreController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('partner/store-categories')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PartnerStoreCategoriesController {
|
||||||
|
constructor(private readonly categories: StoreCategoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categories.listTree({ includeDisabled: false, ensure: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('partner/stores')
|
@Controller('partner/stores')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class PartnerStoreController {
|
export class PartnerStoreController {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { RedeemModule } from '../redeem/redeem.module';
|
|||||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
|
import { StoreCategoryService } from './store-category.service';
|
||||||
import {
|
import {
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
PartnerReportController,
|
PartnerReportController,
|
||||||
|
PartnerStoreCategoriesController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
@@ -17,13 +19,14 @@ import {
|
|||||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||||
controllers: [
|
controllers: [
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
|
PartnerStoreCategoriesController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
PartnerReportController,
|
PartnerReportController,
|
||||||
ShopStoreController,
|
ShopStoreController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
],
|
],
|
||||||
providers: [StoreService],
|
providers: [StoreService, StoreCategoryService],
|
||||||
exports: [StoreService],
|
exports: [StoreService, StoreCategoryService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { parseBigIntParam } from '../../common/parse-bigint';
|
|||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { AuthService } from '../iam/auth.service';
|
import { AuthService } from '../iam/auth.service';
|
||||||
|
import { StoreCategoryService } from './store-category.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StoreService {
|
export class StoreService {
|
||||||
@@ -23,6 +24,7 @@ export class StoreService {
|
|||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly storeCategoryService: StoreCategoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listOpenStores(cityCode?: string) {
|
async listOpenStores(cityCode?: string) {
|
||||||
@@ -172,11 +174,17 @@ export class StoreService {
|
|||||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||||
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
|
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
|
||||||
|
|
||||||
|
if (!body.categoryId) {
|
||||||
|
throw new BadRequestException('请选择店铺类型');
|
||||||
|
}
|
||||||
|
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
|
||||||
|
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
partnerAccountId: primaryId,
|
partnerAccountId: primaryId,
|
||||||
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
|
categoryId,
|
||||||
name: String(body.name),
|
name: String(body.name),
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
province: String(body.province ?? city.province ?? '河南省'),
|
province: String(body.province ?? city.province ?? '河南省'),
|
||||||
|
|||||||
Reference in New Issue
Block a user