fix(server): support rebooking cancelled slots

This commit is contained in:
richarjiang
2026-04-13 17:50:14 +08:00
parent f78cdcc9d1
commit d45a5b2c14
3 changed files with 113 additions and 36 deletions

View File

@@ -257,7 +257,7 @@ describe('BookingService', () => {
bookingContent: '预约已确认', bookingContent: '预约已确认',
bookingTime: '2099-12-31 09:00', bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates', courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00', bookingEndTime: '2099-12-31 10:00',
}) })
}) })
}) })
@@ -270,7 +270,7 @@ describe('BookingService', () => {
it('creates booking in pending confirmation status', async () => { it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) // no duplicate tx.booking.findUnique.mockResolvedValue(null) // no duplicate
tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({ tx.booking.create.mockResolvedValue({
...mockConfirmedBooking, ...mockConfirmedBooking,
@@ -312,7 +312,7 @@ describe('BookingService', () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot) tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
tx.booking.findFirst.mockResolvedValue(null) tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({ tx.booking.create.mockResolvedValue({
...mockConfirmedBooking, ...mockConfirmedBooking,
@@ -345,7 +345,7 @@ describe('BookingService', () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockDurationMembership) tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id }) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
@@ -368,7 +368,7 @@ describe('BookingService', () => {
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => { it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes) tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
tx.booking.create.mockResolvedValue({ tx.booking.create.mockResolvedValue({
...mockConfirmedBooking, ...mockConfirmedBooking,
@@ -394,7 +394,7 @@ describe('BookingService', () => {
it('sends admin booking created subscription message to admins with remaining count', async () => { it('sends admin booking created subscription message to admins with remaining count', async () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({ tx.booking.create.mockResolvedValue({
...mockConfirmedBooking, ...mockConfirmedBooking,
@@ -435,7 +435,7 @@ describe('BookingService', () => {
bookingContent: 'Alice已预约', bookingContent: 'Alice已预约',
bookingTime: '2099-12-31 09:00', bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates', courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00', bookingEndTime: '2099-12-31 10:00',
}) })
}) })
@@ -455,7 +455,7 @@ describe('BookingService', () => {
it('throws ConflictException on duplicate booking (same user + slot)', async () => { it('throws ConflictException on duplicate booking (same user + slot)', async () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(mockConfirmedBooking) // duplicate exists tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -467,7 +467,7 @@ describe('BookingService', () => {
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => { it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership) tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -493,7 +493,7 @@ describe('BookingService', () => {
const tx = buildTxMock() const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(otherUserMembership) tx.membership.findUnique.mockResolvedValue(otherUserMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -502,6 +502,65 @@ describe('BookingService', () => {
ForbiddenException, ForbiddenException,
) )
}) })
it('reuses a cancelled booking record when booking the same slot again', async () => {
const cancelledBooking = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
membershipId: 'mem-old-001',
cancelledAt: new Date('2099-12-30T00:00:00Z'),
confirmedAt: new Date('2099-12-29T00:00:00Z'),
completedAt: null,
operatorId: 'admin-001',
}
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(cancelledBooking)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.update.mockResolvedValue({
...cancelledBooking,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
operatorId: null,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith({
where: { id: MOCK_BOOKING_ID },
data: {
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
bookingId: MOCK_BOOKING_ID,
fromStatus: BookingStatus.CANCELLED,
toStatus: BookingStatus.PENDING_CONFIRMATION,
remark: '学员重新发起预约',
}),
}),
)
})
}) })
// ─── cancelBooking ──────────────────────────────────────────────────────── // ─── cancelBooking ────────────────────────────────────────────────────────

View File

@@ -72,15 +72,20 @@ export class BookingService {
) )
} }
// 2. Check for active (PENDING_CONFIRMATION or CONFIRMED) booking — cancelled bookings don't block re-booking // 2. Find existing booking record for this user + slot.
const existing = await tx.booking.findFirst({ // The DB keeps a unique key on this pair, so cancelled bookings must be revived instead of recreated.
const existing = await tx.booking.findUnique({
where: { where: {
userId, userId_timeSlotId: {
timeSlotId: dto.timeSlotId, userId,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, timeSlotId: dto.timeSlotId,
},
}, },
}) })
if (existing) { if (
existing &&
(existing.status === 'PENDING_CONFIRMATION' || existing.status === 'CONFIRMED')
) {
throw new ConflictException('You have already booked this time slot') throw new ConflictException('You have already booked this time slot')
} }
@@ -115,24 +120,40 @@ export class BookingService {
} }
} }
// 5. Create booking with PENDING_CONFIRMATION status // 5. Create booking or revive a previously cancelled booking.
const newBooking = await tx.booking.create({ const newBooking = existing?.status === BookingStatus.CANCELLED
data: { ? await tx.booking.update({
userId, where: { id: existing.id },
timeSlotId: dto.timeSlotId, data: {
membershipId: dto.membershipId, membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION, status: BookingStatus.PENDING_CONFIRMATION,
}, cancelledAt: null,
}) confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
: await tx.booking.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
},
})
// 6. Record status history: created // 6. Record status history: created or re-created from cancelled state.
await tx.bookingStatusHistory.create({ await tx.bookingStatusHistory.create({
data: { data: {
bookingId: newBooking.id, bookingId: newBooking.id,
fromStatus: null, fromStatus: existing?.status === BookingStatus.CANCELLED
? BookingStatus.CANCELLED
: null,
toStatus: BookingStatus.PENDING_CONFIRMATION, toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: userId, operatorId: userId,
remark: '学员发起预约', remark: existing?.status === BookingStatus.CANCELLED
? '学员重新发起预约'
: '学员发起预约',
}, },
}) })
@@ -588,7 +609,6 @@ export class BookingService {
const studio = await this.studioService.getInfo() const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}` const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({ await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid, openid: user.openid,
@@ -596,7 +616,7 @@ export class BookingService {
bookingContent: '预约已确认', bookingContent: '预约已确认',
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`, bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程', courseName: studio.name || '普拉提课程',
bookingPeriod: `${dateLabel} ${periodLabel}`, bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
}) })
} catch (error) { } catch (error) {
console.error('Send booking confirmed subscription message failed:', error) console.error('Send booking confirmed subscription message failed:', error)
@@ -628,8 +648,6 @@ export class BookingService {
const studio = await this.studioService.getInfo() const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}` const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
const studentLabel = this.buildAdminBookingStudentLabel(student) const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled( await Promise.allSettled(
admins admins
@@ -640,7 +658,7 @@ export class BookingService {
bookingContent: `${studentLabel}已预约`.slice(0, 20), bookingContent: `${studentLabel}已预约`.slice(0, 20),
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`, bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程', courseName: studio.name || '普拉提课程',
bookingPeriod: `${dateLabel} ${periodLabel}`, bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})), })),
) )
} catch (error) { } catch (error) {

View File

@@ -13,7 +13,7 @@ interface BookingConfirmedTemplatePayload {
readonly bookingContent: string readonly bookingContent: string
readonly bookingTime: string readonly bookingTime: string
readonly courseName: string readonly courseName: string
readonly bookingPeriod: string readonly bookingEndTime: string
} }
interface AdminBookingCreatedTemplatePayload { interface AdminBookingCreatedTemplatePayload {
@@ -22,7 +22,7 @@ interface AdminBookingCreatedTemplatePayload {
readonly bookingContent: string readonly bookingContent: string
readonly bookingTime: string readonly bookingTime: string
readonly courseName: string readonly courseName: string
readonly bookingPeriod: string readonly bookingEndTime: string
} }
interface WechatAccessTokenResponse { interface WechatAccessTokenResponse {
@@ -188,7 +188,7 @@ export class SubscriptionMessageService {
thing1: { value: params.payload.bookingContent.slice(0, 20) }, thing1: { value: params.payload.bookingContent.slice(0, 20) },
time2: { value: params.payload.bookingTime.slice(0, 20) }, time2: { value: params.payload.bookingTime.slice(0, 20) },
thing25: { value: params.payload.courseName.slice(0, 20) }, thing25: { value: params.payload.courseName.slice(0, 20) },
time35: { value: params.payload.bookingPeriod.slice(0, 20) }, time35: { value: params.payload.bookingEndTime.slice(0, 20) },
}, },
} }