fix: 老师代约只允许已发布课表,去掉临时加开时段
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1318,100 +1318,5 @@ describe('BookingService', () => {
|
||||
)
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses an existing slot when arranging by date and time', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx)
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
})
|
||||
|
||||
expect(tx.timeSlot.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
date_startTime_endTime: {
|
||||
date: new Date('2099-12-31T00:00:00.000Z'),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a manual slot when arranging a missing date and time', async () => {
|
||||
const tx = buildTxMock()
|
||||
const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' }
|
||||
tx.timeSlot.findUnique
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 })
|
||||
tx.timeSlot.create.mockResolvedValue(createdSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlotId: createdSlot.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlotId: createdSlot.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: createdSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue({
|
||||
...mockStudioConfig,
|
||||
name: 'FocusCore Pilates',
|
||||
})
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
})
|
||||
|
||||
expect(tx.timeSlot.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
source: 'MANUAL',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects custom slots whose end time is not after start time', async () => {
|
||||
const tx = buildTxMock()
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '23:00',
|
||||
endTime: '00:00',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException)
|
||||
expect(tx.timeSlot.findUnique).not.toHaveBeenCalled()
|
||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { Booking, Membership, Prisma, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import {
|
||||
BookingStatus,
|
||||
CardTypeCategory,
|
||||
DEFAULT_SLOT_CAPACITY,
|
||||
MembershipStatus,
|
||||
TimeSlotSource,
|
||||
TimeSlotStatus,
|
||||
type TeachingScheduleSlot,
|
||||
} from '@mp-pilates/shared'
|
||||
@@ -51,23 +49,6 @@ function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
function normalizeClock(time: string): string {
|
||||
return time.slice(0, 5)
|
||||
}
|
||||
|
||||
function clockToMinutes(time: string): number {
|
||||
const [hours, minutes] = normalizeClock(time).split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
function parseSlotDate(date: string): Date {
|
||||
const parsed = new Date(`${date}T00:00:00.000Z`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException('Invalid date')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// ─── Service ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Injectable()
|
||||
@@ -298,7 +279,12 @@ export class BookingService {
|
||||
dto: AdminArrangeBookingDto,
|
||||
): Promise<BookingWithRelations> {
|
||||
const booking = await this.prisma.$transaction(async (tx) => {
|
||||
const timeSlot = await this.resolveArrangeSlot(tx, dto)
|
||||
const timeSlot = await tx.timeSlot.findUnique({
|
||||
where: { id: dto.timeSlotId },
|
||||
})
|
||||
if (!timeSlot) {
|
||||
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
|
||||
}
|
||||
if (timeSlot.status !== TimeSlotStatus.OPEN) {
|
||||
throw new BadRequestException(
|
||||
`TimeSlot is not available (status: ${timeSlot.status})`,
|
||||
@@ -433,55 +419,6 @@ export class BookingService {
|
||||
return arrangedBooking
|
||||
}
|
||||
|
||||
private async resolveArrangeSlot(
|
||||
tx: Prisma.TransactionClient,
|
||||
dto: AdminArrangeBookingDto,
|
||||
): Promise<TimeSlot> {
|
||||
if (dto.timeSlotId) {
|
||||
const slot = await tx.timeSlot.findUnique({
|
||||
where: { id: dto.timeSlotId },
|
||||
})
|
||||
if (!slot) {
|
||||
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
if (!dto.date || !dto.startTime || !dto.endTime) {
|
||||
throw new BadRequestException('timeSlotId or date+startTime+endTime is required')
|
||||
}
|
||||
|
||||
const startTime = normalizeClock(dto.startTime)
|
||||
const endTime = normalizeClock(dto.endTime)
|
||||
if (clockToMinutes(endTime) <= clockToMinutes(startTime)) {
|
||||
throw new BadRequestException('End time must be after start time')
|
||||
}
|
||||
|
||||
const date = parseSlotDate(dto.date)
|
||||
const existing = await tx.timeSlot.findUnique({
|
||||
where: {
|
||||
date_startTime_endTime: {
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return tx.timeSlot.create({
|
||||
data: {
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
|
||||
source: TimeSlotSource.MANUAL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||
|
||||
async completeBooking(
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator'
|
||||
import { IsUUID } from 'class-validator'
|
||||
|
||||
export class AdminArrangeBookingDto {
|
||||
@IsUUID()
|
||||
@@ -16,25 +7,6 @@ export class AdminArrangeBookingDto {
|
||||
@IsUUID()
|
||||
membershipId!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
timeSlotId?: string
|
||||
|
||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
||||
@IsDateString()
|
||||
date?: string
|
||||
|
||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
||||
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
|
||||
startTime?: string
|
||||
|
||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
||||
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
|
||||
endTime?: string
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
capacity?: number
|
||||
timeSlotId!: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user