feat(store): add two-level store categories for admin and partner open-store
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:
2026-07-22 12:10:02 +08:00
parent 823e439101
commit 9a550cbaaf
18 changed files with 848 additions and 26 deletions
+2
View File
@@ -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: '门店资源' },
], ],
+4
View File
@@ -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>
);
}
+15 -1
View File
@@ -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 字';
+85 -4
View File
@@ -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>
+6
View File
@@ -481,8 +481,14 @@ model CommonStoreCategory {
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)
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[] stores Store[]
@@index([parentId, sort])
@@index([status])
@@map("common_store_category") @@map("common_store_category")
} }
@@ -339,8 +339,14 @@ model CommonStoreCategory {
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)
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[] stores Store[]
@@index([parentId, sort])
@@index([status])
@@map("common_store_category") @@map("common_store_category")
} }
+30 -7
View File
@@ -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 ?? '河南省'),