import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types'; import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types'; import { 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 { parseBigIntParam } from '../../common/parse-bigint'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { AuthService } from '../iam/auth.service'; import { StoreCategoryService } from './store-category.service'; import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider'; function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; const R = 6371000; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))); } /** 选填文案:空 / null / "null" 存库为 null,避免 String(null)==="null" */ function normalizeOptionalTextField(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; } export type StoreViewer = { /** C 端用户手机号;无则无法看到白名单门店 */ phone?: string | null; /** 运营/代下单等场景跳过白名单 */ bypassWhitelist?: boolean; }; function normalizePhone(phone: string | null | undefined): string { return (phone || '').replace(/\D/g, '').trim(); } function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null { if (value == null || value === '') return null; const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) return null; if (kind === 'lat' && (n < -90 || n > 90)) return null; if (kind === 'lng' && (n < -180 || n > 180)) return null; return n; } @Injectable() export class StoreService { private readonly config = loadAppConfig(); constructor( private readonly prisma: PrismaService, private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, private readonly authService: AuthService, private readonly storeCategoryService: StoreCategoryService, private readonly tencentLbs: TencentLbsProvider, ) {} private storeAddressText(store: { province?: string | null; cityName?: string | null; district?: string | null; address?: string | null; }) { return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim(); } /** 缺坐标时用地址正向地理编码并回写 */ private async ensureStoreCoordinates(store: { id: bigint; latitude?: unknown; longitude?: unknown; province?: string | null; cityName?: string | null; district?: string | null; address?: string | null; }): Promise<{ latitude: number; longitude: number } | null> { const lat = store.latitude != null ? Number(store.latitude) : NaN; const lng = store.longitude != null ? Number(store.longitude) : NaN; if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) { return { latitude: lat, longitude: lng }; } const address = this.storeAddressText(store); if (!address) return null; const geo = await this.tencentLbs.geocodeAddress(address, { refType: 'STORE', refId: store.id, }); if (!geo) return null; await this.prisma.store.update({ where: { id: store.id }, data: { latitude: geo.latitude, longitude: geo.longitude }, }); return { latitude: geo.latitude, longitude: geo.longitude }; } async resolveUserPhone(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { phone: true }, }); return user?.phone ?? null; } isVisibleToViewer( store: { visibilityWhitelistEnabled: boolean; visibilityPhones: Array<{ phone: string }>; }, viewer?: StoreViewer, ): boolean { if (viewer?.bypassWhitelist) return true; if (!store.visibilityWhitelistEnabled) return true; const phone = normalizePhone(viewer?.phone); if (!phone) return false; return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone); } async listOpenStores( cityCode?: string, userLat?: number, userLng?: number, viewer?: StoreViewer, ) { const where: Record = { status: 'OPEN' }; if (cityCode) { const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } }); if (city) where.cityId = city.id; } const stores = await this.prisma.store.findMany({ where: where as never, include: { category: true, coverResource: true, visibilityPhones: { select: { phone: true } }, }, orderBy: { createdAt: 'desc' }, }); const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer)); const hasUser = userLat != null && userLng != null && Number.isFinite(userLat) && Number.isFinite(userLng); type StoreListItem = ReturnType & { distanceMeters: number | null; latitude?: unknown; longitude?: unknown; }; const items: StoreListItem[] = []; for (const store of visible) { const coords = await this.ensureStoreCoordinates(store); const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; const mapped = mapStoreCompat({ ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, }); const distanceMeters = hasUser && coords ? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude)) : null; items.push({ ...mapped, distanceMeters }); } if (hasUser) { items.sort((a, b) => { const da = a.distanceMeters ?? Number.POSITIVE_INFINITY; const db = b.distanceMeters ?? Number.POSITIVE_INFINITY; return da - db; }); } return serializeBigInt(items); } async getStore(id: bigint, viewer?: StoreViewer) { const store = await this.prisma.store.findFirst({ where: { id, status: 'OPEN' }, include: { category: true, coverResource: true, visibilityPhones: { select: { phone: true } }, }, }); if (!store || !this.isVisibleToViewer(store, viewer)) { throw new NotFoundException('门店不存在'); } const coords = await this.ensureStoreCoordinates(store); const media = await this.prisma.commonResource.findMany({ 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({ ...rest, 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, imageUrl: p.imageUrl, sortOrder: p.sortOrder, })), }), ); } private async resolvePartnerScope(actorAccountId: bigint) { const account = await this.getPartnerAccount(actorAccountId); const primaryId = await this.getPartnerPrimaryId(actorAccountId); return { account, primaryId }; } async partnerListStores(partnerAccountId: bigint) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId }; if (this.isSubAccount(account)) { const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId); if (storeIds.length === 0) return []; where.id = { in: storeIds }; } const stores = await this.prisma.store.findMany({ where, include: { category: true, coverResource: true }, orderBy: { createdAt: 'desc' }, }); return serializeBigInt(stores.map(mapStoreCompat)); } async partnerGetStore(partnerAccountId: bigint, storeId: bigint) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); const store = await this.prisma.store.findFirst({ where: { id: storeId, partnerAccountId: primaryId }, include: { category: true, coverResource: true }, }); if (!store) throw new NotFoundException('门店不存在'); if (this.isSubAccount(account)) { await this.assertStoreOwnedByAccount(partnerAccountId, storeId); } const media = await this.loadPartnerStoreMedia(storeId); return serializeBigInt(mapStoreCompat({ ...store, media })); } async partnerListCities(partnerAccountId: bigint) { const { primaryId } = await this.resolvePartnerScope(partnerAccountId); const cityWhere = await this.partnerCityService.buildPartnerCityWhere(primaryId); const cities = await this.prisma.commonCity.findMany({ where: cityWhere, select: { id: true, name: true, code: true, province: true }, orderBy: { createdAt: 'desc' }, }); return serializeBigInt(cities); } async partnerCheckStorePhone(phone: string) { const normalizedPhone = phone.trim(); if (!normalizedPhone) { return { available: false, message: '请填写联系电话' }; } if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) { return { available: false, message: '联系电话须为11位手机号' }; } const existingAccount = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, include: { _count: { select: { bindings: true } } }, }); if (!existingAccount) { return { available: true, existingStoreCount: 0, needConfirm: false }; } if (existingAccount.isPrimary !== 1) { return { available: false, message: '该手机号已是门店子账号,不可作为负责人' }; } if (existingAccount.status !== 'ACTIVE') { return { available: false, message: '该手机号对应门店账号已停用' }; } const existingStoreCount = existingAccount._count.bindings; return { available: true, existingStoreCount, needConfirm: existingStoreCount > 0, message: existingStoreCount > 0 ? `该手机号已是门店主账号(已绑 ${existingStoreCount} 家店),确认后将追加绑定新店` : undefined, }; } async sendPartnerStorePhoneSms(phone: string) { const check = await this.partnerCheckStorePhone(phone); if (!check.available) { throw new BadRequestException(check.message ?? '该手机号不可用于门店账号'); } return this.authService.sendSms(phone.trim(), SmsScene.PARTNER_STORE_OPEN, { clientApp: ClientApp.PARTNER_H5, }).then(() => { const normalizedPhone = phone.trim(); const masked = normalizedPhone.length >= 7 ? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}` : normalizedPhone; return { ok: true, maskedPhone: masked }; }); } async createStore(partnerAccountId: bigint, body: Record) { const { primaryId } = await this.resolvePartnerScope(partnerAccountId); const normalizedPhone = String(body.phone).trim(); const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true'; await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting); const city = await this.resolvePartnerCity(partnerAccountId, body.cityId); const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : ''; const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls); const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : ''; if (!coverUrl) throw new BadRequestException('请上传门头照'); if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片'); if (!contractUrl) throw new BadRequestException('请上传签约合同'); const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null; const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null; const bankBranch = body.bankBranch ? String(body.bankBranch) : null; if (!body.categoryId) { throw new BadRequestException('请选择店铺类型'); } const categoryId = parseBigIntParam(body.categoryId, '分类ID'); await this.storeCategoryService.assertLeafCategoryId(categoryId); const openTime = body.openTime ? String(body.openTime).trim() : '10:00'; const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00'; const openTime2 = body.openTime2 ? String(body.openTime2).trim() : ''; const closeTime2 = body.closeTime2 ? String(body.closeTime2).trim() : ''; const hoursCheck = validateBusinessHours([ { open: openTime, close: closeTime }, ...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []), ]); if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message); const avgPriceRaw = body.avgPrice != null && body.avgPrice !== '' ? Number(body.avgPrice) : null; if (avgPriceRaw != null && (Number.isNaN(avgPriceRaw) || avgPriceRaw < 0)) { throw new BadRequestException('人均费用须为非负数字'); } const introRaw = body.intro != null ? String(body.intro).trim() : ''; if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) { throw new BadRequestException('门店简介须为 2~500 字'); } const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule); if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) { throw new BadRequestException('好客权益券使用规则最多 1000 字'); } const latitude = parseOptionalCoord(body.latitude, 'lat'); const longitude = parseOptionalCoord(body.longitude, 'lng'); if ((latitude == null) !== (longitude == null)) { throw new BadRequestException('经纬度须同时提供'); } const store = await this.prisma.store.create({ data: { cityId: city.id, partnerAccountId: primaryId, categoryId, name: String(body.name), phone: normalizedPhone, province: String(body.province ?? city.province ?? '河南省'), cityName: String(body.city ?? city.name ?? '郑州市'), district: String(body.district ?? ''), address: String(body.address), intro: introRaw || null, benefitUsageRule: benefitUsageRuleRaw, avgPrice: avgPriceRaw, openTime, closeTime, openTime2: openTime2 || null, closeTime2: closeTime2 || null, ...(latitude != null && longitude != null ? { latitude, longitude } : {}), status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED', auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING', auditedAt: this.config.autoApproveStore ? new Date() : null, rejectReason: null, }, }); if (latitude == null || longitude == null) { await this.ensureStoreCoordinates(store); } const ossBucket = process.env.OSS_BUCKET ?? 'legacy'; if (coverUrl) { const cover = await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'COVER', mediaType: 'IMAGE', ossBucket, ossKey: coverUrl, url: coverUrl, }, }); await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); } for (let i = 0; i < envPhotoUrls.length; i++) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'ENV', mediaType: 'IMAGE', ossBucket, ossKey: envPhotoUrls[i], url: envPhotoUrls[i], sortOrder: i, }, }); } if (contractUrl) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'CONTRACT', mediaType: 'FILE', ossBucket, ossKey: contractUrl, url: contractUrl, }, }); } const audit = await this.prisma.commonEvent.create({ data: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: store.id, actorType: 'PARTNER', actorId: partnerAccountId, status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING', param1: 'NEW', param1Desc: 'audit_type', extraJson: body as never, }, }); void audit; const existingPrimary = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, }); if (existingPrimary && existingPrimary.isPrimary === 1) { await this.prisma.storeAccountStore.create({ data: { storeAccountId: existingPrimary.id, storeId: store.id }, }); if (bankAccountName || bankAccountNo || bankBranch) { await this.prisma.storeAccount.update({ where: { id: existingPrimary.id }, data: { ...(bankAccountName != null ? { bankAccountName } : {}), ...(bankAccountNo != null ? { bankAccountNo } : {}), ...(bankBranch != null ? { bankBranch } : {}), }, }); } } else { await this.prisma.storeAccount.create({ data: { phone: normalizedPhone, name: String(body.name), isPrimary: 1, bankAccountName, bankAccountNo, bankBranch, bindings: { create: [{ storeId: store.id }] }, }, }); } this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primaryId, eventName: 'partner_store_create', refType: 'STORE', refId: store.id, extraJson: { storeName: store.name, phone: normalizedPhone }, }); return serializeBigInt({ store: mapStoreCompat({ ...store, coverResource: coverUrl ? { url: coverUrl } : null, }), }); } async partnerUpdateStoreStatus( partnerAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED' | 'CLOSED', ) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); await this.assertCanMutateStore(account, partnerAccountId, storeId); const store = await this.prisma.store.findFirst({ where: { id: storeId, partnerAccountId: primaryId }, }); if (!store) throw new NotFoundException('门店不存在'); if (store.status === 'CLOSED') { throw new BadRequestException('门店已关闭,不可变更状态'); } if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) { throw new BadRequestException('无效的门店状态'); } if (status === 'OPEN') { if (store.auditStatus === 'PENDING') { throw new BadRequestException('门店尚在总部审核中,通过后方可开门'); } if (store.auditStatus === 'REJECTED') { throw new BadRequestException( store.rejectReason ? `门店审核未通过:${store.rejectReason}` : '门店审核未通过,请查看驳回原因并重新提交', ); } } const updated = await this.prisma.store.update({ where: { id: storeId }, data: { status }, include: { coverResource: true }, }); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primaryId, eventName: 'partner_store_status_change', refType: 'STORE', refId: storeId, extraJson: { status, previousStatus: store.status, }, }); return serializeBigInt(mapStoreCompat(updated)); } async partnerUpdateStoreBasic( partnerAccountId: bigint, storeId: bigint, body: Record, ) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); await this.assertCanMutateStore(account, partnerAccountId, storeId); const store = await this.prisma.store.findFirst({ where: { id: storeId, partnerAccountId: primaryId }, }); if (!store) throw new NotFoundException('门店不存在'); if (store.status === 'CLOSED') { throw new BadRequestException('门店已关闭,不可编辑'); } if (store.auditStatus === 'PENDING') { throw new BadRequestException('门店审核中,暂不可修改资料'); } const name = body.name !== undefined ? String(body.name).trim() : undefined; const phone = body.phone !== undefined ? String(body.phone).trim() : undefined; const address = body.address !== undefined ? String(body.address).trim() : undefined; const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined; const benefitUsageRuleRaw = body.benefitUsageRule !== undefined ? normalizeOptionalTextField(body.benefitUsageRule) : undefined; const latitude = body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined; const longitude = body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined; if (name !== undefined && !name) throw new BadRequestException('请填写门店名称'); if (phone !== undefined && !/^1\d{10}$/.test(phone)) { throw new BadRequestException('联系电话须为11位手机号'); } if (address !== undefined && !address) throw new BadRequestException('请填写详细地址'); if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) { throw new BadRequestException('门店简介须为 2~500 字'); } if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) { throw new BadRequestException('好客权益券使用规则最多 1000 字'); } if ( (latitude !== undefined || longitude !== undefined) && (latitude == null || longitude == null) ) { throw new BadRequestException('经纬度须同时提供'); } const resubmitAudit = store.auditStatus === 'REJECTED'; const hasCoordsUpdate = latitude != null && longitude != null; const updated = await this.prisma.store.update({ where: { id: storeId }, data: { ...(name !== undefined ? { name } : {}), ...(phone !== undefined ? { phone } : {}), ...(address !== undefined ? hasCoordsUpdate ? { address } : { address, latitude: null, longitude: null } : {}), ...(hasCoordsUpdate ? { latitude, longitude } : {}), ...(introRaw !== undefined ? { intro: introRaw || null } : {}), ...(benefitUsageRuleRaw !== undefined ? { benefitUsageRule: benefitUsageRuleRaw } : {}), ...(resubmitAudit ? { auditStatus: 'PENDING' as const, rejectReason: null, auditedAt: null, status: store.status === 'OPEN' ? ('PAUSED' as const) : store.status, } : {}), }, }); if (address !== undefined && !hasCoordsUpdate) { await this.ensureStoreCoordinates(updated); } if (resubmitAudit) { await this.prisma.commonEvent.create({ data: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: storeId, actorType: 'PARTNER', actorId: partnerAccountId, status: 'PENDING', param1: 'RESUBMIT', param1Desc: 'audit_type', remark: '合伙人修改资料后重新提交审核', }, }); } return this.partnerGetStore(partnerAccountId, storeId); } /** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(至少 3 张,不设上限) */ async partnerUpdateStoreMedia( partnerAccountId: bigint, storeId: bigint, body: Record, ) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); await this.assertCanMutateStore(account, partnerAccountId, storeId); const store = await this.prisma.store.findFirst({ where: { id: storeId, partnerAccountId: primaryId }, }); if (!store) throw new NotFoundException('门店不存在'); if (store.status === 'CLOSED') { throw new BadRequestException('门店已关闭,不可编辑'); } if (store.auditStatus === 'PENDING') { throw new BadRequestException('门店审核中,暂不可修改资料'); } const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined; const hasEnv = body.envPhotoUrls !== undefined; const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined; if (coverUrl !== undefined && !coverUrl) { throw new BadRequestException('请上传门头照'); } if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) { throw new BadRequestException('请上传至少 3 张环境照片'); } if (coverUrl === undefined && envPhotoUrls === undefined) { throw new BadRequestException('请至少更新门头照或环境照片'); } const ossBucket = process.env.OSS_BUCKET ?? 'legacy'; if (coverUrl !== undefined) { if (store.coverResourceId) { await this.prisma.commonResource.update({ where: { id: store.coverResourceId }, data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' }, }); } else { const cover = await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: storeId, bizType: 'COVER', mediaType: 'IMAGE', ossBucket, ossKey: coverUrl, url: coverUrl, }, }); await this.prisma.store.update({ where: { id: storeId }, data: { coverResourceId: cover.id }, }); } } if (envPhotoUrls !== undefined) { await this.prisma.commonResource.updateMany({ where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' }, data: { status: 'DELETED' }, }); for (let i = 0; i < envPhotoUrls.length; i++) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', mediaType: 'IMAGE', ossBucket, ossKey: envPhotoUrls[i], url: envPhotoUrls[i], sortOrder: i, }, }); } } const resubmitAudit = store.auditStatus === 'REJECTED'; if (resubmitAudit) { await this.prisma.store.update({ where: { id: storeId }, data: { auditStatus: 'PENDING', rejectReason: null, auditedAt: null, status: store.status === 'OPEN' ? 'PAUSED' : store.status, }, }); await this.prisma.commonEvent.create({ data: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: storeId, actorType: 'PARTNER', actorId: partnerAccountId, status: 'PENDING', param1: 'RESUBMIT', param1Desc: 'audit_type', remark: '合伙人重新上传资料后重新提交审核', }, }); } return this.partnerGetStore(partnerAccountId, storeId); } async getShopStore(storeAccountId: bigint, storeId: bigint) { const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({ where: { storeAccountId_storeId: { storeAccountId, storeId } }, include: { store: { include: { category: true, coverResource: true } } }, }); return serializeBigInt(mapStoreCompat(binding.store)); } async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') { const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({ where: { storeAccountId_storeId: { storeAccountId, storeId } }, include: { store: true }, }); if (binding.store.status === 'CLOSED') { throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态'); } if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') { throw new BadRequestException( binding.store.auditStatus === 'REJECTED' ? `门店审核未通过${binding.store.rejectReason ? `:${binding.store.rejectReason}` : ''}` : '门店尚在总部审核中,通过后方可营业', ); } const previousStatus = binding.store.status; const store = await this.prisma.store.update({ where: { id: storeId }, data: { status }, }); this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { storeId, eventName: 'store_status_change', extraJson: { status, previousStatus, actor: 'STORE', }, }); return serializeBigInt(store); } async partnerDashboard(partnerAccountId: bigint) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); const primaryAccount = await this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: primaryId }, }); const orderCount = await this.prisma.order.count({ where: await this.partnerCityService.buildPartnerOrderWhere(primaryId), }); let storeWhere: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId, }; if (this.isSubAccount(account)) { const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId); if (storeIds.length === 0) { return { storeCount: 0, orderCount, companyName: primaryAccount.companyName ?? '', recentStores: [], pendingAuditCount: 0, notifications: [], }; } storeWhere = { partnerAccountId: primaryId, id: { in: storeIds } }; } const storeIdsForNotices = ( await this.prisma.store.findMany({ where: storeWhere, select: { id: true }, }) ).map((s) => s.id); const [storeCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([ this.prisma.store.count({ where: storeWhere }), this.prisma.store.findMany({ where: storeWhere, select: { id: true, name: true, status: true, auditStatus: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: 10, }), this.prisma.store.count({ where: { ...storeWhere, auditStatus: 'PENDING' }, }), storeIdsForNotices.length === 0 ? Promise.resolve([]) : this.prisma.commonEvent.findMany({ where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: { in: storeIdsForNotices }, status: { in: ['APPROVED', 'REJECTED'] }, actorType: 'HQ', }, orderBy: { createdAt: 'desc' }, take: 20, }), ]); const storeNameMap = new Map( ( await this.prisma.store.findMany({ where: { id: { in: recentNotices.map((n) => n.refId) } }, select: { id: true, name: true }, }) ).map((s) => [s.id.toString(), s.name]), ); return { storeCount, orderCount, companyName: primaryAccount.companyName ?? '', recentStores: serializeBigInt(recentStores), pendingAuditCount, notifications: serializeBigInt( recentNotices.map((n) => ({ id: n.id, storeId: n.refId, storeName: storeNameMap.get(n.refId.toString()) ?? '门店', status: n.status, remark: n.remark, createdAt: n.createdAt, title: n.status === 'APPROVED' ? '门店审核已通过' : n.status === 'REJECTED' ? '门店审核已驳回' : '门店审核更新', content: n.status === 'APPROVED' ? `${storeNameMap.get(n.refId.toString()) ?? '门店'} 已通过总部审核,可开门营业` : `${storeNameMap.get(n.refId.toString()) ?? '门店'} 未通过审核${n.remark ? `:${n.remark}` : ''}`, })), ), }; } async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') { const { primaryId } = await this.resolvePartnerScope(partnerAccountId); const accounts = await this.prisma.partnerAccount.findMany({ where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] }, orderBy: { id: 'asc' }, }); const { periodStart, periodEnd } = this.resolveLeaderboardPeriodRange(period); const entries = await Promise.all( accounts.map(async (row) => { const totalStores = await this.prisma.commonEvent.count({ where: { eventType: 'STORE_AUDIT', param1: 'NEW', actorType: 'PARTNER', actorId: row.id, refType: 'STORE', }, }); const periodStores = period === 'total' ? totalStores : await this.prisma.commonEvent.count({ where: { eventType: 'STORE_AUDIT', param1: 'NEW', actorType: 'PARTNER', actorId: row.id, refType: 'STORE', createdAt: { gte: periodStart, lt: periodEnd }, }, }); const staffRole = row.staffRole as PartnerStaffRole | null; const roleLabel = staffRole != null ? PARTNER_STAFF_ROLE_LABELS[staffRole] : row.isPrimary === 1 ? PARTNER_STAFF_ROLE_LABELS.PARTNER : PARTNER_STAFF_ROLE_LABELS.INTERNAL; return { accountId: row.id.toString(), name: row.name, staffRole: staffRole ?? undefined, roleLabel, totalStores, periodStores, isSelf: row.id === partnerAccountId, }; }), ); entries.sort((a, b) => { if (b.periodStores !== a.periodStores) return b.periodStores - a.periodStores; if (b.totalStores !== a.totalStores) return b.totalStores - a.totalStores; return a.accountId.localeCompare(b.accountId); }); const list = entries.map((entry, index) => ({ ...entry, rank: index + 1, })); const selfEntry = list.find((entry) => entry.isSelf); let self: (typeof list)[number] & { beatPercent?: number } | undefined; if (selfEntry) { const below = list.filter((entry) => entry.rank > selfEntry.rank).length; const beatPercent = list.length <= 1 ? 0 : Math.round((below / (list.length - 1)) * 100); self = { ...selfEntry, beatPercent }; } return { period, list, self }; } async partnerWeeklyReport(actorAccountId: bigint, startDate?: string) { const { account, primaryId } = await this.resolvePartnerScope(actorAccountId); this.assertPrimaryAccount(account); const currentWeekStart = this.startOfWeekMonday(new Date()); const periodStart = startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? this.parseLocalDate(startDate) : currentWeekStart; const periodEnd = this.addDays(periodStart, 7); const prevPeriodStart = this.addDays(periodStart, -7); const prevPeriodEnd = periodStart; const newStoreTarget = account.weeklyStoreTarget ?? Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20); const orderWhere = { ...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)), payStatus: 'PAID' as const, paidAt: { gte: periodStart, lt: periodEnd }, }; const prevOrderWhere = { ...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)), payStatus: 'PAID' as const, paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd }, }; const [ gmvAgg, orderCount, totalStoreCount, newStoreCount, prevGmvAgg, redeemGroups, activeRedeems, paidOrders, ] = await Promise.all([ this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }), this.prisma.order.count({ where: orderWhere }), this.prisma.store.count({ where: { partnerAccountId: primaryId } }), this.prisma.store.count({ where: { partnerAccountId: primaryId, createdAt: { gte: periodStart, lt: periodEnd } }, }), this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }), this.prisma.redeemRecord.groupBy({ by: ['storeId'], where: { createdAt: { gte: periodStart, lt: periodEnd }, store: { partnerAccountId: primaryId }, }, _sum: { amount: true }, orderBy: { _sum: { amount: 'desc' } }, take: 10, }), this.prisma.redeemRecord.findMany({ where: { createdAt: { gte: periodStart, lt: periodEnd }, store: { partnerAccountId: primaryId }, }, select: { storeId: true }, distinct: ['storeId'], }), this.prisma.order.findMany({ where: orderWhere, select: { payAmount: true, paidAt: true }, }), ]); const gmv = Number(gmvAgg._sum.payAmount ?? 0); const prevGmv = Number(prevGmvAgg._sum.payAmount ?? 0); const gmvGrowthPercent = prevGmv === 0 ? 0 : Math.round(((gmv - prevGmv) / prevGmv) * 1000) / 10; const newStoreProgressPercent = newStoreTarget <= 0 ? 0 : Math.min(100, Math.round((newStoreCount / newStoreTarget) * 100)); const dailyGmvMap = new Map(); for (let i = 0; i < 7; i += 1) { dailyGmvMap.set(this.formatDateKey(this.addDays(periodStart, i)), 0); } for (const order of paidOrders) { if (!order.paidAt) continue; const key = this.formatDateKey(order.paidAt); if (dailyGmvMap.has(key)) { dailyGmvMap.set(key, (dailyGmvMap.get(key) ?? 0) + Number(order.payAmount)); } } const dailyGmv = Array.from({ length: 7 }, (_, i) => { const day = this.addDays(periodStart, i); const date = this.formatDateKey(day); return { date, weekdayLabel: this.weekdayLabel(i), amount: dailyGmvMap.get(date) ?? 0, }; }); const rankStoreIds = redeemGroups.map((group) => group.storeId); const rankStores = rankStoreIds.length > 0 ? await this.prisma.store.findMany({ where: { id: { in: rankStoreIds } }, select: { id: true, name: true, intro: true, address: true }, }) : []; const storeMap = new Map(rankStores.map((store) => [store.id.toString(), store])); const storeRanking = redeemGroups.map((group, index) => { const store = storeMap.get(group.storeId.toString()); const subtitleSource = store?.intro?.trim() || store?.address?.trim() || ''; const subtitle = subtitleSource.length > 30 ? `${subtitleSource.slice(0, 30)}…` : subtitleSource; return { rank: index + 1, storeId: group.storeId.toString(), name: store?.name ?? '未知门店', subtitle: subtitle || undefined, redeemAmount: Number(group._sum.amount ?? 0), }; }); const topStore = storeRanking[0]; let insight: string; if (topStore && topStore.redeemAmount > 0) { if (gmvGrowthPercent > 0) { insight = `本周 GMV 增长 ${gmvGrowthPercent}%,主要得益于「${topStore.name}」的核销表现。建议关注同类高潜力门店。`; } else if (gmvGrowthPercent < 0) { insight = `本周 GMV 较上期下降 ${Math.abs(gmvGrowthPercent)}%,「${topStore.name}」仍为核销领先门店。建议复盘低效门店并复制头部经验。`; } else { insight = `本周 GMV 与上期持平,「${topStore.name}」核销表现领先。建议持续推动门店活跃。`; } } else { insight = '本周暂无核销数据,建议关注门店培训与权益推广,激活辖区门店。'; } const availablePeriods = [0, 1, 2, 3].map((offset) => { const start = this.addDays(currentWeekStart, -7 * offset); const end = this.addDays(start, 7); return { startDate: this.formatDateKey(start), endDate: this.formatDateKey(this.addDays(end, -1)), label: this.formatPeriodLabel(start, end), }; }); return { period: { startDate: this.formatDateKey(periodStart), endDate: this.formatDateKey(this.addDays(periodEnd, -1)), label: this.formatPeriodLabel(periodStart, periodEnd), }, availablePeriods, summary: { gmv, gmvGrowthPercent, activeStoreCount: activeRedeems.length, totalStoreCount, orderCount, newStoreCount, newStoreTarget, newStoreProgressPercent, }, dailyGmv, storeRanking, insight, }; } private resolveLeaderboardPeriodRange(period: PartnerLeaderboardPeriod) { const now = new Date(); if (period === 'month') { const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); return { periodStart, periodEnd: now }; } if (period === 'lastMonth') { const periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const periodEnd = new Date(now.getFullYear(), now.getMonth(), 1); return { periodStart, periodEnd }; } return { periodStart: new Date(0), periodEnd: now }; } private startOfWeekMonday(date: Date): Date { const d = new Date(date); d.setHours(0, 0, 0, 0); const day = d.getDay(); const diff = day === 0 ? 6 : day - 1; d.setDate(d.getDate() - diff); return d; } private addDays(date: Date, days: number): Date { const d = new Date(date); d.setDate(d.getDate() + days); return d; } private parseLocalDate(dateKey: string): Date { const [year, month, day] = dateKey.split('-').map(Number); return new Date(year, month - 1, day, 0, 0, 0, 0); } private formatDateKey(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } private formatPeriodLabel(start: Date, endExclusive: Date): string { const end = this.addDays(endExclusive, -1); return `${start.getMonth() + 1}月${start.getDate()}日 - ${end.getMonth() + 1}月${end.getDate()}日`; } private weekdayLabel(index: number): string { return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? ''; } private async getPartnerPrimaryId(actorAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId); return primary.id; } private async getPartnerAccount(partnerAccountId: bigint) { return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: partnerAccountId }, }); } private async resolvePartnerCity(actorAccountId: bigint, cityId: unknown) { const primaryId = await this.getPartnerPrimaryId(actorAccountId); if (cityId) { const id = parseBigIntParam(cityId, '城市ID'); await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id); const city = await this.prisma.commonCity.findUnique({ where: { id } }); if (!city) throw new BadRequestException('所选地区未匹配到开城城市'); return city; } const cityIds = await this.partnerCityService.listCityIdsForPartnerAccount(primaryId); if (!cityIds.length) throw new BadRequestException('合伙人未绑定开城'); const city = await this.prisma.commonCity.findFirst({ where: { id: { in: cityIds } } }); if (!city) throw new BadRequestException('合伙人未绑定开城'); return city; } private isSubAccount(account: { isPrimary: number }) { return account.isPrimary !== 1; } private partnerPermissionList(account: { permissions?: unknown }): string[] { return Array.isArray(account.permissions) ? (account.permissions as string[]) : []; } /** 主账号,或门店类子账号(含历史空权限)可改门店 */ private async assertCanMutateStore( account: { isPrimary: number; permissions?: unknown }, partnerAccountId: bigint, storeId: bigint, ) { if (!this.isSubAccount(account)) return; const perms = this.partnerPermissionList(account); const canManage = perms.includes('store:manage'); const canCreate = perms.includes('store:create'); const legacyStoreStaff = perms.length === 0; const warehouseOnly = !canManage && !canCreate && !legacyStoreStaff && (perms.includes('warehouse:manage') || (perms.includes('order:view') && !perms.includes('store:create') && !perms.includes('store:manage'))); if (warehouseOnly || (!canManage && !canCreate && !legacyStoreStaff)) { throw new ForbiddenException('子账号无门店管理权限'); } // store:manage 可管团队门店;仅 store:create / 历史空权限只能改自己录入的店 if (canManage) return; await this.assertStoreOwnedByAccount(partnerAccountId, storeId); } private normalizeEnvPhotoUrls(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const seen = new Set(); const urls: string[] = []; for (const item of raw) { const url = String(item ?? '').trim(); if (!url || seen.has(url)) continue; seen.add(url); urls.push(url); } return urls; } /** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */ private async loadPartnerStoreMedia(storeId: bigint) { const media = await this.prisma.commonResource.findMany({ where: { ownerType: 'STORE', ownerId: storeId, status: 'ACTIVE', bizType: { in: ['ENV', 'CONTRACT'] }, }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); const seenEnv = new Set(); const duplicateEnvIds: bigint[] = []; const kept: typeof media = []; for (const row of media) { if (row.bizType !== 'ENV') { kept.push(row); continue; } const key = row.url.trim(); if (seenEnv.has(key)) { duplicateEnvIds.push(row.id); continue; } seenEnv.add(key); kept.push(row); } if (duplicateEnvIds.length > 0) { await this.prisma.commonResource.updateMany({ where: { id: { in: duplicateEnvIds } }, data: { status: 'DELETED' }, }); } return kept; } private assertPrimaryAccount(account: { isPrimary: number }) { if (this.isSubAccount(account)) { throw new ForbiddenException('子账号无权执行此操作'); } } private async assertStorePhoneAvailable(phone: string, confirmBindExisting = false) { const result = await this.partnerCheckStorePhone(phone); if (!result.available) { throw new BadRequestException(result.message ?? '该手机号不可用'); } if (result.needConfirm && !confirmBindExisting) { throw new BadRequestException(result.message ?? '该手机号已绑定门店,请确认后重试'); } } private async getStoreIdsCreatedByAccount(partnerAccountId: bigint): Promise { const events = await this.prisma.commonEvent.findMany({ where: { eventType: 'STORE_AUDIT', param1: 'NEW', actorType: 'PARTNER', actorId: partnerAccountId, refType: 'STORE', }, select: { refId: true }, }); return events.map((event) => event.refId).filter((id): id is bigint => id != null); } private async assertStoreOwnedByAccount(partnerAccountId: bigint, storeId: bigint) { const event = await this.prisma.commonEvent.findFirst({ where: { eventType: 'STORE_AUDIT', param1: 'NEW', actorType: 'PARTNER', actorId: partnerAccountId, refType: 'STORE', refId: storeId, }, }); if (!event) throw new ForbiddenException('无权查看该门店'); } }