@@ -0,0 +1,21 @@
|
||||
-- 门店多分类关联表(v4.0.9+)
|
||||
-- 执行:mysql ... < migrate-store-category-link.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS store_category_link (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
category_id BIGINT UNSIGNED NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_store_category_link (store_id, category_id),
|
||||
KEY idx_store_category_link_category (category_id),
|
||||
CONSTRAINT fk_store_category_link_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_store_category_link_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 从现有主分类回填
|
||||
INSERT IGNORE INTO store_category_link (store_id, category_id, priority)
|
||||
SELECT id, category_id, 0
|
||||
FROM store_store
|
||||
WHERE category_id IS NOT NULL;
|
||||
@@ -1000,12 +1000,29 @@ model CommonStoreCategory {
|
||||
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children CommonStoreCategory[] @relation("StoreCategoryTree")
|
||||
stores Store[]
|
||||
storeLinks StoreCategoryLink[]
|
||||
|
||||
@@index([parentId, sort])
|
||||
@@index([status])
|
||||
@@map("common_store_category")
|
||||
}
|
||||
|
||||
/// 门店 ↔ 二级分类多对多;store.category_id 保留主分类(排序第一)
|
||||
model StoreCategoryLink {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
categoryId BigInt @map("category_id") @db.UnsignedBigInt
|
||||
priority Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
category CommonStoreCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([storeId, categoryId])
|
||||
@@index([categoryId])
|
||||
@@map("store_category_link")
|
||||
}
|
||||
|
||||
model CommonPromoCode {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
@@ -1542,6 +1559,7 @@ model Store {
|
||||
packages StorePackage[]
|
||||
packageChangeRequests StorePackageChangeRequest[]
|
||||
infoChangeRequests StoreInfoChangeRequest[]
|
||||
categoryLinks StoreCategoryLink[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
|
||||
@@ -14,6 +14,12 @@ import { contractMediaType, normalizeContractUrls } from '../../common/store-med
|
||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { StoreCategoryService } from '../store/store-category.service';
|
||||
import {
|
||||
attachStoreCategories,
|
||||
parseUniqueCategoryIds,
|
||||
storeCategoryLinkInclude,
|
||||
syncStoreCategoryLinks,
|
||||
} from '../store/store-category-link.util';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import type {
|
||||
CreateStoreAccountDto,
|
||||
@@ -123,6 +129,7 @@ export class AdminStoresService {
|
||||
cityRef: { select: { id: true, name: true, code: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } },
|
||||
category: { select: { id: true, name: true, parentId: true } },
|
||||
...storeCategoryLinkInclude,
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
@@ -169,7 +176,7 @@ export class AdminStoresService {
|
||||
return serializeBigInt({
|
||||
items: items.map((s) => {
|
||||
const { visibilityPhones, ...rest } = s;
|
||||
return mapStoreCompat({
|
||||
return mapStoreCompat(attachStoreCategories({
|
||||
...rest,
|
||||
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
|
||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||
@@ -180,7 +187,7 @@ export class AdminStoresService {
|
||||
partner: s.partnerAccount,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
});
|
||||
}));
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
@@ -196,6 +203,7 @@ export class AdminStoresService {
|
||||
cityRef: true,
|
||||
partnerAccount: true,
|
||||
category: true,
|
||||
...storeCategoryLinkInclude,
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
@@ -219,7 +227,7 @@ export class AdminStoresService {
|
||||
}),
|
||||
]);
|
||||
const { visibilityPhones, ...rest } = store;
|
||||
return serializeBigInt(mapStoreCompat({
|
||||
return serializeBigInt(mapStoreCompat(attachStoreCategories({
|
||||
...rest,
|
||||
visibilityWhitelistEnabled: store.visibilityWhitelistEnabled,
|
||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||
@@ -235,7 +243,7 @@ export class AdminStoresService {
|
||||
redeemCount: store._count.redeemRecords,
|
||||
ratingCount: store._count.ratings,
|
||||
_count: undefined,
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) {
|
||||
@@ -430,11 +438,22 @@ export class AdminStoresService {
|
||||
}
|
||||
|
||||
let categoryId: bigint | undefined;
|
||||
if (dto.categoryId !== undefined) {
|
||||
let categoryIds: bigint[] | undefined;
|
||||
if (dto.categoryIds !== undefined) {
|
||||
categoryIds = parseUniqueCategoryIds(dto.categoryIds);
|
||||
if (!categoryIds.length) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
for (const id of categoryIds) {
|
||||
await this.storeCategoryService.assertLeafCategoryId(id);
|
||||
}
|
||||
categoryId = categoryIds[0];
|
||||
} else if (dto.categoryId !== undefined) {
|
||||
if (!dto.categoryId?.trim()) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
categoryId = BigInt(dto.categoryId);
|
||||
categoryIds = [categoryId];
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
}
|
||||
|
||||
@@ -661,6 +680,10 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
await syncStoreCategoryLinks(tx, id, categoryIds);
|
||||
}
|
||||
});
|
||||
|
||||
return this.detailStore(id, actorId);
|
||||
@@ -693,11 +716,20 @@ export class AdminStoresService {
|
||||
if (!city) throw new BadRequestException('开城城市不存在');
|
||||
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
||||
|
||||
if (!dto.categoryId?.trim()) {
|
||||
if (!dto.categoryId?.trim() && (!dto.categoryIds || !dto.categoryIds.length)) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
const categoryIds = parseUniqueCategoryIds(dto.categoryIds);
|
||||
if (!categoryIds.length && dto.categoryId?.trim()) {
|
||||
categoryIds.push(BigInt(dto.categoryId));
|
||||
}
|
||||
if (!categoryIds.length) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
for (const id of categoryIds) {
|
||||
await this.storeCategoryService.assertLeafCategoryId(id);
|
||||
}
|
||||
const categoryId = categoryIds[0];
|
||||
|
||||
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
|
||||
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
|
||||
@@ -775,6 +807,8 @@ export class AdminStoresService {
|
||||
},
|
||||
});
|
||||
|
||||
await syncStoreCategoryLinks(this.prisma, store.id, categoryIds);
|
||||
|
||||
if (dto.coverUrl) {
|
||||
const cover = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
|
||||
@@ -45,9 +45,15 @@ export class CreateStoreDto {
|
||||
@IsString()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryId: string;
|
||||
categoryId?: string;
|
||||
|
||||
/** 多选二级分类;至少选一项 */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categoryIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -240,6 +246,12 @@ export class UpdateStoreDto {
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
/** 多选二级分类;传此项时覆盖 categoryId */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categoryIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export const storeCategoryLinkInclude = {
|
||||
categoryLinks: {
|
||||
include: {
|
||||
category: { include: { parent: true } },
|
||||
},
|
||||
orderBy: [{ priority: 'asc' as const }, { id: 'asc' as const }],
|
||||
},
|
||||
} satisfies Prisma.StoreInclude;
|
||||
|
||||
export type StoreCategoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
parent: { id: string; name: string } | null;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type CategoryLinkRow = {
|
||||
priority: number;
|
||||
category: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
parentId: bigint | null;
|
||||
parent?: { id: bigint; name: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function mapStoreCategoryLinks(links: CategoryLinkRow[] | undefined): StoreCategoryItem[] {
|
||||
return (links ?? []).map((link) => ({
|
||||
id: String(link.category.id),
|
||||
name: link.category.name,
|
||||
parentId: link.category.parentId != null ? String(link.category.parentId) : null,
|
||||
parent: link.category.parent
|
||||
? { id: String(link.category.parent.id), name: link.category.parent.name }
|
||||
: null,
|
||||
priority: link.priority,
|
||||
}));
|
||||
}
|
||||
|
||||
export function attachStoreCategories<
|
||||
T extends {
|
||||
categoryId?: bigint | null;
|
||||
category?: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
parentId?: bigint | null;
|
||||
parent?: { id: bigint; name: string } | null;
|
||||
} | null;
|
||||
categoryLinks?: CategoryLinkRow[];
|
||||
},
|
||||
>(store: T) {
|
||||
const { categoryLinks, ...rest } = store;
|
||||
let categories = mapStoreCategoryLinks(categoryLinks);
|
||||
if (!categories.length && store.category) {
|
||||
categories = [
|
||||
{
|
||||
id: String(store.category.id),
|
||||
name: store.category.name,
|
||||
parentId: store.category.parentId != null ? String(store.category.parentId) : null,
|
||||
parent: store.category.parent
|
||||
? { id: String(store.category.parent.id), name: store.category.parent.name }
|
||||
: null,
|
||||
priority: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
const primary = categories[0] ?? null;
|
||||
return {
|
||||
...rest,
|
||||
categories,
|
||||
categoryId: primary?.id ?? (store.categoryId != null ? String(store.categoryId) : null),
|
||||
category: primary
|
||||
? {
|
||||
id: primary.id,
|
||||
name: primary.name,
|
||||
parentId: primary.parentId,
|
||||
parent: primary.parent,
|
||||
}
|
||||
: store.category
|
||||
? {
|
||||
id: String(store.category.id),
|
||||
name: store.category.name,
|
||||
parentId: store.category.parentId != null ? String(store.category.parentId) : null,
|
||||
parent: store.category.parent
|
||||
? { id: String(store.category.parent.id), name: store.category.parent.name }
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncStoreCategoryLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
storeId: bigint,
|
||||
categoryIds: bigint[],
|
||||
) {
|
||||
const uniqueIds = [...new Set(categoryIds.map((id) => id.toString()))].map(BigInt);
|
||||
await tx.storeCategoryLink.deleteMany({ where: { storeId } });
|
||||
if (uniqueIds.length === 0) {
|
||||
await tx.store.update({ where: { id: storeId }, data: { categoryId: null } });
|
||||
return;
|
||||
}
|
||||
await tx.storeCategoryLink.createMany({
|
||||
data: uniqueIds.map((categoryId, index) => ({
|
||||
storeId,
|
||||
categoryId,
|
||||
priority: index,
|
||||
})),
|
||||
});
|
||||
await tx.store.update({
|
||||
where: { id: storeId },
|
||||
data: { categoryId: uniqueIds[0] },
|
||||
});
|
||||
}
|
||||
|
||||
export function parseUniqueCategoryIds(raw: unknown): bigint[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
const ids: bigint[] = [];
|
||||
for (const item of raw) {
|
||||
const id = BigInt(String(item));
|
||||
const key = id.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -253,7 +253,11 @@ export class StoreCategoryService {
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException('请先删除或停用下级分类');
|
||||
}
|
||||
const storeCount = await this.prisma.store.count({ where: { categoryId: id } });
|
||||
const storeCount = await this.prisma.store.count({
|
||||
where: {
|
||||
OR: [{ categoryId: id }, { categoryLinks: { some: { categoryId: id } } }],
|
||||
},
|
||||
});
|
||||
if (storeCount > 0) {
|
||||
// 软停用,避免破坏已有门店关联
|
||||
const row = await this.prisma.commonStoreCategory.update({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, 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';
|
||||
@@ -109,41 +109,51 @@ export class PartnerStoreController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
||||
}
|
||||
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/categories')
|
||||
async getCategories(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.getStoreCategories(BigInt(id));
|
||||
}
|
||||
@Get(':id/categories')
|
||||
async getCategories(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.partnerGetStoreCategories(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/categories')
|
||||
async assignCategory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { categoryId: string; priority?: number },
|
||||
) {
|
||||
return this.storeService.assignCategoryToStore(BigInt(id), body.categoryId, body.priority);
|
||||
}
|
||||
@Post(':id/categories')
|
||||
async assignCategory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { categoryId: string; priority?: number },
|
||||
) {
|
||||
return this.storeService.partnerAssignCategoryToStore(
|
||||
user.actorId,
|
||||
BigInt(id),
|
||||
body.categoryId,
|
||||
body.priority,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/categories/:categoryId')
|
||||
async removeCategory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
) {
|
||||
return this.storeService.removeCategoryFromStore(BigInt(id), categoryId);
|
||||
}
|
||||
@Delete(':id/categories/:categoryId')
|
||||
async removeCategory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
) {
|
||||
return this.storeService.partnerRemoveCategoryFromStore(user.actorId, BigInt(id), categoryId);
|
||||
}
|
||||
|
||||
@Put(':id/categories')
|
||||
async replaceCategories(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { categoryIds: string[]; priorities?: Record<string, number> },
|
||||
) {
|
||||
return this.storeService.replaceStoreCategories(BigInt(id), body.categoryIds, body.priorities);
|
||||
}
|
||||
@Put(':id/categories')
|
||||
async replaceCategories(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { categoryIds: string[]; priorities?: Record<string, number> },
|
||||
) {
|
||||
return this.storeService.partnerReplaceStoreCategories(
|
||||
user.actorId,
|
||||
BigInt(id),
|
||||
body.categoryIds,
|
||||
body.priorities,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
|
||||
@@ -18,6 +18,12 @@ 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';
|
||||
import {
|
||||
attachStoreCategories,
|
||||
parseUniqueCategoryIds,
|
||||
storeCategoryLinkInclude,
|
||||
syncStoreCategoryLinks,
|
||||
} from './store-category-link.util';
|
||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||
import {
|
||||
TestWhitelistService,
|
||||
@@ -185,6 +191,7 @@ export class StoreService {
|
||||
where: where as never,
|
||||
include: {
|
||||
category: { include: { parent: true } },
|
||||
...storeCategoryLinkInclude,
|
||||
coverResource: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -226,11 +233,11 @@ export class StoreService {
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
const mapped = mapStoreCompat(
|
||||
{
|
||||
attachStoreCategories({
|
||||
...rest,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
},
|
||||
}),
|
||||
{ publicDial: true },
|
||||
);
|
||||
const distanceMeters =
|
||||
@@ -263,6 +270,7 @@ export class StoreService {
|
||||
where: { id, status: 'OPEN' },
|
||||
include: {
|
||||
category: { include: { parent: true } },
|
||||
...storeCategoryLinkInclude,
|
||||
coverResource: true,
|
||||
},
|
||||
});
|
||||
@@ -287,7 +295,7 @@ export class StoreService {
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
return serializeBigInt(
|
||||
mapStoreCompat(
|
||||
{
|
||||
attachStoreCategories({
|
||||
...rest,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
@@ -310,7 +318,7 @@ export class StoreService {
|
||||
sortOrder: p.sortOrder,
|
||||
};
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ publicDial: true },
|
||||
),
|
||||
);
|
||||
@@ -332,17 +340,17 @@ export class StoreService {
|
||||
}
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where,
|
||||
include: { category: true, coverResource: true },
|
||||
include: { category: true, ...storeCategoryLinkInclude, coverResource: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
return serializeBigInt(stores.map((s) => mapStoreCompat(s)));
|
||||
return serializeBigInt(stores.map((s) => mapStoreCompat(attachStoreCategories(s))));
|
||||
}
|
||||
|
||||
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerAccountId: primaryId },
|
||||
include: { category: true, coverResource: true },
|
||||
include: { category: true, ...storeCategoryLinkInclude, coverResource: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (this.isSubAccount(account)) {
|
||||
@@ -350,7 +358,7 @@ export class StoreService {
|
||||
}
|
||||
|
||||
const media = await this.loadPartnerStoreMedia(storeId);
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
return serializeBigInt(mapStoreCompat(attachStoreCategories({ ...store, media })));
|
||||
}
|
||||
|
||||
async partnerListCities(partnerAccountId: bigint) {
|
||||
@@ -444,11 +452,20 @@ export class StoreService {
|
||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
|
||||
|
||||
if (!body.categoryId) {
|
||||
if (!body.categoryId && !Array.isArray(body.categoryIds)) {
|
||||
throw new BadRequestException('请选择店铺类型');
|
||||
}
|
||||
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
const categoryIds = parseUniqueCategoryIds(body.categoryIds);
|
||||
if (!categoryIds.length && body.categoryId) {
|
||||
categoryIds.push(parseBigIntParam(body.categoryId, '分类ID'));
|
||||
}
|
||||
if (!categoryIds.length) {
|
||||
throw new BadRequestException('请选择店铺类型');
|
||||
}
|
||||
for (const id of categoryIds) {
|
||||
await this.storeCategoryService.assertLeafCategoryId(id);
|
||||
}
|
||||
const categoryId = categoryIds[0];
|
||||
|
||||
const openTime = body.openTime ? String(body.openTime).trim() : '10:00';
|
||||
const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00';
|
||||
@@ -507,6 +524,8 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
await syncStoreCategoryLinks(this.prisma, store.id, categoryIds);
|
||||
|
||||
if (latitude == null || longitude == null) {
|
||||
await this.ensureStoreCoordinates(store);
|
||||
}
|
||||
@@ -1515,4 +1534,138 @@ export class StoreService {
|
||||
});
|
||||
if (!event) throw new ForbiddenException('无权查看该门店');
|
||||
}
|
||||
|
||||
async getStoreCategories(storeId: bigint) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
include: {
|
||||
category: { include: { parent: true } },
|
||||
...storeCategoryLinkInclude,
|
||||
},
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
return serializeBigInt(attachStoreCategories(store).categories);
|
||||
}
|
||||
|
||||
async partnerGetStoreCategories(partnerAccountId: bigint, storeId: bigint) {
|
||||
await this.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.getStoreCategories(storeId);
|
||||
}
|
||||
|
||||
async assignCategoryToStore(storeId: bigint, categoryIdRaw: string, priority?: number) {
|
||||
const categoryId = parseBigIntParam(categoryIdRaw, '分类ID');
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
|
||||
const existing = await this.prisma.storeCategoryLink.findUnique({
|
||||
where: { storeId_categoryId: { storeId, categoryId } },
|
||||
});
|
||||
if (existing) {
|
||||
if (priority != null) {
|
||||
await this.prisma.storeCategoryLink.update({
|
||||
where: { id: existing.id },
|
||||
data: { priority },
|
||||
});
|
||||
}
|
||||
return this.getStoreCategories(storeId);
|
||||
}
|
||||
|
||||
const nextPriority =
|
||||
priority ??
|
||||
((await this.prisma.storeCategoryLink.count({ where: { storeId } })) || 0);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.storeCategoryLink.create({
|
||||
data: { storeId, categoryId, priority: nextPriority },
|
||||
});
|
||||
if (!store.categoryId) {
|
||||
await tx.store.update({ where: { id: storeId }, data: { categoryId } });
|
||||
}
|
||||
});
|
||||
return this.getStoreCategories(storeId);
|
||||
}
|
||||
|
||||
async partnerAssignCategoryToStore(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
categoryId: string,
|
||||
priority?: number,
|
||||
) {
|
||||
await this.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.assignCategoryToStore(storeId, categoryId, priority);
|
||||
}
|
||||
|
||||
async removeCategoryFromStore(storeId: bigint, categoryIdRaw: string) {
|
||||
const categoryId = parseBigIntParam(categoryIdRaw, '分类ID');
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
|
||||
const linkCount = await this.prisma.storeCategoryLink.count({ where: { storeId } });
|
||||
const legacyOnly = linkCount === 0 && store.categoryId?.toString() === categoryId.toString();
|
||||
if (linkCount <= 1 && !legacyOnly) {
|
||||
throw new BadRequestException('门店至少保留一个分类');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.storeCategoryLink.deleteMany({ where: { storeId, categoryId } });
|
||||
const remaining = await tx.storeCategoryLink.findMany({
|
||||
where: { storeId },
|
||||
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const nextPrimary = remaining[0]?.categoryId ?? null;
|
||||
await tx.store.update({
|
||||
where: { id: storeId },
|
||||
data: { categoryId: nextPrimary },
|
||||
});
|
||||
});
|
||||
return this.getStoreCategories(storeId);
|
||||
}
|
||||
|
||||
async partnerRemoveCategoryFromStore(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
categoryId: string,
|
||||
) {
|
||||
await this.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.removeCategoryFromStore(storeId, categoryId);
|
||||
}
|
||||
|
||||
async replaceStoreCategories(
|
||||
storeId: bigint,
|
||||
categoryIdsRaw: string[],
|
||||
priorities?: Record<string, number>,
|
||||
) {
|
||||
if (!categoryIdsRaw?.length) {
|
||||
throw new BadRequestException('请至少选择一个门店分类');
|
||||
}
|
||||
const parsed = parseUniqueCategoryIds(categoryIdsRaw);
|
||||
if (!parsed.length) {
|
||||
throw new BadRequestException('请至少选择一个门店分类');
|
||||
}
|
||||
for (const id of parsed) {
|
||||
await this.storeCategoryService.assertLeafCategoryId(id);
|
||||
}
|
||||
const sorted = [...parsed].sort((a, b) => {
|
||||
const pa = priorities?.[a.toString()] ?? 0;
|
||||
const pb = priorities?.[b.toString()] ?? 0;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return Number(a - b);
|
||||
});
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await syncStoreCategoryLinks(tx, storeId, sorted);
|
||||
});
|
||||
return this.getStoreCategories(storeId);
|
||||
}
|
||||
|
||||
async partnerReplaceStoreCategories(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
categoryIds: string[],
|
||||
priorities?: Record<string, number>,
|
||||
) {
|
||||
await this.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.replaceStoreCategories(storeId, categoryIds, priorities);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user