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:
@@ -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 { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import { RedeemService } from '../redeem/redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStoreController {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import {
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PublicStoreController,
|
||||
ShopDashboardController,
|
||||
@@ -17,13 +19,14 @@ import {
|
||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
ShopStoreController,
|
||||
ShopDashboardController,
|
||||
],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
providers: [StoreService, StoreCategoryService],
|
||||
exports: [StoreService, StoreCategoryService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parseBigIntParam } from '../../common/parse-bigint';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
@@ -23,6 +24,7 @@ export class StoreService {
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
@@ -172,11 +174,17 @@ export class StoreService {
|
||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : 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({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerAccountId: primaryId,
|
||||
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
|
||||
categoryId,
|
||||
name: String(body.name),
|
||||
phone: normalizedPhone,
|
||||
province: String(body.province ?? city.province ?? '河南省'),
|
||||
|
||||
Reference in New Issue
Block a user