feat(store): 门店套餐 v3.4.10 全端实现

新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 19:30:59 +08:00
parent 7e3dc131ad
commit 3c19b7dd97
35 changed files with 2128 additions and 28 deletions
+51
View File
@@ -92,6 +92,7 @@ enum TicketType {
ALERT
DAMAGE_RETURN
RETURN_REFUND
PACKAGE_DISPUTE
}
enum SupportTicketType {
@@ -256,6 +257,17 @@ enum StoreAuditStatus {
REJECTED
}
enum StorePackageChangeStatus {
PENDING
APPROVED
REJECTED
}
enum StorePackageSubmitterType {
PARTNER
SHOP
}
enum UserSourceType {
ORGANIC
PROMO_CODE
@@ -1093,6 +1105,8 @@ model Store {
storeBills StoreBill[]
withdrawRequests StoreWithdrawRequest[]
visibilityPhones StoreVisibilityPhone[]
packages StorePackage[]
packageChangeRequests StorePackageChangeRequest[]
@@index([cityId, status])
@@index([partnerAccountId])
@@ -1114,6 +1128,43 @@ model StoreVisibilityPhone {
@@map("store_visibility_phone")
}
model StorePackage {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
name String @db.VarChar(128)
price Decimal @db.Decimal(10, 2)
dishes String @db.Text
usableTime String? @map("usable_time") @db.VarChar(256)
otherNotes String? @map("other_notes") @db.VarChar(512)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@index([storeId, sortOrder])
@@map("store_package")
}
model StorePackageChangeRequest {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
status StorePackageChangeStatus @default(PENDING)
packagesJson Json @map("packages_json")
submitterType StorePackageSubmitterType @map("submitter_type")
submitterId BigInt @map("submitter_id") @db.UnsignedBigInt
rejectReason String? @map("reject_reason") @db.VarChar(512)
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@index([storeId, status])
@@index([status, createdAt])
@@map("store_package_change_request")
}
model StoreAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
@@ -143,7 +143,7 @@ export class CreateEventDto {
export class CreateTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
ticketType: string;
@IsString()
@@ -172,7 +172,7 @@ export class CreateTicketDto {
export class AdminCreateTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
ticketType: string;
@IsString()
@@ -0,0 +1,104 @@
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,
});
}
@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));
}
}
@@ -0,0 +1,315 @@
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 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' });
}
}
@@ -16,6 +16,14 @@ import {
ShopDashboardController,
ShopStoreController,
} from './store.controller';
import {
AdminStorePackageAuditController,
AdminStorePackageController,
PartnerStorePackageController,
PublicStorePackageController,
ShopStorePackageController,
} from './store-package.controller';
import { StorePackageService } from './store-package.service';
@Module({
imports: [
@@ -27,15 +35,20 @@ import {
],
controllers: [
PublicStoreController,
PublicStorePackageController,
PublicStoreCategoriesController,
PartnerStoreCategoriesController,
PartnerStoreController,
PartnerStorePackageController,
PartnerDashboardController,
PartnerReportController,
ShopStoreController,
ShopStorePackageController,
ShopDashboardController,
AdminStorePackageController,
AdminStorePackageAuditController,
],
providers: [StoreService, StoreCategoryService],
exports: [StoreService, StoreCategoryService],
providers: [StoreService, StoreCategoryService, StorePackageService],
exports: [StoreService, StoreCategoryService, StorePackageService],
})
export class StoreModule {}
@@ -208,6 +208,10 @@ export class StoreService {
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
orderBy: { sortOrder: 'asc' },
});
const packageRows = await this.prisma.storePackage.findMany({
where: { storeId: id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
@@ -215,6 +219,14 @@ export class StoreService {
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,
packages: packageRows.map((p) => ({
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
sortOrder: p.sortOrder,
})),
}),
);
}
@@ -9,7 +9,7 @@ import {
export class CreateAfterSaleTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
ticketType: string;
@IsOptional()
@@ -21,6 +21,21 @@ export class CreateAfterSaleTicketDto {
evidenceUrls?: string[];
}
export class CreatePackageDisputeDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsOptional()
@IsString()
@MaxLength(512)
remark?: string;
@IsOptional()
@IsString()
redeemRecordId?: string;
}
export class CreateInvoiceDto {
@IsString()
@IsIn(['PERSONAL', 'ENTERPRISE'])
@@ -12,7 +12,7 @@ import {
PartnerProxyOrderPreviewDto,
} from './dto/partner-proxy-order.dto';
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto, CreatePackageDisputeDto } from './dto/after-sale.dto';
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
@@ -103,6 +103,17 @@ export class TradeController {
}
}
@Controller('trade/package-disputes')
@UseGuards(JwtAuthGuard)
export class TradePackageDisputeController {
constructor(private readonly tradeService: TradeService) {}
@Post()
create(@CurrentUser() user: AuthUser, @Body() body: CreatePackageDisputeDto) {
return this.tradeService.createPackageDispute(user.actorId, body);
}
}
@Controller('trade/after-sale-tickets')
@UseGuards(JwtAuthGuard)
export class TradeAfterSaleTicketController {
@@ -15,6 +15,7 @@ import {
PartnerReshipmentController,
TradeAfterSaleTicketController,
TradeInvoiceController,
TradePackageDisputeController,
} from './trade.controller';
import { TradeService } from './trade.service';
@@ -32,6 +33,7 @@ import { TradeService } from './trade.service';
],
controllers: [
TradeController,
TradePackageDisputeController,
TradeAfterSaleTicketController,
TradeInvoiceController,
PartnerOrderController,
@@ -730,6 +730,40 @@ export class TradeService {
return serializeBigInt({ ...ticket, orderNo: order.orderNo, orderStatus: order.status });
}
async createPackageDispute(
userId: bigint,
body: { storeId: string; remark?: string; redeemRecordId?: string },
) {
const storeId = BigInt(body.storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, status: 'OPEN' },
});
if (!store) throw new NotFoundException('门店不存在');
const pending = await this.prisma.commonTicket.findFirst({
where: {
ticketType: 'PACKAGE_DISPUTE',
refType: 'STORE',
refId: storeId,
status: { in: ['PENDING', 'OPEN'] },
},
});
if (pending) throw new BadRequestException('该门店套餐异议已在处理中');
const extraJson: Record<string, unknown> = { userId: userId.toString() };
if (body.redeemRecordId) {
extraJson.redeemRecordId = body.redeemRecordId;
}
return this.ticketService.create({
ticketType: 'PACKAGE_DISPUTE',
refType: 'STORE',
refId: storeId.toString(),
remark: body.remark ?? '用户对门店套餐有异议',
extraJson,
});
}
private generateInvoiceNo() {
return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}