feat: 优化排课管理
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user