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)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user