feat: 支持上课统计功能;优化会员管理默认筛选状态

This commit is contained in:
richarjiang
2026-09-09 10:04:29 +08:00
parent 30ebc1c344
commit 99c5c211ad
17 changed files with 594 additions and 20 deletions

View File

@@ -0,0 +1,75 @@
import 'reflect-metadata'
import { BadRequestException } from '@nestjs/common'
import { BookingStatus, UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../../prisma/prisma.service'
import { TeachingAnalyticsService } from '../teaching-analytics.service'
import { AdminController } from '../admin.controller'
import { ROLES_KEY } from '../../auth/roles.decorator'
function booking(id: string, userId: string, slotId: string, date: string, status = BookingStatus.COMPLETED) {
return {
id, userId, status, user: { nickname: '同名学员' },
timeSlot: { id: slotId, date: new Date(`${date}T00:00:00Z`), startTime: '09:00', endTime: '10:30' },
membership: { cardType: { name: '次卡' } },
}
}
describe('TeachingAnalyticsService', () => {
const findMany = jest.fn()
const service = new TeachingAnalyticsService({ booking: { findMany } } as unknown as PrismaService)
beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T03:00:00Z')); findMany.mockReset() })
afterEach(() => jest.useRealTimers())
it.each(['2026-13', '2026-00', '2026-9', '', '2026-09-01', '1999-12', undefined])('rejects invalid month %s before querying', async month => {
await expect(service.getMonthly(month as string)).rejects.toBeInstanceOf(BadRequestException)
expect(findMany).not.toHaveBeenCalled()
})
it('counts shared sessions and duration once, students by identity, and excludes other statuses', async () => {
findMany.mockResolvedValue([
booking('previous', 'a', 'old', '2026-08-31'),
booking('1', 'a', 'one', '2026-09-01'), booking('2', 'b', 'one', '2026-09-01'),
booking('3', 'a', 'two', '2026-09-03'),
...[BookingStatus.CANCELLED, BookingStatus.NO_SHOW, BookingStatus.CONFIRMED, BookingStatus.PENDING_CONFIRMATION]
.map((status, index) => booking(`other${index}`, 'c', `other${index}`, '2026-09-04', status)),
])
const result = await service.getMonthly('2026-09')
expect(result.summary).toEqual({ sessions: 2, attendances: 3, students: 2, minutes: 180, teachingDays: 2 })
expect(result.previous.sessions).toBe(1)
expect(result.records).toHaveLength(7)
expect(result.records.every(row => row.date.startsWith('2026-09'))).toBe(true)
expect(findMany).toHaveBeenCalledTimes(1)
})
it.each([
['2026-01', '2025-12-01', '2026-02-01'],
['2024-02', '2024-01-01', '2024-03-01'],
])('uses half-open course date boundaries for %s', async (month, from, to) => {
findMany.mockResolvedValue([])
const result = await service.getMonthly(month)
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { timeSlot: { date: { gte: new Date(`${from}T00:00:00Z`), lt: new Date(`${to}T00:00:00Z`) } } },
}))
expect(result.previousMonth).toBe(from.slice(0, 7))
expect(result.summary).toEqual({ sessions: 0, attendances: 0, students: 0, minutes: 0, teachingDays: 0 })
})
it('flags only unfinished bookings past their China-time end, without converting status', async () => {
const future = booking('future', 'b', 'future', '2026-09-09', BookingStatus.CONFIRMED)
future.timeSlot.endTime = '11:30'
findMany.mockResolvedValue([
booking('past', 'a', 'past', '2026-09-09', BookingStatus.CONFIRMED), future,
booking('cancel', 'c', 'cancel', '2026-09-09', BookingStatus.CANCELLED),
])
const result = await service.getMonthly('2026-09')
expect(result.records.map(row => row.needsReview)).toEqual([true, false, false])
expect(result.summary.sessions).toBe(0)
expect(result.records[0].status).toBe(BookingStatus.CONFIRMED)
})
it('inherits the admin-only controller role and authentication guards', () => {
expect(Reflect.getMetadata(ROLES_KEY, AdminController)).toEqual([UserRole.ADMIN])
const guards = Reflect.getMetadata('__guards__', AdminController) as Array<{ name: string }>
expect(guards.map(guard => guard.name)).toEqual(['JwtAuthGuard', 'RolesGuard'])
})
})

View File

@@ -1,4 +1,5 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { TeachingAnalyticsService } from './teaching-analytics.service'
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
@@ -15,7 +16,12 @@ interface AdminStats {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminController {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService, private readonly analytics: TeachingAnalyticsService) {}
@Get('teaching-analytics')
getTeachingAnalytics(@Query('month') month: string) {
return this.analytics.getMonthly(month)
}
@Get('stats')
async getStats(): Promise<AdminStats> {

View File

@@ -1,7 +1,9 @@
import { TeachingAnalyticsService } from './teaching-analytics.service'
import { Module } from '@nestjs/common'
import { AdminController } from './admin.controller'
@Module({
controllers: [AdminController],
providers: [TeachingAnalyticsService],
})
export class AdminModule {}

View File

@@ -0,0 +1,58 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { BookingStatus, type TeachingAnalytics, type TeachingAnalyticsRecord, type TeachingAnalyticsSummary } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class TeachingAnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async getMonthly(month: string): Promise<TeachingAnalytics> {
if (typeof month !== 'string' || !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) {
throw new BadRequestException('月份格式应为 YYYY-MM范围为 2000—2099 年')
}
const [year, number] = month.split('-').map(Number)
const start = new Date(Date.UTC(year, number - 1, 1))
const previousStart = new Date(Date.UTC(year, number - 2, 1))
const end = new Date(Date.UTC(year, number, 1))
const now = new Date()
const bookings = await this.prisma.booking.findMany({
where: { timeSlot: { date: { gte: previousStart, lt: end } } },
select: {
id: true, userId: true, status: true,
user: { select: { nickname: true } },
timeSlot: { select: { id: true, date: true, startTime: true, endTime: true } },
membership: { select: { cardType: { select: { name: true } } } },
},
orderBy: [{ timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, { id: 'asc' }],
})
const rows: TeachingAnalyticsRecord[] = bookings.map((booking) => {
const slot = booking.timeSlot
const date = slot.date.toISOString().slice(0, 10)
const unfinished = booking.status === BookingStatus.CONFIRMED || booking.status === BookingStatus.PENDING_CONFIRMATION
return {
id: booking.id, userId: booking.userId, nickname: booking.user.nickname,
slotId: slot.id, date, startTime: slot.startTime, endTime: slot.endTime,
cardName: booking.membership.cardType.name, status: booking.status as BookingStatus,
needsReview: unfinished && new Date(`${date}T${slot.endTime}:00+08:00`).getTime() < now.getTime(),
}
})
const records = rows.filter((row) => row.date >= start.toISOString().slice(0, 10))
return {
month, generatedAt: now.toISOString(), records,
summary: this.summarize(records), previousMonth: previousStart.toISOString().slice(0, 7),
previous: this.summarize(rows.filter((row) => row.date < start.toISOString().slice(0, 10))),
}
}
private summarize(rows: TeachingAnalyticsRecord[]): TeachingAnalyticsSummary {
const completed = rows.filter((row) => row.status === BookingStatus.COMPLETED)
const slots = new Map(completed.map((row) => [row.slotId, row]))
const minutes = [...slots.values()].reduce((total, slot) => {
const parse = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
return total + Math.max(0, parse(slot.endTime) - parse(slot.startTime))
}, 0)
return { sessions: slots.size, attendances: completed.length,
students: new Set(completed.map((row) => row.userId)).size,
teachingDays: new Set(completed.map((row) => row.date)).size, minutes }
}
}

View File

@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'
import { BadRequestException, NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service'
import { UserController } from '../user.controller'
import { PrismaService } from '../../prisma/prisma.service'
import {
MembershipStatus,
@@ -457,6 +458,24 @@ describe('UserService', () => {
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
})
describe('member filters', () => {
it.each([
['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
[undefined, {}],
])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
mockPrisma.user.findMany.mockResolvedValue([])
mockPrisma.user.count.mockResolvedValue(0)
const controller = new UserController(service)
await controller.getMembers('2', '20', undefined, filter as string | undefined)
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ where, skip: 20, take: 20 }))
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where })
})
})
describe('getMemberDetail', () => {
const cardType = {
id: 'ct-1',

View File

@@ -76,7 +76,7 @@ export class UserController {
@Query('cardType') cardType?: string,
) {
const validCardType =
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE')
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE' || cardType === 'ACTIVE')
? cardType
: undefined
return this.userService.getMembers(

View File

@@ -382,9 +382,11 @@ export class UserService {
}
: {}
// cardType filter: NONE = no active membership, otherwise filter by card type category
// ACTIVE and NONE are complementary membership-status filters.
if (cardType === 'NONE') {
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
} else if (cardType === 'ACTIVE') {
where.memberships = { some: { status: MembershipStatus.ACTIVE } }
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
where.memberships = {
some: {