feat: 支持上课统计功能;优化会员管理默认筛选状态
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {}
|
||||
58
packages/server/src/admin/teaching-analytics.service.ts
Normal file
58
packages/server/src/admin/teaching-analytics.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user