Files
dukang/server/dukang-api/src/modules/store/store-package.service.ts
T
jacy 26334ed072
CI / verify (pull_request) Waiting to run
v3.5.3版本更新2
2026-08-20 20:09:05 +08:00

410 lines
14 KiB
TypeScript

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<string, unknown>;
@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 };
}
}