feat(store): 门店套餐 v3.4.10 全端实现
新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user