import { ForbiddenException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import type { DashboardAnalytics, DashboardGranularity, HqPermissionKey, } from '@dukang/shared-types'; import { addShanghaiDays, defaultShanghaiRangeYmds, eachShanghaiBuckets, parseShanghaiYmd, shanghaiYmd, } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { HqPermissionsResolver, assertHqCityInScope, hqStoreCityWhere, type HqCityScope, } from '../../common/guards/hq-permission.guard'; import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto'; import { type CityFilter } from './admin-dashboard-analytics'; import { loadDashboardLineCharts } from './admin-dashboard-lines'; function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean { const day = appliedAt.getDay(); if (day === 0 || day === 6) return false; const deadline = new Date( appliedAt.getFullYear(), appliedAt.getMonth(), appliedAt.getDate(), 18, 0, 0, 0, ); return now.getTime() > deadline.getTime(); } function cityIdFilter(scope: HqCityScope): Prisma.BigIntFilter | undefined { if (scope === null) return undefined; if (!scope.length) return { equals: BigInt(0) }; return { in: scope }; } function emptyRows(): Promise { return Promise.resolve([] as T[]); } @Injectable() export class AdminDashboardService { constructor( private readonly prisma: PrismaService, private readonly hqPermissions: HqPermissionsResolver, ) {} async getStats(actorId: bigint) { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const [{ keys, isSuperAdmin }, scope] = await Promise.all([ this.hqPermissions.resolveAccess(actorId), this.hqPermissions.resolveCityScope(actorId), ]); const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k); const storeWhere = hqStoreCityWhere(scope) ?? {}; const cityFilter = cityIdFilter(scope); const orderWhere: Prisma.OrderWhereInput = cityFilter ? { cityId: cityFilter } : {}; const partnerWhere: Prisma.PartnerAccountWhereInput = { isPrimary: 1, ...(cityFilter ? { cityId: cityFilter } : {}), }; let userWhere: Prisma.UserWhereInput = { status: 1, mergedIntoUserId: null }; if (scope !== null) { if (!scope.length) { userWhere = { id: { equals: BigInt(0) } }; } else { const cities = await this.prisma.commonCity.findMany({ where: { id: { in: scope } }, select: { code: true }, }); const codes = cities.map((c) => c.code); userWhere = { status: 1, mergedIntoUserId: null, cityPreference: { selectedCityCode: { in: codes.length ? codes : [''] } }, }; } } const redeemTodayWhere: Prisma.RedeemRecordWhereInput = { createdAt: { gte: todayStart }, ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), }; const [ usersTotal, guestUsers, verifiedUsers, mergedUsers, ordersToday, ordersByStatus, storesTotal, partnersTotal, redeemToday, deliveriesTotal, pendingPayouts, pendingBills, pendingPartnerDraftBills, openTickets, pendingWithdrawRows, pendingStoreOnboard, pendingStorePackageAudits, pendingStoreInfoChanges, ] = await Promise.all([ can('users') ? this.prisma.user.count({ where: userWhere }) : Promise.resolve(0), can('users') ? this.prisma.user.count({ where: { ...userWhere, phoneVerifiedAt: null }, }) : Promise.resolve(0), can('users') ? this.prisma.user.count({ where: { ...userWhere, phoneVerifiedAt: { not: null } }, }) : Promise.resolve(0), can('users') ? this.prisma.user.count({ where: scope === null ? { mergedIntoUserId: { not: null } } : { id: { equals: BigInt(0) } }, }) : Promise.resolve(0), can('orders') ? this.prisma.order.count({ where: { ...orderWhere, createdAt: { gte: todayStart } }, }) : Promise.resolve(0), can('orders') ? this.prisma.order.groupBy({ by: ['status'], where: orderWhere, _count: { status: true }, }) : emptyRows<{ status: string; _count: { status: number } }>(), can('stores') ? this.prisma.store.count({ where: storeWhere }) : Promise.resolve(0), can('partners') ? this.prisma.partnerAccount.count({ where: partnerWhere }) : Promise.resolve(0), can('benefit') ? this.prisma.redeemRecord.count({ where: redeemTodayWhere }) : Promise.resolve(0), can('deliveries') ? this.prisma.orderDelivery.count({ where: { order: { deliveryType: { not: 'ON_SITE_PICKUP' }, ...(cityFilter ? { cityId: cityFilter } : {}), }, }, }) : Promise.resolve(0), can('finance') ? this.prisma.storePayout.count({ where: { status: 'PENDING', ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), }, }) : Promise.resolve(0), can('finance') ? this.prisma.partnerBill.count({ where: { status: 'UNPAID', ...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}), }, }) : Promise.resolve(0), can('finance') ? this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW', ...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}), }, }) : Promise.resolve(0), can('tickets') && scope === null ? this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }) : Promise.resolve(0), can('finance') ? this.prisma.storeWithdrawRequest.findMany({ where: { status: 'PENDING_REVIEW', ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), }, select: { appliedAt: true }, }) : emptyRows<{ appliedAt: Date }>(), can('stores') ? this.prisma.store.count({ where: { auditStatus: 'PENDING', ...storeWhere }, }) : Promise.resolve(0), can('store_audits') ? this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING', ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), }, }) : Promise.resolve(0), can('store_audits') ? this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING', ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), }, }) : Promise.resolve(0), ]); const now = new Date(); const pendingStoreWithdrawals = pendingWithdrawRows.length; const overdueStoreWithdrawals = pendingWithdrawRows.filter((r) => isWithdrawOverdue(r.appliedAt, now), ).length; return { usersTotal, guestUsers, verifiedUsers, mergedUsers, ordersToday, storesTotal, partnersTotal, redeemToday, deliveriesTotal, pendingPayouts, pendingBills, pendingPartnerDraftBills, openTickets, pendingStoreWithdrawals, overdueStoreWithdrawals, pendingStoreOnboard, pendingStorePackageAudits, pendingStoreInfoChanges, ordersByStatus: ordersByStatus.map((row) => ({ status: row.status, count: row._count.status, })), }; } async getLatestVersion() { const row = await this.prisma.systemVersion.findFirst({ orderBy: { deployedAt: 'desc' }, }); if (!row) return null; return { id: row.id.toString(), gitTag: row.gitTag, commitId: row.commitId, commitMessage: row.commitMessage, branch: row.branch, deployedBy: row.deployedBy, deployedAt: row.deployedAt.toISOString(), }; } async getAnalytics( actorId: bigint, query: AdminDashboardAnalyticsQueryDto, ): Promise { const grain: DashboardGranularity = query.granularity ?? 'day'; const defaults = defaultShanghaiRangeYmds(grain); const fromYmd = /^\d{4}-\d{2}-\d{2}$/.test(query.dateFrom ?? '') ? query.dateFrom! : defaults.from; const toYmd = /^\d{4}-\d{2}-\d{2}$/.test(query.dateTo ?? '') ? query.dateTo! : defaults.to; const fromDay = parseShanghaiYmd(fromYmd <= toYmd ? fromYmd : toYmd); const toDay = parseShanghaiYmd(fromYmd <= toYmd ? toYmd : fromYmd); const rangeStart = fromDay; const rangeEndExclusive = addShanghaiDays(toDay, 1); const [{ keys, isSuperAdmin }, scope] = await Promise.all([ this.hqPermissions.resolveAccess(actorId), this.hqPermissions.resolveCityScope(actorId), ]); const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k); let city: CityFilter = { kind: 'all' }; if (query.cityId === 'none') { if (scope !== null) throw new ForbiddenException('无权按未选城筛选'); city = { kind: 'none' }; } else if (query.cityId) { const row = await this.prisma.commonCity.findUnique({ where: { id: BigInt(query.cityId) }, select: { id: true, code: true }, }); if (!row) throw new ForbiddenException('无权访问该城市的门店'); assertHqCityInScope(scope, row.id); city = { kind: 'ids', ids: [row.id], codes: [row.code] }; } else if (scope !== null) { if (!scope.length) { city = { kind: 'empty' }; } else { const scopedCities = await this.prisma.commonCity.findMany({ where: { id: { in: scope } }, select: { id: true, code: true }, }); city = { kind: 'ids', ids: scope, codes: scopedCities.map((c) => c.code) }; } } const buckets = eachShanghaiBuckets(rangeStart, toDay, grain); const charts = await loadDashboardLineCharts({ prisma: this.prisma, grain, city, rangeStart, rangeEndExclusive, periodKeys: buckets.map((b) => b.key), can, }); return { granularity: grain, range: { from: shanghaiYmd(fromDay), to: shanghaiYmd(toDay) }, periods: buckets.map((b) => ({ key: b.key, label: b.label })), charts, }; } }