feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,33 +0,0 @@
|
||||
/** 门店两级分类默认树(一级 + 二级) */
|
||||
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;
|
||||
@@ -1,267 +0,0 @@
|
||||
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,109 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StorePackageService } from './store-package.service';
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStorePackageController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get(':storeId/packages')
|
||||
getPackages(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
|
||||
return this.packages.getPartnerPackages(user.actorId, BigInt(storeId));
|
||||
}
|
||||
|
||||
@Put(':storeId/packages')
|
||||
submitPackages(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('storeId') storeId: string,
|
||||
@Body() body: { packages: unknown },
|
||||
) {
|
||||
const normalized = this.packages.normalizePackages(body.packages);
|
||||
return this.packages.submitPartnerChangeRequest(user.actorId, BigInt(storeId), normalized);
|
||||
}
|
||||
|
||||
@Get(':storeId/package-change-requests')
|
||||
listRequests(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
|
||||
return this.packages.listPartnerChangeRequests(user.actorId, BigInt(storeId));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/store')
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopStorePackageController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get('packages')
|
||||
getPackages(@CurrentUser() user: AuthUser) {
|
||||
return this.packages.getShopPackages(user.actorId, user.storeId!);
|
||||
}
|
||||
|
||||
@Put('packages')
|
||||
submitPackages(@CurrentUser() user: AuthUser, @Body() body: { packages: unknown }) {
|
||||
const normalized = this.packages.normalizePackages(body.packages);
|
||||
return this.packages.submitShopChangeRequest(user.actorId, user.storeId!, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/stores')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStorePackageController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get(':storeId/packages')
|
||||
getPackages(@Param('storeId') storeId: string) {
|
||||
return this.packages.adminGetPackages(BigInt(storeId));
|
||||
}
|
||||
|
||||
@Put(':storeId/packages')
|
||||
savePackages(@Param('storeId') storeId: string, @Body() body: { packages: unknown }) {
|
||||
const normalized = this.packages.normalizePackages(body.packages);
|
||||
return this.packages.adminDirectSave(BigInt(storeId), normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-package-audits')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStorePackageAuditController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.packages.adminListAudits({
|
||||
status: status || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':requestId')
|
||||
detail(@Param('requestId') requestId: string) {
|
||||
return this.packages.adminGetAuditDetail(BigInt(requestId));
|
||||
}
|
||||
|
||||
@Put(':requestId/audit')
|
||||
audit(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('requestId') requestId: string,
|
||||
@Body() body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string },
|
||||
) {
|
||||
return this.packages.adminAuditRequest(BigInt(requestId), user.actorId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('stores')
|
||||
export class PublicStorePackageController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get(':storeId/packages')
|
||||
list(@Param('storeId') storeId: string) {
|
||||
return this.packages.listLivePackages(BigInt(storeId));
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { STORE_PACKAGE_MAX_COUNT, type StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreService } from './store.service';
|
||||
|
||||
type PackageInput = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class StorePackageService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storeService: StoreService,
|
||||
) {}
|
||||
|
||||
normalizePackages(raw: unknown): StorePackageItemDto[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new BadRequestException('packages 须为数组');
|
||||
}
|
||||
if (raw.length > STORE_PACKAGE_MAX_COUNT) {
|
||||
throw new BadRequestException(`套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`);
|
||||
}
|
||||
return raw.map((item, index) => this.normalizeOne(item as PackageInput, index));
|
||||
}
|
||||
|
||||
private normalizeOne(item: PackageInput, index: number): StorePackageItemDto {
|
||||
const name = String(item.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException(`第 ${index + 1} 条套餐名称不能为空`);
|
||||
const priceRaw = item.price;
|
||||
const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(String(priceRaw ?? '').trim());
|
||||
if (!Number.isFinite(priceNum) || priceNum < 0) {
|
||||
throw new BadRequestException(`第 ${index + 1} 条套餐价格须为非负数字`);
|
||||
}
|
||||
const dishes = String(item.dishes ?? '').trim();
|
||||
if (!dishes) throw new BadRequestException(`第 ${index + 1} 条套餐菜品不能为空`);
|
||||
const usableTime = item.usableTime != null && String(item.usableTime).trim()
|
||||
? String(item.usableTime).trim()
|
||||
: null;
|
||||
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
|
||||
? String(item.otherNotes).trim()
|
||||
: null;
|
||||
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
|
||||
return {
|
||||
name,
|
||||
price: priceNum.toFixed(2),
|
||||
dishes,
|
||||
usableTime,
|
||||
otherNotes,
|
||||
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
|
||||
};
|
||||
}
|
||||
|
||||
private mapLiveRow(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
price: Prisma.Decimal;
|
||||
dishes: string;
|
||||
usableTime: string | null;
|
||||
otherNotes: string | null;
|
||||
sortOrder: number;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
price: row.price.toFixed(2),
|
||||
dishes: row.dishes,
|
||||
usableTime: row.usableTime,
|
||||
otherNotes: row.otherNotes,
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
async listLivePackages(storeId: bigint) {
|
||||
const rows = await this.prisma.storePackage.findMany({
|
||||
where: { storeId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => this.mapLiveRow(r));
|
||||
}
|
||||
|
||||
async getPartnerPackages(partnerAccountId: bigint, storeId: bigint) {
|
||||
await this.storeService.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.getPackagesWithPending(storeId);
|
||||
}
|
||||
|
||||
async getShopPackages(storeAccountId: bigint, storeId: bigint) {
|
||||
await this.storeService.getShopStore(storeAccountId, storeId);
|
||||
return this.getPackagesWithPending(storeId);
|
||||
}
|
||||
|
||||
private async getPackagesWithPending(storeId: bigint) {
|
||||
const [live, pendingRequest] = await Promise.all([
|
||||
this.listLivePackages(storeId),
|
||||
this.prisma.storePackageChangeRequest.findFirst({
|
||||
where: { storeId, status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
live,
|
||||
pendingRequest: pendingRequest
|
||||
? {
|
||||
id: pendingRequest.id.toString(),
|
||||
status: pendingRequest.status,
|
||||
packages: pendingRequest.packagesJson as unknown as StorePackageItemDto[],
|
||||
rejectReason: pendingRequest.rejectReason,
|
||||
createdAt: pendingRequest.createdAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
async submitPartnerChangeRequest(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
packages: StorePackageItemDto[],
|
||||
) {
|
||||
await this.storeService.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.submitChangeRequest(storeId, packages, 'PARTNER', partnerAccountId);
|
||||
}
|
||||
|
||||
async submitShopChangeRequest(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
packages: StorePackageItemDto[],
|
||||
) {
|
||||
await this.storeService.getShopStore(storeAccountId, storeId);
|
||||
return this.submitChangeRequest(storeId, packages, 'SHOP', storeAccountId);
|
||||
}
|
||||
|
||||
private async submitChangeRequest(
|
||||
storeId: bigint,
|
||||
packages: StorePackageItemDto[],
|
||||
submitterType: 'PARTNER' | 'SHOP',
|
||||
submitterId: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.storePackageChangeRequest.findFirst({
|
||||
where: { storeId, status: 'PENDING' },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('该门店已有套餐变更审核中,请等待总部处理');
|
||||
}
|
||||
const req = await this.prisma.storePackageChangeRequest.create({
|
||||
data: {
|
||||
storeId,
|
||||
status: 'PENDING',
|
||||
packagesJson: packages as unknown as Prisma.InputJsonValue,
|
||||
submitterType,
|
||||
submitterId,
|
||||
},
|
||||
});
|
||||
return serializeBigInt({
|
||||
id: req.id.toString(),
|
||||
status: req.status,
|
||||
createdAt: req.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerChangeRequests(partnerAccountId: bigint, storeId: bigint) {
|
||||
await this.storeService.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.listChangeRequests(storeId);
|
||||
}
|
||||
|
||||
private async listChangeRequests(storeId: bigint) {
|
||||
const rows = await this.prisma.storePackageChangeRequest.findMany({
|
||||
where: { storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
return serializeBigInt(
|
||||
rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
storeId: r.storeId.toString(),
|
||||
status: r.status,
|
||||
packages: r.packagesJson as unknown as StorePackageItemDto[],
|
||||
submitterType: r.submitterType,
|
||||
submitterId: r.submitterId.toString(),
|
||||
rejectReason: r.rejectReason,
|
||||
reviewedAt: r.reviewedAt?.toISOString() ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async adminGetPackages(storeId: bigint) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const live = await this.listLivePackages(storeId);
|
||||
return serializeBigInt({ live });
|
||||
}
|
||||
|
||||
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[]) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
await this.replaceLivePackages(storeId, packages);
|
||||
const live = await this.listLivePackages(storeId);
|
||||
return serializeBigInt({ live });
|
||||
}
|
||||
|
||||
private async replaceLivePackages(storeId: bigint, packages: StorePackageItemDto[]) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.storePackage.deleteMany({ where: { storeId } }),
|
||||
...packages.map((pkg, index) =>
|
||||
this.prisma.storePackage.create({
|
||||
data: {
|
||||
storeId,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
dishes: pkg.dishes,
|
||||
usableTime: pkg.usableTime ?? null,
|
||||
otherNotes: pkg.otherNotes ?? null,
|
||||
sortOrder: pkg.sortOrder ?? index,
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async adminGetAuditDetail(requestId: bigint) {
|
||||
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { store: { select: { id: true, name: true } } },
|
||||
});
|
||||
if (!req) throw new NotFoundException('审核记录不存在');
|
||||
const livePackages = await this.listLivePackages(req.storeId);
|
||||
return serializeBigInt({
|
||||
id: req.id.toString(),
|
||||
storeId: req.storeId.toString(),
|
||||
storeName: req.store.name,
|
||||
status: req.status,
|
||||
packages: req.packagesJson as unknown as StorePackageItemDto[],
|
||||
livePackages,
|
||||
submitterType: req.submitterType,
|
||||
submitterId: req.submitterId.toString(),
|
||||
rejectReason: req.rejectReason,
|
||||
reviewedAt: req.reviewedAt?.toISOString() ?? null,
|
||||
createdAt: req.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StorePackageChangeRequestWhereInput = {};
|
||||
if (query.status) {
|
||||
where.status = query.status as Prisma.EnumStorePackageChangeStatusFilter['equals'];
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePackageChangeRequest.findMany({
|
||||
where,
|
||||
include: { store: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.storePackageChangeRequest.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
storeId: r.storeId.toString(),
|
||||
storeName: r.store.name,
|
||||
status: r.status,
|
||||
packages: r.packagesJson as unknown as StorePackageItemDto[],
|
||||
submitterType: r.submitterType,
|
||||
submitterId: r.submitterId.toString(),
|
||||
rejectReason: r.rejectReason,
|
||||
reviewedAt: r.reviewedAt?.toISOString() ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async adminAuditRequest(
|
||||
requestId: bigint,
|
||||
reviewerId: bigint,
|
||||
body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string },
|
||||
) {
|
||||
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
if (!req) throw new NotFoundException('审核记录不存在');
|
||||
if (req.status !== 'PENDING') {
|
||||
throw new BadRequestException('该记录已处理');
|
||||
}
|
||||
if (body.action === 'REJECT') {
|
||||
const reason = String(body.rejectReason ?? '').trim();
|
||||
if (!reason) throw new BadRequestException('请填写驳回原因');
|
||||
const updated = await this.prisma.storePackageChangeRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectReason: reason,
|
||||
reviewedAt: new Date(),
|
||||
reviewerId,
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ id: updated.id.toString(), status: updated.status });
|
||||
}
|
||||
const packages = req.packagesJson as unknown as StorePackageItemDto[];
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.storePackage.deleteMany({ where: { storeId: req.storeId } });
|
||||
for (let i = 0; i < packages.length; i++) {
|
||||
const pkg = packages[i];
|
||||
await tx.storePackage.create({
|
||||
data: {
|
||||
storeId: req.storeId,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
dishes: pkg.dishes,
|
||||
usableTime: pkg.usableTime ?? null,
|
||||
otherNotes: pkg.otherNotes ?? null,
|
||||
sortOrder: pkg.sortOrder ?? i,
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.storePackageChangeRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
reviewedAt: new Date(),
|
||||
reviewerId,
|
||||
rejectReason: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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 { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
|
||||
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('stores')
|
||||
export class PublicStoreController {
|
||||
constructor(
|
||||
private readonly storeService: StoreService,
|
||||
private readonly redeemService: RedeemService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
async list(
|
||||
@CurrentUser() user: AuthUser | undefined,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
) {
|
||||
const userLat = lat != null && lat !== '' ? Number(lat) : undefined;
|
||||
const userLng = lng != null && lng !== '' ? Number(lng) : undefined;
|
||||
const viewerPhone = await this.resolveViewerPhone(user);
|
||||
return this.storeService.listOpenStores(cityCode, userLat, userLng, { phone: viewerPhone });
|
||||
}
|
||||
|
||||
@Get(':id/recent-redeems')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
async recentRedeems(
|
||||
@CurrentUser() user: AuthUser | undefined,
|
||||
@Param('id') id: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const viewerPhone = await this.resolveViewerPhone(user);
|
||||
// 与详情同权:白名单门店对不可见用户返回空(不泄露存在核销)
|
||||
try {
|
||||
await this.storeService.getStore(BigInt(id), { phone: viewerPhone });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const n = limit != null && limit !== '' ? Number(limit) : 20;
|
||||
return this.redeemService.listPublicStoreRecentRedeems(BigInt(id), Number.isFinite(n) ? n : 20);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
async detail(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {
|
||||
const viewerPhone = await this.resolveViewerPhone(user);
|
||||
return this.storeService.getStore(BigInt(id), { phone: viewerPhone });
|
||||
}
|
||||
|
||||
private async resolveViewerPhone(user?: AuthUser) {
|
||||
if (!user || user.actorType !== 'USER') return null;
|
||||
return this.storeService.resolveUserPhone(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('store-categories')
|
||||
export class PublicStoreCategoriesController {
|
||||
constructor(private readonly categories: StoreCategoryService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.categories.listTree({ includeDisabled: false, ensure: true });
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerListStores(user.actorId);
|
||||
}
|
||||
|
||||
@Get('cities')
|
||||
cities(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerListCities(user.actorId);
|
||||
}
|
||||
|
||||
@Get('phone-available')
|
||||
phoneAvailable(@Query('phone') phone?: string) {
|
||||
return this.storeService.partnerCheckStorePhone(phone ?? '');
|
||||
}
|
||||
|
||||
@Post('send-phone-sms')
|
||||
sendPhoneSms(@Body() body: { phone: string }) {
|
||||
return this.storeService.sendPartnerStorePhoneSms(body.phone);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.storeService.createStore(user.actorId, body);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreStatus(user.actorId, BigInt(id), body.status);
|
||||
}
|
||||
|
||||
@Put(':id/basic')
|
||||
updateBasic(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Put(':id/media')
|
||||
updateMedia(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreMedia(user.actorId, BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/dashboard')
|
||||
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
||||
export class PartnerDashboardController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
||||
dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerDashboard(user.actorId);
|
||||
}
|
||||
|
||||
/** 任意合伙人子账号可看同主账号排行(激励),不校验业务权限点 */
|
||||
@Get('leaderboard')
|
||||
leaderboard(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('period') period?: string,
|
||||
) {
|
||||
const normalized =
|
||||
period === 'month' || period === 'lastMonth' || period === 'total' ? period : 'total';
|
||||
return this.storeService.partnerLeaderboard(user.actorId, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/reports')
|
||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||
export class PartnerReportController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get('weekly')
|
||||
weekly(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('startDate') startDate?: string,
|
||||
) {
|
||||
return this.storeService.partnerWeeklyReport(user.actorId, startDate);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/store')
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
info(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.getShopStore(user.actorId, user.storeId!);
|
||||
}
|
||||
|
||||
@Put('status')
|
||||
status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) {
|
||||
return this.storeService.updateShopStatus(user.actorId, user.storeId!, body.status);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/dashboard')
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopDashboardController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Get()
|
||||
async dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.redeemService.getShopDashboard(user.actorId, user.storeId!);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import {
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PublicStoreCategoriesController,
|
||||
PublicStoreController,
|
||||
ShopDashboardController,
|
||||
ShopStoreController,
|
||||
} from './store.controller';
|
||||
import {
|
||||
AdminStorePackageAuditController,
|
||||
AdminStorePackageController,
|
||||
PartnerStorePackageController,
|
||||
PublicStorePackageController,
|
||||
ShopStorePackageController,
|
||||
} from './store-package.controller';
|
||||
import { StorePackageService } from './store-package.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
IamModule,
|
||||
AnalyticsModule,
|
||||
CityScopeModule,
|
||||
IntegrationsModule,
|
||||
forwardRef(() => RedeemModule),
|
||||
],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PublicStorePackageController,
|
||||
PublicStoreCategoriesController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PartnerStorePackageController,
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
ShopStoreController,
|
||||
ShopStorePackageController,
|
||||
ShopDashboardController,
|
||||
AdminStorePackageController,
|
||||
AdminStorePackageAuditController,
|
||||
],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user