Compare commits
2 Commits
richarjian
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c6d2092a | ||
|
|
b72357030d |
@@ -111,7 +111,7 @@
|
||||
<text class="action-btn-text action-btn-text--ghost">婉拒</text>
|
||||
</view>
|
||||
<view class="action-btn action-btn--primary" @tap.stop="handleConfirm(booking)">
|
||||
<text class="action-btn-text">确认预约</text>
|
||||
<text class="action-btn-text action-btn-text--light">确认预约</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -982,11 +982,19 @@ $cream: #efe4cf; // base cream tint
|
||||
&--primary {
|
||||
background: $sage;
|
||||
border-color: $sage;
|
||||
|
||||
.action-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
background: $ink;
|
||||
border-color: $ink;
|
||||
|
||||
.action-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user