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 { mapStoreCompat } from '../../common/compat/v31-compat'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import { PartnerCityService } from '../city-scope/partner-city.service'; import type { CreateStoreAccountDto, CreateStoreDto, CreateStoreMediaDto, UpdateStoreAccountDto, UpdateStoreDto, UpdateStoreMediaDto, UpdateStoreStatusDto, } from './dto/admin-mutate.dto'; @Injectable() export class AdminStoresService { constructor( private readonly prisma: PrismaService, private readonly partnerCityService: PartnerCityService, ) {} async listStores(query: AdminStoresQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.StoreWhereInput = {}; if (query.name) where.name = { contains: query.name }; if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals']; if (query.cityId) where.cityId = BigInt(query.cityId); if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.phone) where.phone = { contains: query.phone }; const [items, total] = await Promise.all([ this.prisma.store.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { cityRef: { select: { id: true, name: true, code: true } }, partnerAccount: { select: { id: true, companyName: true } }, account: { select: { id: true, phone: true, name: true, status: true } }, coverResource: { select: { id: true, url: true } }, }, }), this.prisma.store.count({ where }), ]); return serializeBigInt({ items: items.map((s) => mapStoreCompat(s)), total, page, pageSize, }); } async detailStore(id: bigint) { const store = await this.prisma.store.findUnique({ where: { id }, include: { cityRef: true, partnerAccount: true, category: true, account: true, coverResource: true, _count: { select: { redeemRecords: true, ratings: true } }, }, }); if (!store) throw new NotFoundException('门店不存在'); const [media, audits] = await Promise.all([ this.prisma.commonResource.findMany({ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' }, orderBy: { sortOrder: 'asc' }, }), this.prisma.commonEvent.findMany({ where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id }, orderBy: { createdAt: 'desc' }, take: 5, }), ]); return serializeBigInt(mapStoreCompat({ ...store, media, audits, redeemCount: store._count.redeemRecords, ratingCount: store._count.ratings, _count: undefined, })); } async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) { const store = await this.prisma.store.update({ where: { id }, data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' }, }); return serializeBigInt(store); } async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) { const store = await this.prisma.store.findUnique({ where: { id } }); if (!store) throw new NotFoundException('门店不存在'); const status = dto.approved ? 'OPEN' : 'PAUSED'; const updated = await this.prisma.store.update({ where: { id }, data: { status }, }); await this.prisma.commonEvent.create({ data: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id, actorType: 'HQ', status: dto.approved ? 'APPROVED' : 'REJECTED', remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'), }, }); return serializeBigInt(updated); } async updateStore(id: bigint, dto: UpdateStoreDto) { const store = await this.prisma.store.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.phone !== undefined ? { phone: dto.phone } : {}), ...(dto.intro !== undefined ? { intro: dto.intro } : {}), ...(dto.address !== undefined ? { address: dto.address } : {}), ...(dto.district !== undefined ? { district: dto.district } : {}), ...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}), }, }); if (dto.coverUrl) { const current = await this.prisma.store.findUniqueOrThrow({ where: { id } }); if (current.coverResourceId) { await this.prisma.commonResource.update({ where: { id: current.coverResourceId }, data: { url: dto.coverUrl, ossKey: dto.coverUrl }, }); } else { const cover = await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', mediaType: 'IMAGE', ossBucket: 'legacy', ossKey: dto.coverUrl, url: dto.coverUrl, }, }); await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } }); } } return this.detailStore(id); } async createStore(dto: CreateStoreDto) { const normalizedPhone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) { throw new BadRequestException('请输入正确的手机号码'); } const existingAccount = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, }); if (existingAccount) throw new BadRequestException('该手机号已绑定门店'); const partnerAccountId = BigInt(dto.partnerAccountId); const partnerAccount = await this.prisma.partnerAccount.findUnique({ where: { id: partnerAccountId }, }); if (!partnerAccount || partnerAccount.isPrimary !== 1) { throw new BadRequestException('开城合伙人不存在'); } const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } }); if (!city) throw new BadRequestException('开城城市不存在'); await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id); const store = await this.prisma.store.create({ data: { cityId: city.id, partnerAccountId, settlementRate: dto.settlementRate ?? 0.6, categoryId: dto.categoryId ? BigInt(dto.categoryId) : null, name: dto.name, phone: normalizedPhone, province: dto.province ?? city.province, cityName: dto.city ?? city.name, district: dto.district ?? '', address: dto.address, intro: dto.intro ?? null, bankAccountName: dto.bankAccountName ?? null, bankAccountNo: dto.bankAccountNo ?? null, bankBranch: dto.bankBranch ?? null, openTime: '10:00', closeTime: '22:00', status: 'OPEN', }, }); if (dto.coverUrl) { const cover = await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'COVER', mediaType: 'IMAGE', ossBucket: 'legacy', ossKey: dto.coverUrl, url: dto.coverUrl, }, }); await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); } const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean); for (let i = 0; i < envUrls.length; i++) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'ENV', mediaType: 'IMAGE', ossBucket: 'legacy', ossKey: envUrls[i], url: envUrls[i], sortOrder: i, }, }); } if (dto.contractUrl) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'CONTRACT', mediaType: 'FILE', ossBucket: 'legacy', ossKey: dto.contractUrl, url: dto.contractUrl, }, }); } await this.prisma.commonEvent.create({ data: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: store.id, actorType: 'HQ', status: 'APPROVED', param1: 'NEW', param1Desc: 'audit_type', remark: 'HQ 后台新建', }, }); await this.prisma.storeAccount.create({ data: { storeId: store.id, phone: normalizedPhone, name: dto.accountName ?? dto.name, }, }); return this.detailStore(store.id); } async createStoreAccount(dto: CreateStoreAccountDto) { const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) }, include: { account: true }, }); if (!store) throw new BadRequestException('门店不存在'); if (store.account) throw new BadRequestException('门店已有账户'); const account = await this.prisma.storeAccount.create({ data: { storeId: store.id, phone: dto.phone, name: dto.name }, }); return serializeBigInt(account); } async listStoreMedia(query: AdminStoreMediaQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.CommonResourceWhereInput = { ownerType: 'STORE', status: 'ACTIVE', }; if (query.storeId) where.ownerId = BigInt(query.storeId); if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals']; const [items, total] = await Promise.all([ this.prisma.commonResource.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.commonResource.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async createStoreMedia(dto: CreateStoreMediaDto) { const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } }); if (!store) throw new BadRequestException('门店不存在'); const media = await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'ENV', mediaType: dto.mediaType as 'IMAGE' | 'VIDEO', ossBucket: 'legacy', ossKey: dto.url, url: dto.url, sortOrder: dto.sortOrder ?? 0, }, }); return serializeBigInt(media); } async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) { const media = await this.prisma.commonResource.update({ where: { id }, data: { ...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}), ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), }, }); return serializeBigInt(media); } async deleteStoreMedia(id: bigint) { await this.prisma.commonResource.update({ where: { id }, data: { status: 'DELETED' }, }); return { ok: true }; } async listStoreAccounts(query: AdminStoreAccountsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.StoreAccountWhereInput = {}; if (query.phone) where.phone = { contains: query.phone }; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; const [items, total] = await Promise.all([ this.prisma.storeAccount.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { store: { select: { id: true, name: true, status: true, cityName: true } }, }, }), this.prisma.storeAccount.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async detailStoreAccount(id: bigint) { const account = await this.prisma.storeAccount.findUnique({ where: { id }, include: { store: { include: { cityRef: true, partnerAccount: true } } }, }); if (!account) throw new NotFoundException('门店账号不存在'); return serializeBigInt(account); } async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) { const account = await this.prisma.storeAccount.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.phone !== undefined ? { phone: dto.phone } : {}), ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), }, }); return serializeBigInt(account); } }