import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { mapStoreCompat } from '../../common/compat/v31-compat'; import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { StoreCategoryService } from '../store/store-category.service'; import { AnalyticsService } from '../analytics/analytics.service'; import type { CreateStoreAccountDto, CreateStoreDto, CreateStoreMediaDto, UpdateStoreAccountDto, UpdateStoreDto, UpdateStoreMediaDto, UpdateStoreStatusDto, } from './dto/admin-mutate.dto'; import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */ function normalizeStoreOptionalText(value: unknown): string | null { if (value == null) return null; const s = String(value).trim(); if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null; return s; } function normalizeVisibilityPhones(phones?: string[]): string[] { if (!phones?.length) return []; const out: string[] = []; const seen = new Set(); for (const raw of phones) { const phone = String(raw || '') .replace(/\D/g, '') .trim(); if (!phone || seen.has(phone)) continue; if (!/^1\d{10}$/.test(phone)) { throw new BadRequestException(`手机号格式无效:${raw}`); } seen.add(phone); out.push(phone); } return out; } @Injectable() export class AdminStoresService { constructor( private readonly prisma: PrismaService, private readonly partnerCityService: PartnerCityService, private readonly storeCategoryService: StoreCategoryService, private readonly analyticsService: AnalyticsService, private readonly testWhitelist: TestWhitelistService, ) {} 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.auditStatus) { where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['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 }; if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.store.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, include: { cityRef: { select: { id: true, name: true, code: true } }, partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } }, category: { select: { id: true, name: true, parentId: true } }, bindings: { where: { storeAccount: { isPrimary: 1 } }, take: 1, include: { storeAccount: { select: { id: true, phone: true, name: true, status: true } }, }, }, coverResource: { select: { id: true, url: true } }, visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } }, }, }), this.prisma.store.count({ where }), ]); // 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用 const pendingByStore = new Map(); // 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用 const pendingInfoByStore = new Map(); if (items.length) { const storeIds = items.map((s) => s.id); const [pendingReqs, pendingInfoReqs] = await Promise.all([ this.prisma.storePackageChangeRequest.findMany({ where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id: true, storeId: true }, }), this.prisma.storeInfoChangeRequest.findMany({ where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id: true, storeId: true }, }), ]); for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString()); for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString()); } return serializeBigInt({ items: items.map((s) => { const { visibilityPhones, ...rest } = s; return mapStoreCompat({ ...rest, visibilityWhitelistEnabled: s.visibilityWhitelistEnabled, visibilityPhones: visibilityPhones.map((p) => p.phone), // 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃 pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null, pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null, partner: s.partnerAccount, account: s.bindings[0]?.storeAccount ?? null, bindings: undefined, }); }), total, page, pageSize, }); } async detailStore(id: bigint) { const store = await this.prisma.store.findUnique({ where: { id }, include: { cityRef: true, partnerAccount: true, category: true, bindings: { where: { storeAccount: { isPrimary: 1 } }, take: 1, include: { storeAccount: true }, }, coverResource: true, visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } }, _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, }), ]); const { visibilityPhones, ...rest } = store; return serializeBigInt(mapStoreCompat({ ...rest, visibilityWhitelistEnabled: store.visibilityWhitelistEnabled, visibilityPhones: visibilityPhones.map((p) => p.phone), partner: store.partnerAccount, account: store.bindings[0]?.storeAccount ?? null, /** 门店端登录手机号(store_account.phone);与 store.phone 对齐 */ loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone, /** 对外联系电话;空则回退登录号 */ contactPhone: store.contactPhone?.trim() || store.phone, bindings: undefined, 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('门店不存在'); if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') { // 允许对已通过门店再次驳回/通过(总部纠错);PENDING/REJECTED/APPROVED 均可审核 } if (!dto.approved) { const reason = dto.remark?.trim(); if (!reason) throw new BadRequestException('驳回时必须填写原因'); } const now = new Date(); const updated = await this.prisma.store.update({ where: { id }, data: dto.approved ? { // 审核通过后保持闭店,由合伙人自行开门 status: store.status === 'CLOSED' ? 'CLOSED' : 'PAUSED', auditStatus: 'APPROVED', rejectReason: null, auditedAt: now, } : { status: 'PAUSED', auditStatus: 'REJECTED', rejectReason: dto.remark!.trim(), auditedAt: now, }, }); await this.prisma.commonEvent.create({ data: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id, actorType: 'HQ', status: dto.approved ? 'APPROVED' : 'REJECTED', remark: dto.approved ? (dto.remark?.trim() || '审核通过,可开门营业') : dto.remark!.trim(), param1: dto.approved ? 'APPROVE' : 'REJECT', param1Desc: 'audit_action', }, }); if (updated.partnerAccountId) { this.analyticsService.trackPartnerOneSafe(undefined, 'HQ_WEB', { partnerAccountId: updated.partnerAccountId, eventName: dto.approved ? 'partner_store_audit_approved' : 'partner_store_audit_rejected', refType: 'STORE', refId: id, extraJson: { storeId: id.toString(), remark: dto.remark?.trim() || null, }, }); } return serializeBigInt({ ...updated, notifyHint: dto.approved ? '已通过审核,合伙人可在端内开门营业' : '已驳回,驳回原因已同步至合伙人端', }); } async updateStore(id: bigint, dto: UpdateStoreDto) { const current = await this.prisma.store.findUnique({ where: { id } }); if (!current) throw new NotFoundException('门店不存在'); const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || ''; const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || ''; const openTime2 = dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || ''; const closeTime2 = dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || ''; if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) { const hoursCheck = validateBusinessHours([ { open: openTime || '10:00', close: closeTime || '22:00' }, ...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []), ]); if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message); } const latitude = dto.latitude !== undefined ? (dto.latitude == null ? null : Number(dto.latitude)) : undefined; const longitude = dto.longitude !== undefined ? (dto.longitude == null ? null : Number(dto.longitude)) : undefined; if (latitude !== undefined || longitude !== undefined) { if (latitude == null || longitude == null) { throw new BadRequestException('经纬度须同时提供'); } if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) { throw new BadRequestException('纬度无效'); } if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) { throw new BadRequestException('经度无效'); } } let categoryId: bigint | undefined; if (dto.categoryId !== undefined) { if (!dto.categoryId?.trim()) { throw new BadRequestException('请选择门店分类'); } categoryId = BigInt(dto.categoryId); await this.storeCategoryService.assertLeafCategoryId(categoryId); } if (dto.phone !== undefined) { const normalizedPhone = dto.phone.trim(); if (!isMobilePhone(normalizedPhone)) { throw new BadRequestException('请输入正确的登录手机号'); } } if (dto.contactPhone !== undefined) { const contact = dto.contactPhone.trim(); if (contact && !isStoreContactPhone(contact)) { throw new BadRequestException(STORE_CONTACT_PHONE_HINT); } } const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined; const normalizedContactPhone = dto.contactPhone !== undefined ? dto.contactPhone.trim() || null : undefined; if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) { const nextEnabled = dto.visibilityWhitelistEnabled !== undefined ? !!dto.visibilityWhitelistEnabled : current.visibilityWhitelistEnabled; if (nextEnabled) { await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } } const bankTouched = dto.bankAccountName !== undefined || dto.bankAccountNo !== undefined || dto.bankBranch !== undefined; const needAccountSync = normalizedPhone !== undefined || dto.name !== undefined || bankTouched; // 登录凭证在 store_account.phone;与 store.phone(老板登录号)同步,不与对外联系电话绑定 let primaryBinding: { storeAccount: { id: bigint; phone: string; name: string } | null; } | null = null; if (needAccountSync) { primaryBinding = await this.prisma.storeAccountStore.findFirst({ where: { storeId: id, storeAccount: { isPrimary: 1 } }, include: { storeAccount: { select: { id: true, phone: true, name: true } }, }, }); if (!primaryBinding) { primaryBinding = await this.prisma.storeAccountStore.findFirst({ where: { storeId: id }, include: { storeAccount: { select: { id: true, phone: true, name: true } }, }, orderBy: { storeAccountId: 'asc' }, }); } if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) { throw new BadRequestException('门店未绑定登录账号,无法修改手机号'); } const primaryAccount = primaryBinding?.storeAccount; if ( normalizedPhone !== undefined && primaryAccount && normalizedPhone !== primaryAccount.phone ) { const occupied = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, }); if (occupied && occupied.id !== primaryAccount.id) { throw new BadRequestException('该手机号已被其他门店账号使用'); } } if (dto.bankAccountNo !== undefined) { const no = dto.bankAccountNo?.trim() || null; if (no && !/^\d{16,19}$/.test(no)) { throw new BadRequestException('银行卡号须为 16–19 位数字'); } } } await this.prisma.$transaction(async (tx) => { await tx.store.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), ...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}), ...(normalizedContactPhone !== undefined ? { contactPhone: normalizedContactPhone } : {}), ...(dto.intro !== undefined ? { intro: dto.intro } : {}), ...(dto.benefitUsageRule !== undefined ? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) } : {}), ...(dto.address !== undefined ? { address: dto.address.trim() } : {}), ...(dto.province !== undefined ? { province: dto.province.trim() } : {}), ...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}), ...(dto.district !== undefined ? { district: dto.district.trim() } : {}), ...(categoryId !== undefined ? { categoryId } : {}), ...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}), ...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}), ...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}), ...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}), ...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}), ...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}), ...(dto.visibilityWhitelistEnabled !== undefined ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } : {}), ...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: Math.floor(Number(dto.sortOrder)) || 0 } : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}), }, }); if (dto.visibilityPhones !== undefined) { // 分实体手机号已废弃,忽略写入 } if (dto.coverUrl !== undefined) { const coverUrl = dto.coverUrl?.trim() || ''; if (!coverUrl) { if (current.coverResourceId) { await tx.commonResource.update({ where: { id: current.coverResourceId }, data: { status: 'DELETED' }, }); await tx.store.update({ where: { id }, data: { coverResourceId: null } }); } await tx.commonResource.updateMany({ where: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', status: 'ACTIVE' }, data: { status: 'DELETED' }, }); } else if (current.coverResourceId) { await tx.commonResource.update({ where: { id: current.coverResourceId }, data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' }, }); } else { const cover = await tx.commonResource.create({ data: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', mediaType: 'IMAGE', ossBucket: 'legacy', ossKey: coverUrl, url: coverUrl, }, }); await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } }); } } if (dto.envPhotoUrls !== undefined) { const envUrls = [...new Set( (dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean), )].slice(0, 20); await tx.commonResource.updateMany({ where: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', status: 'ACTIVE' }, data: { status: 'DELETED' }, }); for (let i = 0; i < envUrls.length; i++) { await tx.commonResource.create({ data: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', mediaType: 'IMAGE', ossBucket: 'legacy', ossKey: envUrls[i], url: envUrls[i], sortOrder: i, }, }); } } if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) { const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl); await tx.commonResource.updateMany({ where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' }, data: { status: 'DELETED' }, }); for (let i = 0; i < contractUrls.length; i++) { await tx.commonResource.create({ data: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', mediaType: contractMediaType(contractUrls[i]), ossBucket: 'legacy', ossKey: contractUrls[i], url: contractUrls[i], sortOrder: i, }, }); } } if (primaryBinding?.storeAccount) { const account = primaryBinding.storeAccount; const accountData: { name?: string; phone?: string; bankAccountName?: string | null; bankAccountNo?: string | null; bankBranch?: string | null; } = {}; if (dto.name !== undefined) accountData.name = dto.name.trim(); if (normalizedPhone !== undefined && normalizedPhone !== account.phone) { accountData.phone = normalizedPhone; } if (dto.bankAccountName !== undefined) { accountData.bankAccountName = dto.bankAccountName?.trim() || null; } if (dto.bankAccountNo !== undefined) { accountData.bankAccountNo = dto.bankAccountNo?.trim() || null; } if (dto.bankBranch !== undefined) { accountData.bankBranch = dto.bankBranch?.trim() || null; } if (Object.keys(accountData).length) { await tx.storeAccount.update({ where: { id: account.id }, data: accountData, }); } } }); return this.detailStore(id); } async createStore(dto: CreateStoreDto) { const normalizedPhone = dto.phone.trim(); if (!isMobilePhone(normalizedPhone)) { throw new BadRequestException('请输入正确的手机号码'); } const existingAccount = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, }); if (existingAccount && existingAccount.isPrimary !== 1) { throw new BadRequestException('该手机号已是门店子账号'); } if (existingAccount && existingAccount.status !== 'ACTIVE') { 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); if (!dto.categoryId?.trim()) { throw new BadRequestException('请选择门店分类'); } const categoryId = BigInt(dto.categoryId); await this.storeCategoryService.assertLeafCategoryId(categoryId); const latitude = dto.latitude != null ? Number(dto.latitude) : null; const longitude = dto.longitude != null ? Number(dto.longitude) : null; if ((latitude == null) !== (longitude == null)) { throw new BadRequestException('经纬度须同时提供'); } if ( latitude != null && (!Number.isFinite(latitude) || !Number.isFinite(longitude!) || latitude < -90 || latitude > 90) ) { throw new BadRequestException('经纬度无效'); } const openTime = dto.openTime?.trim() || '10:00'; const closeTime = dto.closeTime?.trim() || '22:00'; const openTime2 = dto.openTime2?.trim() || ''; const closeTime2 = dto.closeTime2?.trim() || ''; const hoursCheck = validateBusinessHours([ { open: openTime, close: closeTime }, ...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []), ]); if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message); const intro = dto.intro?.trim() || null; if (intro && (intro.length < 2 || intro.length > 500)) { throw new BadRequestException('门店简介须为 2~500 字'); } const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule); if (benefitUsageRule && benefitUsageRule.length > 1000) { throw new BadRequestException('好客权益券使用规则最多 1000 字'); } const whitelistEnabled = !!dto.visibilityWhitelistEnabled; if (whitelistEnabled) { await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } const isTest = dto.isTest !== undefined ? !!dto.isTest : await this.testWhitelist.isPhoneInWhitelist(normalizedPhone); const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone; if (!isStoreContactPhone(contactPhoneRaw)) { throw new BadRequestException(STORE_CONTACT_PHONE_HINT); } const store = await this.prisma.store.create({ data: { cityId: city.id, partnerAccountId, settlementRate: dto.settlementRate ?? 0.6, categoryId, name: dto.name, phone: normalizedPhone, contactPhone: contactPhoneRaw, province: dto.province ?? city.province, cityName: dto.city ?? city.name, district: dto.district ?? '', address: dto.address, intro, benefitUsageRule, avgPrice: dto.avgPrice ?? null, openTime, closeTime, openTime2: openTime2 || null, closeTime2: closeTime2 || null, visibilityWhitelistEnabled: whitelistEnabled, isTest, sortOrder: dto.sortOrder != null ? Math.floor(Number(dto.sortOrder)) || 0 : 0, ...(latitude != null && longitude != null ? { latitude, longitude } : {}), status: 'OPEN', auditStatus: 'APPROVED', auditedAt: new Date(), rejectReason: null, }, }); 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 = [...new Set((dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean))].slice(0, 20); 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, }, }); } const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl); for (let i = 0; i < contractUrls.length; i++) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'CONTRACT', mediaType: contractMediaType(contractUrls[i]), ossBucket: 'legacy', ossKey: contractUrls[i], url: contractUrls[i], sortOrder: i, }, }); } 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 后台新建', }, }); const bankAccountName = dto.bankAccountName ?? null; const bankAccountNo = dto.bankAccountNo ?? null; const bankBranch = dto.bankBranch ?? null; if (existingAccount) { await this.prisma.storeAccountStore.create({ data: { storeAccountId: existingAccount.id, storeId: store.id }, }); if (bankAccountName || bankAccountNo || bankBranch) { await this.prisma.storeAccount.update({ where: { id: existingAccount.id }, data: { ...(bankAccountName != null ? { bankAccountName } : {}), ...(bankAccountNo != null ? { bankAccountNo } : {}), ...(bankBranch != null ? { bankBranch } : {}), }, }); } } else { await this.prisma.storeAccount.create({ data: { phone: normalizedPhone, name: dto.accountName ?? dto.name, isPrimary: 1, bankAccountName, bankAccountNo, bankBranch, bindings: { create: [{ storeId: store.id }] }, }, }); } return this.detailStore(store.id); } async createStoreAccount(dto: CreateStoreAccountDto) { const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) }, include: { bindings: true }, }); if (!store) throw new BadRequestException('门店不存在'); const primaryBound = store.bindings.length > 0 ? await this.prisma.storeAccount.findFirst({ where: { isPrimary: 1, bindings: { some: { storeId: store.id } }, }, }) : null; if (primaryBound) throw new BadRequestException('门店已有主账号绑定'); const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } }); if (existing) { if (existing.isPrimary !== 1) { throw new BadRequestException('该手机号已是门店子账号'); } await this.prisma.storeAccountStore.create({ data: { storeAccountId: existing.id, storeId: store.id }, }); return serializeBigInt(existing); } const account = await this.prisma.storeAccount.create({ data: { phone: dto.phone, name: dto.name, isPrimary: 1, bindings: { create: [{ storeId: store.id }] }, }, }); 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 = { isPrimary: 1 }; if (query.phone) where.phone = { contains: query.phone }; if (query.storeId) { where.bindings = { some: { storeId: BigInt(query.storeId) } }; } if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.storeAccount.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { bindings: { include: { store: { select: { id: true, name: true, status: true, cityName: true } }, }, }, _count: { select: { childAccounts: true, bindings: true } }, }, }), this.prisma.storeAccount.count({ where }), ]); const mapped = items.map((row) => ({ ...row, storeCount: row._count.bindings, staffCount: row._count.childAccounts, stores: row.bindings.map((b) => b.store), store: row.bindings[0]?.store ?? null, })); return serializeBigInt({ items: mapped, total, page, pageSize }); } async detailStoreAccount(id: bigint) { const account = await this.prisma.storeAccount.findUnique({ where: { id }, include: { bindings: { include: { store: { include: { cityRef: true, partnerAccount: true } }, }, }, childAccounts: { include: { bindings: { include: { store: { select: { id: true, name: true, status: true } }, }, }, }, }, }, }); if (!account) throw new NotFoundException('门店账号不存在'); return serializeBigInt({ ...account, stores: account.bindings.map((b) => b.store), store: account.bindings[0]?.store ?? null, staff: account.childAccounts, }); } 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); } /** HQ 删除门店子账号(非主账号) */ async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) { const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } }); if (!parent || parent.isPrimary !== 1) { throw new BadRequestException('主账号不存在'); } const staff = await this.prisma.storeAccount.findFirst({ where: { id: staffId, parentAccountId, isPrimary: 0 }, }); if (!staff) throw new NotFoundException('子账号不存在'); const pending = await this.prisma.redeemPendingRecord.count({ where: { storeAccountId: staffId }, }); if (pending > 0) { throw new BadRequestException('该子账号仍有待处理核销单,无法删除'); } await this.prisma.storeAccount.delete({ where: { id: staffId } }); return { ok: true }; } }