diff --git a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts index a437b92..9d038a9 100644 --- a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts +++ b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts @@ -72,21 +72,25 @@ describe('SlotGeneratorService', () => { expect(count).toBe(defaultSlots.length * 7) }) - it('creates 13 slots per day (08:00-09:00, then 09:30-21:30 hourly)', async () => { - mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 }) + it('creates 12 slots per day (morning on the hour, afternoon from 13:30)', async () => { + mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 }) await service.generateSlots(1) const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { data: Array<{ startTime: string; endTime: string }> } - expect(data).toHaveLength(13) + expect(data).toHaveLength(12) expect(data[0].startTime).toBe('08:00') expect(data[0].endTime).toBe('09:00') - expect(data[1].startTime).toBe('09:30') - expect(data[1].endTime).toBe('10:30') - expect(data[12].startTime).toBe('20:30') - expect(data[12].endTime).toBe('21:30') + expect(data[1].startTime).toBe('09:00') + expect(data[1].endTime).toBe('10:00') + expect(data[3].startTime).toBe('11:00') + expect(data[3].endTime).toBe('12:00') + expect(data[4].startTime).toBe('13:30') + expect(data[4].endTime).toBe('14:30') + expect(data[11].startTime).toBe('20:30') + expect(data[11].endTime).toBe('21:30') }) it('passes skipDuplicates: true to handle existing date+time combinations', async () => { @@ -101,7 +105,7 @@ describe('SlotGeneratorService', () => { }) it('sets source to TEMPLATE for all generated slots', async () => { - mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 }) + mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 }) await service.generateSlots(1) diff --git a/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts b/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts index b43d3f7..0c645dd 100644 --- a/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts +++ b/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts @@ -7,6 +7,7 @@ import { TimeSlotSource, BookingStatus, DEFAULT_SLOT_CAPACITY, + getDefaultTimeSlots, } from '@mp-pilates/shared' // --------------------------------------------------------------------------- @@ -508,6 +509,102 @@ describe('TimeSlotService', () => { expect(createData.capacity).toBe(4) }) + it('updates materialized template rows when ghost preview is published without ids', async () => { + const defaultSlots = getDefaultTimeSlots() + const materialized = defaultSlots.map((slot, index) => + makeSlot({ + id: `slot-${index}`, + startTime: slot.startTime, + endTime: slot.endTime, + source: TimeSlotSource.TEMPLATE, + }), + ) + mockPrisma.timeSlot.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(materialized) + .mockResolvedValueOnce(materialized) + mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length }) + mockPrisma.timeSlot.update.mockImplementation(async (args: { where: { id: string } }) => { + const row = materialized.find((s) => s.id === args.where.id)! + return { ...row } + }) + + await service.publishDaySlots({ + date, + slots: defaultSlots.map((slot) => ({ + startTime: slot.startTime, + endTime: slot.endTime, + capacity: DEFAULT_SLOT_CAPACITY, + })), + }) + + expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1) + expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled() + expect(mockPrisma.timeSlot.update).toHaveBeenCalledTimes(defaultSlots.length) + }) + + it('reopens an existing row by time when admin re-adds a slot without existingSlotId', async () => { + const existing = [ + makeSlot({ + id: 'slot-open', + startTime: '09:30', + endTime: '10:30', + status: TimeSlotStatus.OPEN, + }), + ] + mockPrisma.timeSlot.findMany + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(existing) + mockPrisma.timeSlot.update.mockResolvedValueOnce({ + ...existing[0], + status: TimeSlotStatus.OPEN, + source: TimeSlotSource.MANUAL, + capacity: 3, + }) + + await service.publishDaySlots({ + date, + slots: [{ startTime: '09:30', endTime: '10:30', capacity: 3 }], + }) + + expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled() + const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as { + data: { status: string; source: string; capacity: number } + }).data + expect(updateData.status).toBe(TimeSlotStatus.OPEN) + expect(updateData.source).toBe(TimeSlotSource.MANUAL) + expect(updateData.capacity).toBe(3) + }) + + it('reopens a CLOSED row when admin publishes the same time window as a new slot', async () => { + const existing = [ + makeSlot({ + id: 'slot-closed', + startTime: '11:00', + endTime: '12:00', + status: TimeSlotStatus.CLOSED, + }), + ] + mockPrisma.timeSlot.findMany + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(existing) + mockPrisma.timeSlot.update.mockResolvedValueOnce({ + ...existing[0], + status: TimeSlotStatus.OPEN, + }) + + await service.publishDaySlots({ + date, + slots: [{ startTime: '11:00', endTime: '12:00', capacity: 2 }], + }) + + expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled() + const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as { + data: { status: string } + }).data + expect(updateData.status).toBe(TimeSlotStatus.OPEN) + }) + it('returns the final state of the day ordered by startTime', async () => { const finalState = [ makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }), diff --git a/packages/server/src/time-slot/slot-generator.service.ts b/packages/server/src/time-slot/slot-generator.service.ts index a20f4e6..22609fd 100644 --- a/packages/server/src/time-slot/slot-generator.service.ts +++ b/packages/server/src/time-slot/slot-generator.service.ts @@ -37,7 +37,7 @@ export class SlotGeneratorService { /** * Generate time slots for the next `daysAhead` days based on the fixed - * default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly). + * default schedule (morning on the hour 08:00–12:00, afternoon from 13:30 hourly to 21:30). * * Behaviour: * - Days that already have any TimeSlot row (any status, including CLOSED) diff --git a/packages/server/src/time-slot/time-slot.service.ts b/packages/server/src/time-slot/time-slot.service.ts index a97bfe0..462f2f5 100644 --- a/packages/server/src/time-slot/time-slot.service.ts +++ b/packages/server/src/time-slot/time-slot.service.ts @@ -18,6 +18,10 @@ export class TimeSlotService { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999)) } + private slotTimeKey(startTime: string, endTime: string): string { + return `${startTime}\0${endTime}` + } + private mapToWithBookingStatus( slot: { id: string @@ -276,6 +280,9 @@ export class TimeSlotService { } const existingMap = new Map(existing.map((s) => [s.id, s])) + const existingByTime = new Map( + existing.map((s) => [this.slotTimeKey(s.startTime, s.endTime), s]), + ) const keptIds = new Set() const results: Array<{ @@ -291,29 +298,44 @@ export class TimeSlotService { // 3. Process each slot in the request. for (const item of dto.slots) { + let resolvedId: string | null = null if (item.existingSlotId && existingMap.has(item.existingSlotId)) { - // Update existing slot. Never reduce capacity below bookedCount. - const existingSlot = existingMap.get(item.existingSlotId)! + resolvedId = item.existingSlotId + } else { + const byTime = existingByTime.get(this.slotTimeKey(item.startTime, item.endTime)) + if (byTime) { + resolvedId = byTime.id + } + } + + if (resolvedId) { + const existingSlot = existingMap.get(resolvedId)! const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount) + const matchedByExplicitId = + Boolean(item.existingSlotId) && item.existingSlotId === resolvedId const updated = await tx.timeSlot.update({ - where: { id: item.existingSlotId }, + where: { id: resolvedId }, data: { startTime: item.startTime, endTime: item.endTime, capacity: safeCapacity, - // Existing status is preserved: CLOSED rows stay CLOSED, so an - // admin's "rest day" intent cannot be undone by a no-op - // re-publish. Reopening must go through a dedicated endpoint. + // Rows referenced by id keep status (CLOSED stays CLOSED on re-publish). + // Rows matched only by time (ghost preview, re-added slot) are published OPEN. + ...(!matchedByExplicitId + ? { + status: TimeSlotStatus.OPEN, + source: TimeSlotSource.MANUAL, + } + : {}), }, }) - keptIds.add(item.existingSlotId) + keptIds.add(resolvedId) results.push(updated) } else { - // Create a new slot. const created = await tx.timeSlot.create({ data: { - date: parsedDate, + date: this.toDateOfDay(parsedDate), startTime: item.startTime, endTime: item.endTime, capacity: item.capacity, @@ -321,6 +343,9 @@ export class TimeSlotService { status: TimeSlotStatus.OPEN, }, }) + keptIds.add(created.id) + existingMap.set(created.id, created) + existingByTime.set(this.slotTimeKey(created.startTime, created.endTime), created) results.push(created) } } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8d2d451..23498b5 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -15,11 +15,15 @@ export const DEFAULT_SLOT_CAPACITY = 1 /** 自动生成时段的天数范围 */ export const SLOT_GENERATION_DAYS = 14 -/** 默认排课时间表:第一节 08:00-09:00,之后从 09:30 起每小时一节至 21:30 */ +/** 默认排课:上午整点 08:00–12:00(8–9、9–10、10–11、11–12),下午 13:30 起每小时至 21:30 结束 */ export const DEFAULT_SCHEDULE_START_TIME = '08:00' export const DEFAULT_SCHEDULE_END_TIME = '21:30' -/** 第一节课后的半点课表起点 */ -export const DEFAULT_SCHEDULE_OFFSET_START_TIME = '09:30' +/** 上午时段结束时刻(不含):最后一节为 11:00–12:00 */ +export const DEFAULT_SCHEDULE_MORNING_END_TIME = '12:00' +/** 下午时段起点:13:30–14:30 起,之后每小时一节 */ +export const DEFAULT_SCHEDULE_AFTERNOON_START_TIME = '13:30' +/** @deprecated 使用 DEFAULT_SCHEDULE_AFTERNOON_START_TIME */ +export const DEFAULT_SCHEDULE_OFFSET_START_TIME = DEFAULT_SCHEDULE_AFTERNOON_START_TIME /** 排课时间选择步长:仅整点与半点 */ export const SCHEDULE_MINUTE_STEP = 30 @@ -37,14 +41,13 @@ function formatMinutesToTime(totalMinutes: number): string { /** 生成默认时段列表 (startTime, endTime) */ export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> { const duration = 60 - const slots: Array<{ startTime: string; endTime: string }> = [ - { startTime: DEFAULT_SCHEDULE_START_TIME, endTime: '09:00' }, - ] - const endMinutes = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME) + const slots: Array<{ startTime: string; endTime: string }> = [] + const morningEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_MORNING_END_TIME) + const dayEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME) for ( - let t = parseTimeToMinutes(DEFAULT_SCHEDULE_OFFSET_START_TIME); - t + duration <= endMinutes; + let t = parseTimeToMinutes(DEFAULT_SCHEDULE_START_TIME); + t + duration <= morningEnd; t += duration ) { slots.push({ @@ -52,6 +55,18 @@ export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: strin endTime: formatMinutesToTime(t + duration), }) } + + for ( + let t = parseTimeToMinutes(DEFAULT_SCHEDULE_AFTERNOON_START_TIME); + t + duration <= dayEnd; + t += duration + ) { + slots.push({ + startTime: formatMinutesToTime(t), + endTime: formatMinutesToTime(t + duration), + }) + } + return slots } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d81e0ed..89b92f7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -38,6 +38,8 @@ export { SLOT_GENERATION_DAYS, DEFAULT_SCHEDULE_START_TIME, DEFAULT_SCHEDULE_END_TIME, + DEFAULT_SCHEDULE_MORNING_END_TIME, + DEFAULT_SCHEDULE_AFTERNOON_START_TIME, DEFAULT_SCHEDULE_OFFSET_START_TIME, SCHEDULE_MINUTE_STEP, getDefaultTimeSlots,