fix(server): 修复排课发布唯一约束冲突并更正默认时段模板
发布时按日期+起止时间匹配已有时段,避免幽灵预览重复 create;默认课表改为上午整点与下午 13:30 起每小时至 21:30。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -72,21 +72,25 @@ describe('SlotGeneratorService', () => {
|
|||||||
expect(count).toBe(defaultSlots.length * 7)
|
expect(count).toBe(defaultSlots.length * 7)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates 13 slots per day (08:00-09:00, then 09:30-21:30 hourly)', async () => {
|
it('creates 12 slots per day (morning on the hour, afternoon from 13:30)', async () => {
|
||||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
|
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 })
|
||||||
|
|
||||||
await service.generateSlots(1)
|
await service.generateSlots(1)
|
||||||
|
|
||||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||||
data: Array<{ startTime: string; endTime: string }>
|
data: Array<{ startTime: string; endTime: string }>
|
||||||
}
|
}
|
||||||
expect(data).toHaveLength(13)
|
expect(data).toHaveLength(12)
|
||||||
expect(data[0].startTime).toBe('08:00')
|
expect(data[0].startTime).toBe('08:00')
|
||||||
expect(data[0].endTime).toBe('09:00')
|
expect(data[0].endTime).toBe('09:00')
|
||||||
expect(data[1].startTime).toBe('09:30')
|
expect(data[1].startTime).toBe('09:00')
|
||||||
expect(data[1].endTime).toBe('10:30')
|
expect(data[1].endTime).toBe('10:00')
|
||||||
expect(data[12].startTime).toBe('20:30')
|
expect(data[3].startTime).toBe('11:00')
|
||||||
expect(data[12].endTime).toBe('21:30')
|
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 () => {
|
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 () => {
|
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)
|
await service.generateSlots(1)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
TimeSlotSource,
|
TimeSlotSource,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
DEFAULT_SLOT_CAPACITY,
|
DEFAULT_SLOT_CAPACITY,
|
||||||
|
getDefaultTimeSlots,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -508,6 +509,102 @@ describe('TimeSlotService', () => {
|
|||||||
expect(createData.capacity).toBe(4)
|
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 () => {
|
it('returns the final state of the day ordered by startTime', async () => {
|
||||||
const finalState = [
|
const finalState = [
|
||||||
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),
|
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class SlotGeneratorService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate time slots for the next `daysAhead` days based on the fixed
|
* 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:
|
* Behaviour:
|
||||||
* - Days that already have any TimeSlot row (any status, including CLOSED)
|
* - Days that already have any TimeSlot row (any status, including CLOSED)
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export class TimeSlotService {
|
|||||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999))
|
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(
|
private mapToWithBookingStatus(
|
||||||
slot: {
|
slot: {
|
||||||
id: string
|
id: string
|
||||||
@@ -276,6 +280,9 @@ export class TimeSlotService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingMap = new Map(existing.map((s) => [s.id, s]))
|
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 keptIds = new Set<string>()
|
||||||
|
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
@@ -291,29 +298,44 @@ export class TimeSlotService {
|
|||||||
|
|
||||||
// 3. Process each slot in the request.
|
// 3. Process each slot in the request.
|
||||||
for (const item of dto.slots) {
|
for (const item of dto.slots) {
|
||||||
|
let resolvedId: string | null = null
|
||||||
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
|
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
|
||||||
// Update existing slot. Never reduce capacity below bookedCount.
|
resolvedId = item.existingSlotId
|
||||||
const existingSlot = existingMap.get(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 safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
|
||||||
|
const matchedByExplicitId =
|
||||||
|
Boolean(item.existingSlotId) && item.existingSlotId === resolvedId
|
||||||
|
|
||||||
const updated = await tx.timeSlot.update({
|
const updated = await tx.timeSlot.update({
|
||||||
where: { id: item.existingSlotId },
|
where: { id: resolvedId },
|
||||||
data: {
|
data: {
|
||||||
startTime: item.startTime,
|
startTime: item.startTime,
|
||||||
endTime: item.endTime,
|
endTime: item.endTime,
|
||||||
capacity: safeCapacity,
|
capacity: safeCapacity,
|
||||||
// Existing status is preserved: CLOSED rows stay CLOSED, so an
|
// Rows referenced by id keep status (CLOSED stays CLOSED on re-publish).
|
||||||
// admin's "rest day" intent cannot be undone by a no-op
|
// Rows matched only by time (ghost preview, re-added slot) are published OPEN.
|
||||||
// re-publish. Reopening must go through a dedicated endpoint.
|
...(!matchedByExplicitId
|
||||||
|
? {
|
||||||
|
status: TimeSlotStatus.OPEN,
|
||||||
|
source: TimeSlotSource.MANUAL,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
keptIds.add(item.existingSlotId)
|
keptIds.add(resolvedId)
|
||||||
results.push(updated)
|
results.push(updated)
|
||||||
} else {
|
} else {
|
||||||
// Create a new slot.
|
|
||||||
const created = await tx.timeSlot.create({
|
const created = await tx.timeSlot.create({
|
||||||
data: {
|
data: {
|
||||||
date: parsedDate,
|
date: this.toDateOfDay(parsedDate),
|
||||||
startTime: item.startTime,
|
startTime: item.startTime,
|
||||||
endTime: item.endTime,
|
endTime: item.endTime,
|
||||||
capacity: item.capacity,
|
capacity: item.capacity,
|
||||||
@@ -321,6 +343,9 @@ export class TimeSlotService {
|
|||||||
status: TimeSlotStatus.OPEN,
|
status: TimeSlotStatus.OPEN,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
keptIds.add(created.id)
|
||||||
|
existingMap.set(created.id, created)
|
||||||
|
existingByTime.set(this.slotTimeKey(created.startTime, created.endTime), created)
|
||||||
results.push(created)
|
results.push(created)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,15 @@ export const DEFAULT_SLOT_CAPACITY = 1
|
|||||||
/** 自动生成时段的天数范围 */
|
/** 自动生成时段的天数范围 */
|
||||||
export const SLOT_GENERATION_DAYS = 14
|
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_START_TIME = '08:00'
|
||||||
export const DEFAULT_SCHEDULE_END_TIME = '21:30'
|
export const DEFAULT_SCHEDULE_END_TIME = '21:30'
|
||||||
/** 第一节课后的半点课表起点 */
|
/** 上午时段结束时刻(不含):最后一节为 11:00–12:00 */
|
||||||
export const DEFAULT_SCHEDULE_OFFSET_START_TIME = '09:30'
|
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
|
export const SCHEDULE_MINUTE_STEP = 30
|
||||||
|
|
||||||
@@ -37,14 +41,13 @@ function formatMinutesToTime(totalMinutes: number): string {
|
|||||||
/** 生成默认时段列表 (startTime, endTime) */
|
/** 生成默认时段列表 (startTime, endTime) */
|
||||||
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
|
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
|
||||||
const duration = 60
|
const duration = 60
|
||||||
const slots: Array<{ startTime: string; endTime: string }> = [
|
const slots: Array<{ startTime: string; endTime: string }> = []
|
||||||
{ startTime: DEFAULT_SCHEDULE_START_TIME, endTime: '09:00' },
|
const morningEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_MORNING_END_TIME)
|
||||||
]
|
const dayEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME)
|
||||||
const endMinutes = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME)
|
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_OFFSET_START_TIME);
|
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_START_TIME);
|
||||||
t + duration <= endMinutes;
|
t + duration <= morningEnd;
|
||||||
t += duration
|
t += duration
|
||||||
) {
|
) {
|
||||||
slots.push({
|
slots.push({
|
||||||
@@ -52,6 +55,18 @@ export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: strin
|
|||||||
endTime: formatMinutesToTime(t + duration),
|
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
|
return slots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export {
|
|||||||
SLOT_GENERATION_DAYS,
|
SLOT_GENERATION_DAYS,
|
||||||
DEFAULT_SCHEDULE_START_TIME,
|
DEFAULT_SCHEDULE_START_TIME,
|
||||||
DEFAULT_SCHEDULE_END_TIME,
|
DEFAULT_SCHEDULE_END_TIME,
|
||||||
|
DEFAULT_SCHEDULE_MORNING_END_TIME,
|
||||||
|
DEFAULT_SCHEDULE_AFTERNOON_START_TIME,
|
||||||
DEFAULT_SCHEDULE_OFFSET_START_TIME,
|
DEFAULT_SCHEDULE_OFFSET_START_TIME,
|
||||||
SCHEDULE_MINUTE_STEP,
|
SCHEDULE_MINUTE_STEP,
|
||||||
getDefaultTimeSlots,
|
getDefaultTimeSlots,
|
||||||
|
|||||||
Reference in New Issue
Block a user