fix(server): 修复排课发布唯一约束冲突并更正默认时段模板

发布时按日期+起止时间匹配已有时段,避免幽灵预览重复 create;默认课表改为上午整点与下午 13:30 起每小时至 21:30。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-14 14:26:43 +08:00
parent d91282ecbd
commit b72357030d
6 changed files with 170 additions and 27 deletions

View File

@@ -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)

View File

@@ -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' }),

View File

@@ -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:0012:00, afternoon from 13:30 hourly to 21:30).
*
* Behaviour:
* - Days that already have any TimeSlot row (any status, including CLOSED)

View File

@@ -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<string>()
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)
}
}

View File

@@ -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:0012:0089、910、1011、1112下午 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:0012:00 */
export const DEFAULT_SCHEDULE_MORNING_END_TIME = '12:00'
/** 下午时段起点13:3014: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
}

View File

@@ -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,