feat: 优化排课管理

This commit is contained in:
richarjiang
2026-04-15 23:25:09 +08:00
parent 6ab16f508a
commit 4dacd908a6
14 changed files with 71 additions and 765 deletions

View File

@@ -6,40 +6,14 @@ import {
TimeSlotSource,
MembershipStatus,
BookingStatus,
getDefaultTimeSlots,
} from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return a Date whose JS getDay() maps to the given ISO weekday (1=Mon…7=Sun) */
function dateForIsoWeekday(isoWeekday: number): Date {
const base = new Date('2026-04-06T00:00:00Z') // Monday
const d = new Date(base)
d.setDate(base.getDate() + (isoWeekday - 1))
return d
}
const makeTemplate = (overrides: Record<string, unknown> = {}) => ({
id: 'tpl-1',
dayOfWeek: 1,
startTime: '09:00',
endTime: '10:00',
capacity: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
weekTemplate: {
findMany: jest.fn(),
},
timeSlot: {
createMany: jest.fn(),
updateMany: jest.fn(),
@@ -77,26 +51,12 @@ describe('SlotGeneratorService', () => {
// -------------------------------------------------------------------------
describe('generateSlots', () => {
it('returns 0 when there are no active templates', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([])
it('creates slots for every day using the default schedule (14 slots per day)', async () => {
const defaultSlots = getDefaultTimeSlots()
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 })
const count = await service.generateSlots(7)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('creates correct number of slots from templates', async () => {
// 2 templates, both for Monday (ISO 1)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-1', dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }),
makeTemplate({ id: 'tpl-2', dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
// Use 7 days — will hit exactly one Monday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data, skipDuplicates } =
mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
@@ -104,23 +64,30 @@ describe('SlotGeneratorService', () => {
skipDuplicates: boolean
}
expect(skipDuplicates).toBe(true)
// Both templates should appear in the batch (may include more days)
const mondaySlots = (
data as Array<{ startTime: string; source: TimeSlotSource }>
).filter(
(s) => s.startTime === '09:00' || s.startTime === '10:00',
)
expect(mondaySlots.length).toBeGreaterThanOrEqual(2)
expect(count).toBe(2)
// 7 days × 14 slots per day = 98
expect(data).toHaveLength(defaultSlots.length * 7)
expect(count).toBe(defaultSlots.length * 7)
})
it('creates 14 slots per day (08:00-22:00 hourly)', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; endTime: string }>
}
expect(data).toHaveLength(14)
expect(data[0].startTime).toBe('08:00')
expect(data[0].endTime).toBe('09:00')
expect(data[13].startTime).toBe('21:00')
expect(data[13].endTime).toBe('22:00')
})
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ dayOfWeek: 1 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
await service.generateSlots(7)
await service.generateSlots(1)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
@@ -128,54 +95,17 @@ describe('SlotGeneratorService', () => {
expect(call.skipDuplicates).toBe(true)
})
it('maps Sunday (JS getDay()=0) to ISO weekday 7', async () => {
// Template for Sunday (ISO 7)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-sun', dayOfWeek: 7, startTime: '08:00', endTime: '09:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
it('sets source to TEMPLATE for all generated slots', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
// 7 days will cover exactly one Sunday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; source: TimeSlotSource }>
}
const sundaySlots = data.filter((s) => s.startTime === '08:00')
expect(sundaySlots.length).toBeGreaterThanOrEqual(1)
expect(sundaySlots[0].source).toBe(TimeSlotSource.TEMPLATE)
expect(count).toBe(1)
})
it('maps Monday (JS getDay()=1) to ISO weekday 1', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-mon', dayOfWeek: 1, startTime: '07:00', endTime: '08:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string }>
data: Array<{ source: TimeSlotSource }>
}
const mondaySlots = data.filter((s) => s.startTime === '07:00')
expect(mondaySlots.length).toBeGreaterThanOrEqual(1)
})
it('attaches templateId from the matching template', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-xyz', dayOfWeek: 2 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ templateId: string }>
for (const slot of data) {
expect(slot.source).toBe(TimeSlotSource.TEMPLATE)
}
const tuesdaySlots = data.filter((s) => s.templateId === 'tpl-xyz')
expect(tuesdaySlots.length).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -6,14 +6,10 @@ import {
BookingStatus,
SLOT_GENERATION_DAYS,
DEFAULT_SLOT_CAPACITY,
getDefaultTimeSlots,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
function toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/** Build a UTC Date for midnight of a local calendar date */
function toUtcMidnight(date: Date): Date {
const d = new Date(date)
@@ -28,20 +24,14 @@ export class SlotGeneratorService {
constructor(private readonly prisma: PrismaService) {}
/**
* Generate time slots for the next `daysAhead` days based on active
* WeekTemplates. Uses `createMany` with `skipDuplicates` so re-runs are safe.
* Generate time slots for the next `daysAhead` days based on the fixed
* default schedule (Mon-Sun, 08:00-22:00 hourly).
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
*
* @returns Number of newly created slots
*/
async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> {
const templates = await this.prisma.weekTemplate.findMany({
where: { isActive: true },
})
if (templates.length === 0) {
this.logger.log('No active week templates found skipping slot generation')
return 0
}
const defaultSlots = getDefaultTimeSlots()
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
@@ -53,27 +43,19 @@ export class SlotGeneratorService {
endTime: string
capacity: number
source: TimeSlotSource
templateId: string
}> = []
for (let offset = 0; offset < daysAhead; offset++) {
const target = new Date(tomorrow)
target.setDate(target.getDate() + offset)
const isoWeekday = toIsoWeekday(target.getDay())
const matchingTemplates = templates.filter(
(t) => t.dayOfWeek === isoWeekday,
)
for (const template of matchingTemplates) {
for (const slot of defaultSlots) {
slotsToCreate.push({
date: toUtcMidnight(target),
startTime: template.startTime,
endTime: template.endTime,
capacity: template.capacity ?? DEFAULT_SLOT_CAPACITY,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.TEMPLATE,
templateId: template.id,
})
}
}

View File

@@ -19,7 +19,6 @@ import { TimeSlotService } from './time-slot.service'
import { SlotGeneratorService } from './slot-generator.service'
import { QuerySlotsDto } from './dto/query-slots.dto'
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
// ---------------------------------------------------------------------------
@@ -61,18 +60,6 @@ export class AdminTimeSlotController {
private readonly slotGeneratorService: SlotGeneratorService,
) {}
// Week template management
@Get('week-template')
getWeekTemplates() {
return this.timeSlotService.getWeekTemplates()
}
@Put('week-template')
replaceWeekTemplates(@Body() dto: UpdateWeekTemplateDto) {
return this.timeSlotService.replaceWeekTemplates(dto.templates)
}
// Manual slot management
@Post('time-slot/manual')

View File

@@ -1,5 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY, getDefaultTimeSlots } from '@mp-pilates/shared'
import { TimeSlotSource } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
@@ -144,51 +144,12 @@ export class TimeSlotService {
})
}
async getWeekTemplates() {
return this.prisma.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
}
async replaceWeekTemplates(
items: Array<{
dayOfWeek: number
startTime: string
endTime: string
capacity?: number
isActive?: boolean
}>,
) {
return this.prisma.$transaction(async (tx) => {
await tx.weekTemplate.deleteMany()
await tx.weekTemplate.createMany({
data: items.map((item) => ({
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
endTime: item.endTime,
capacity: item.capacity ?? DEFAULT_SLOT_CAPACITY,
isActive: item.isActive ?? true,
})),
})
return tx.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
})
}
// ── Schedule preview & publish ──────────────────────────────
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
private toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/**
* Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from active WeekTemplates (isPublished: false).
* Otherwise → derive from the fixed default schedule (isPublished: false).
*/
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date)
@@ -216,23 +177,19 @@ export class TimeSlotService {
}))
}
// 2. No existing slots — derive from WeekTemplate
const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay())
const templates = await this.prisma.weekTemplate.findMany({
where: { dayOfWeek: isoWeekday, isActive: true },
orderBy: { startTime: 'asc' },
})
// 2. No existing slots — use fixed default schedule
const defaultSlots = getDefaultTimeSlots()
return templates.map((tpl) => ({
return defaultSlots.map((slot) => ({
id: null,
date: date,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacity: tpl.capacity,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.TEMPLATE,
templateId: tpl.id,
templateId: null,
isPublished: false,
}))
}

View File

@@ -2,8 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "../..",
"module": "CommonJS",
"moduleResolution": "node",
"ignoreDeprecations": "5.0",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2021",

File diff suppressed because one or more lines are too long