import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls, 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'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; type PackageInput = Record; @Injectable() export class StorePackageService { private readonly logger = new Logger(StorePackageService.name); constructor( private readonly prisma: PrismaService, private readonly storeService: StoreService, private readonly wecomPush: WecomMessagePushService, ) {} 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 imageUrls = normalizeStorePackageImageUrls({ imageUrl: item.imageUrl as string | null | undefined, imageUrls: item.imageUrls, }); if ( Array.isArray(item.imageUrls) && item.imageUrls.filter((u) => String(u ?? '').trim()).length > STORE_PACKAGE_IMAGE_MAX_COUNT ) { throw new BadRequestException( `第 ${index + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`, ); } const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index; return { name, price: priceNum.toFixed(2), dishes, usableTime, otherNotes, imageUrl: imageUrls[0] ?? null, imageUrls, sortOrder: Number.isFinite(sortOrder) ? sortOrder : index, }; } private mapLiveRow(row: { id: bigint; name: string; price: Prisma.Decimal; dishes: string; usableTime: string | null; otherNotes: string | null; imageUrl: string | null; imageUrls: Prisma.JsonValue | null; sortOrder: number; }) { const imageUrls = normalizeStorePackageImageUrls({ imageUrl: row.imageUrl, imageUrls: row.imageUrls, }); return { id: row.id.toString(), name: row.name, price: row.price.toFixed(2), dishes: row.dishes, usableTime: row.usableTime, otherNotes: row.otherNotes, imageUrl: imageUrls[0] ?? null, imageUrls, 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, }, }); const store = await this.prisma.store.findUnique({ where: { id: storeId }, select: { name: true, cityName: true }, }); const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店'; void this.wecomPush.dispatchEvent( 'store.package_audit_pending', { storeName: store?.name ?? String(storeId), cityName: store?.cityName || '—', submitter: submitterLabel, packageCount: String(packages.length), requestId: req.id.toString(), }, { handlePath: `/store-package-audits?requestId=${req.id.toString()}` }, ); 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 packageImageWrite(pkg: { imageUrl?: string | null; imageUrls?: string[] | null; }) { const imageUrls = normalizeStorePackageImageUrls(pkg); return { imageUrl: imageUrls[0] ?? null, imageUrls: imageUrls.length ? (imageUrls as Prisma.InputJsonValue) : Prisma.JsonNull, }; } 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, ...this.packageImageWrite(pkg), 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 = this.normalizePackages(req.packagesJson); 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, ...this.packageImageWrite(pkg), 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' }); } async adminAuditSummary() { const pendingCount = await this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' }, }); return { pendingCount }; } }