fix(server): support rebooking cancelled slots
This commit is contained in:
@@ -257,7 +257,7 @@ describe('BookingService', () => {
|
||||
bookingContent: '预约已确认',
|
||||
bookingTime: '2099-12-31 09:00',
|
||||
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 () => {
|
||||
const tx = buildTxMock()
|
||||
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.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
@@ -312,7 +312,7 @@ describe('BookingService', () => {
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
@@ -345,7 +345,7 @@ describe('BookingService', () => {
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
|
||||
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 () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
@@ -394,7 +394,7 @@ describe('BookingService', () => {
|
||||
it('sends admin booking created subscription message to admins with remaining count', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
@@ -435,7 +435,7 @@ describe('BookingService', () => {
|
||||
bookingContent: 'Alice已预约',
|
||||
bookingTime: '2099-12-31 09:00',
|
||||
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 () => {
|
||||
const tx = buildTxMock()
|
||||
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))
|
||||
|
||||
@@ -467,7 +467,7 @@ describe('BookingService', () => {
|
||||
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
@@ -493,7 +493,7 @@ describe('BookingService', () => {
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
@@ -502,6 +502,65 @@ describe('BookingService', () => {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -72,15 +72,20 @@ export class BookingService {
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Check for active (PENDING_CONFIRMATION or CONFIRMED) booking — cancelled bookings don't block re-booking
|
||||
const existing = await tx.booking.findFirst({
|
||||
// 2. Find existing booking record for this user + slot.
|
||||
// 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: {
|
||||
userId,
|
||||
timeSlotId: dto.timeSlotId,
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
userId_timeSlotId: {
|
||||
userId,
|
||||
timeSlotId: dto.timeSlotId,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (existing) {
|
||||
if (
|
||||
existing &&
|
||||
(existing.status === 'PENDING_CONFIRMATION' || existing.status === 'CONFIRMED')
|
||||
) {
|
||||
throw new ConflictException('You have already booked this time slot')
|
||||
}
|
||||
|
||||
@@ -115,24 +120,40 @@ export class BookingService {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create booking with PENDING_CONFIRMATION status
|
||||
const newBooking = await tx.booking.create({
|
||||
data: {
|
||||
userId,
|
||||
timeSlotId: dto.timeSlotId,
|
||||
membershipId: dto.membershipId,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
},
|
||||
})
|
||||
// 5. Create booking or revive a previously cancelled booking.
|
||||
const newBooking = existing?.status === BookingStatus.CANCELLED
|
||||
? await tx.booking.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
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({
|
||||
data: {
|
||||
bookingId: newBooking.id,
|
||||
fromStatus: null,
|
||||
fromStatus: existing?.status === BookingStatus.CANCELLED
|
||||
? BookingStatus.CANCELLED
|
||||
: null,
|
||||
toStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||
operatorId: userId,
|
||||
remark: '学员发起预约',
|
||||
remark: existing?.status === BookingStatus.CANCELLED
|
||||
? '学员重新发起预约'
|
||||
: '学员发起预约',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -588,7 +609,6 @@ export class BookingService {
|
||||
const studio = await this.studioService.getInfo()
|
||||
const bookingDate = booking.timeSlot.date
|
||||
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({
|
||||
openid: user.openid,
|
||||
@@ -596,7 +616,7 @@ export class BookingService {
|
||||
bookingContent: '预约已确认',
|
||||
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
|
||||
courseName: studio.name || '普拉提课程',
|
||||
bookingPeriod: `${dateLabel} ${periodLabel}`,
|
||||
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Send booking confirmed subscription message failed:', error)
|
||||
@@ -628,8 +648,6 @@ export class BookingService {
|
||||
const studio = await this.studioService.getInfo()
|
||||
const bookingDate = booking.timeSlot.date
|
||||
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)
|
||||
await Promise.allSettled(
|
||||
admins
|
||||
@@ -640,7 +658,7 @@ export class BookingService {
|
||||
bookingContent: `${studentLabel}已预约`.slice(0, 20),
|
||||
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
|
||||
courseName: studio.name || '普拉提课程',
|
||||
bookingPeriod: `${dateLabel} ${periodLabel}`,
|
||||
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
|
||||
})),
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,7 +13,7 @@ interface BookingConfirmedTemplatePayload {
|
||||
readonly bookingContent: string
|
||||
readonly bookingTime: string
|
||||
readonly courseName: string
|
||||
readonly bookingPeriod: string
|
||||
readonly bookingEndTime: string
|
||||
}
|
||||
|
||||
interface AdminBookingCreatedTemplatePayload {
|
||||
@@ -22,7 +22,7 @@ interface AdminBookingCreatedTemplatePayload {
|
||||
readonly bookingContent: string
|
||||
readonly bookingTime: string
|
||||
readonly courseName: string
|
||||
readonly bookingPeriod: string
|
||||
readonly bookingEndTime: string
|
||||
}
|
||||
|
||||
interface WechatAccessTokenResponse {
|
||||
@@ -188,7 +188,7 @@ export class SubscriptionMessageService {
|
||||
thing1: { value: params.payload.bookingContent.slice(0, 20) },
|
||||
time2: { value: params.payload.bookingTime.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) },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user