import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto'; function startOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); } function endOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999); } function parseYmd(s: string): Date | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null; const d = new Date(`${s}T00:00:00`); return Number.isNaN(d.getTime()) ? null : d; } function formatYmd(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function eachDate(from: Date, to: Date): string[] { const out: string[] = []; const cur = startOfDay(from); const end = startOfDay(to); while (cur <= end) { out.push(formatYmd(cur)); cur.setDate(cur.getDate() + 1); } return out; } function num(v: Prisma.Decimal | number | string | null | undefined): number { if (v == null) return 0; return typeof v === 'number' ? v : Number(v); } 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(); } @Injectable() export class AdminDashboardService { constructor(private readonly prisma: PrismaService) {} async getStats() { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const [ usersTotal, guestUsers, verifiedUsers, mergedUsers, ordersToday, ordersByStatus, storesTotal, partnersTotal, redeemToday, deliveriesTotal, pendingPayouts, pendingBills, pendingPartnerDraftBills, openTickets, pendingWithdrawRows, ] = await Promise.all([ this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null }, }), this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } }, }), this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }), this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }), this.prisma.order.groupBy({ by: ['status'], _count: { status: true }, }), this.prisma.store.count(), this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }), this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), this.prisma.orderDelivery.count(), this.prisma.storePayout.count({ where: { status: 'PENDING' } }), this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }), this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), this.prisma.storeWithdrawRequest.findMany({ where: { status: 'PENDING_REVIEW' }, select: { appliedAt: true }, }), ]); 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, 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(query: AdminDashboardAnalyticsQueryDto) { const today = startOfDay(new Date()); const defaultFrom = new Date(today); defaultFrom.setDate(defaultFrom.getDate() - 29); const from = (query.dateFrom ? parseYmd(query.dateFrom) : null) ?? defaultFrom; const to = (query.dateTo ? parseYmd(query.dateTo) : null) ?? today; const rangeStart = startOfDay(from <= to ? from : to); const rangeEnd = endOfDay(from <= to ? to : from); let filterCityCode: string | null | undefined; let filterCityId: bigint | null | undefined; if (query.cityId === 'none') { filterCityCode = null; filterCityId = null; } else if (query.cityId) { const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(query.cityId) }, select: { id: true, code: true }, }); if (city) { filterCityCode = city.code; filterCityId = city.id; } } const filterPromoNone = query.promoCodeId === 'none'; const filterPromoId = query.promoCodeId && query.promoCodeId !== 'none' ? BigInt(query.promoCodeId) : undefined; const filterPartnerId = query.partnerAccountId ? BigInt(query.partnerAccountId) : undefined; const userWhere: Prisma.UserWhereInput = { status: 1, mergedIntoUserId: null, createdAt: { gte: rangeStart, lte: rangeEnd }, }; if (filterCityCode === null) { userWhere.OR = [ { cityPreference: null }, { cityPreference: { selectedCityCode: null } }, ]; } else if (filterCityCode) { userWhere.cityPreference = { selectedCityCode: filterCityCode }; } if (filterPromoNone) { userWhere.promoTouch = { is: null }; } else if (filterPromoId !== undefined) { userWhere.promoTouch = { promoCodeId: filterPromoId }; } const orderWhere: Prisma.OrderWhereInput = { createdAt: { gte: rangeStart, lte: rangeEnd }, }; if (filterCityId !== undefined && filterCityId !== null) { orderWhere.cityId = filterCityId; } if (filterPromoNone) { orderWhere.promoCodeId = null; } else if (filterPromoId !== undefined) { orderWhere.promoCodeId = filterPromoId; } const partnerWhere: Prisma.PartnerAccountWhereInput = { isPrimary: 1, createdAt: { gte: rangeStart, lte: rangeEnd }, }; if (filterCityId === null) { partnerWhere.cityId = null; } else if (filterCityId !== undefined) { partnerWhere.cityId = filterCityId; } if (filterPartnerId !== undefined) { partnerWhere.id = filterPartnerId; } const storeWhere: Prisma.StoreWhereInput = { createdAt: { gte: rangeStart, lte: rangeEnd }, }; if (filterCityId === null) { // 门店必有 cityId storeWhere.id = { in: [] }; } else if (filterCityId !== undefined) { storeWhere.cityId = filterCityId; } if (filterPartnerId !== undefined) { storeWhere.partnerAccountId = filterPartnerId; } const redeemWhere: Prisma.RedeemRecordWhereInput = { createdAt: { gte: rangeStart, lte: rangeEnd }, }; if (filterCityId === null) { redeemWhere.id = { in: [] }; } else { const storeFilter: Prisma.StoreWhereInput = {}; if (filterCityId !== undefined) storeFilter.cityId = filterCityId; if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId; if (Object.keys(storeFilter).length) { redeemWhere.store = storeFilter; } } const skipOrders = filterCityId === null; const [users, orders, partners, stores, redeems, cities, promos, partnerNames] = await Promise.all([ this.prisma.user.findMany({ where: userWhere, select: { id: true, createdAt: true, cityPreference: { select: { selectedCityCode: true } }, promoTouch: { select: { promoCodeId: true } }, }, }), skipOrders ? Promise.resolve([]) : this.prisma.order.findMany({ where: orderWhere, select: { id: true, userId: true, createdAt: true, cityId: true, promoCodeId: true, payStatus: true, }, }), this.prisma.partnerAccount.findMany({ where: partnerWhere, select: { id: true, createdAt: true, cityId: true, companyName: true, name: true, }, }), this.prisma.store.findMany({ where: storeWhere, select: { id: true, createdAt: true, cityId: true, partnerAccountId: true, }, }), this.prisma.redeemRecord.findMany({ where: redeemWhere, select: { id: true, createdAt: true, amount: true, settleAmount: true, store: { select: { cityId: true, partnerAccountId: true } }, }, }), this.prisma.commonCity.findMany({ select: { id: true, code: true, name: true }, }), this.prisma.commonPromoCode.findMany({ select: { id: true, code: true, name: true }, }), this.prisma.partnerAccount.findMany({ where: { isPrimary: 1 }, select: { id: true, companyName: true, name: true }, }), ]); const cityByCode = new Map(cities.map((c) => [c.code, c])); const cityById = new Map(cities.map((c) => [c.id.toString(), c])); const promoById = new Map(promos.map((p) => [p.id.toString(), p])); const partnerLabel = new Map( partnerNames.map((p) => [ p.id.toString(), p.companyName || p.name || `合伙人#${p.id}`, ]), ); const dateKeys = eachDate(rangeStart, rangeEnd); type DateBucket = { date: string; users: number; orders: number; partners: number; stores: number; redeems: number; redeemAmount: number; }; const byDateMap = new Map( dateKeys.map((d) => [ d, { date: d, users: 0, orders: 0, partners: 0, stores: 0, redeems: 0, redeemAmount: 0 }, ]), ); type CityBucket = { cityId: string; cityName: string; users: number; orders: number; partners: number; stores: number; redeems: number; redeemAmount: number; }; const byCityMap = new Map(); type PromoBucket = { promoCodeId: string | null; code: string; name: string; users: number; orders: number; }; const byPromoMap = new Map(); type PartnerBucket = { partnerAccountId: string; companyName: string; stores: number; redeems: number; redeemAmount: number; }; const byPartnerMap = new Map(); const ensureCity = (key: string, cityId: string, cityName: string) => { let b = byCityMap.get(key); if (!b) { b = { cityId, cityName, users: 0, orders: 0, partners: 0, stores: 0, redeems: 0, redeemAmount: 0, }; byCityMap.set(key, b); } return b; }; const ensurePromo = ( key: string, promoCodeId: string | null, code: string, name: string, ) => { let b = byPromoMap.get(key); if (!b) { b = { promoCodeId, code, name, users: 0, orders: 0 }; byPromoMap.set(key, b); } return b; }; const ensurePartner = (key: string, companyName: string) => { let b = byPartnerMap.get(key); if (!b) { b = { partnerAccountId: key, companyName, stores: 0, redeems: 0, redeemAmount: 0, }; byPartnerMap.set(key, b); } return b; }; for (const u of users) { const d = formatYmd(u.createdAt); const day = byDateMap.get(d); if (day) day.users += 1; const code = u.cityPreference?.selectedCityCode ?? null; if (code && cityByCode.has(code)) { const city = cityByCode.get(code)!; ensureCity(city.id.toString(), city.id.toString(), city.name).users += 1; } else { ensureCity('none', 'none', '未选城').users += 1; } const pid = u.promoTouch?.promoCodeId?.toString() ?? null; if (pid && promoById.has(pid)) { const p = promoById.get(pid)!; ensurePromo(pid, pid, p.code, p.name).users += 1; } else { ensurePromo('none', null, 'ORGANIC', '自然量').users += 1; } } const payingUserIds = new Set(); for (const o of orders) { const d = formatYmd(o.createdAt); const day = byDateMap.get(d); if (day) day.orders += 1; const cid = o.cityId.toString(); const city = cityById.get(cid); ensureCity(cid, cid, city?.name ?? `城市#${cid}`).orders += 1; const pid = o.promoCodeId?.toString() ?? null; if (pid && promoById.has(pid)) { const p = promoById.get(pid)!; ensurePromo(pid, pid, p.code, p.name).orders += 1; } else { ensurePromo('none', null, 'NONE', '无推广码').orders += 1; } if (o.payStatus === 'PAID') { payingUserIds.add(o.userId.toString()); } } for (const p of partners) { const d = formatYmd(p.createdAt); const day = byDateMap.get(d); if (day) day.partners += 1; if (p.cityId) { const cid = p.cityId.toString(); const city = cityById.get(cid); ensureCity(cid, cid, city?.name ?? `城市#${cid}`).partners += 1; } else { ensureCity('none', 'none', '未绑定城市').partners += 1; } const key = p.id.toString(); ensurePartner(key, p.companyName || p.name || `合伙人#${key}`); } for (const s of stores) { const d = formatYmd(s.createdAt); const day = byDateMap.get(d); if (day) day.stores += 1; const cid = s.cityId.toString(); const city = cityById.get(cid); ensureCity(cid, cid, city?.name ?? `城市#${cid}`).stores += 1; const pid = s.partnerAccountId.toString(); ensurePartner(pid, partnerLabel.get(pid) || `合伙人#${pid}`).stores += 1; } let redeemAmountTotal = 0; for (const r of redeems) { const amount = num(r.amount); redeemAmountTotal += amount; const d = formatYmd(r.createdAt); const day = byDateMap.get(d); if (day) { day.redeems += 1; day.redeemAmount += amount; } const cid = r.store.cityId.toString(); const city = cityById.get(cid); const cityBucket = ensureCity(cid, cid, city?.name ?? `城市#${cid}`); cityBucket.redeems += 1; cityBucket.redeemAmount += amount; const pid = r.store.partnerAccountId.toString(); const partnerBucket = ensurePartner( pid, partnerLabel.get(pid) || `合伙人#${pid}`, ); partnerBucket.redeems += 1; partnerBucket.redeemAmount += amount; } const byCity = [...byCityMap.values()].sort( (a, b) => b.users + b.orders + b.partners + b.stores + b.redeems - (a.users + a.orders + a.partners + a.stores + a.redeems), ); const byPromo = [...byPromoMap.values()].sort( (a, b) => b.users + b.orders - (a.users + a.orders), ); const byPartner = [...byPartnerMap.values()].sort( (a, b) => b.stores + b.redeems - (a.stores + a.redeems), ); return { summary: { users: users.length, orders: orders.length, payingUsers: payingUserIds.size, partners: partners.length, stores: stores.length, redeems: redeems.length, redeemAmount: Math.round(redeemAmountTotal * 100) / 100, }, byDate: dateKeys.map((d) => { const row = byDateMap.get(d)!; return { ...row, redeemAmount: Math.round(row.redeemAmount * 100) / 100, }; }), byCity: byCity.map((row) => ({ ...row, redeemAmount: Math.round(row.redeemAmount * 100) / 100, })), byPromo, byPartner: byPartner.map((row) => ({ ...row, redeemAmount: Math.round(row.redeemAmount * 100) / 100, })), }; } }