fix(server): support rebooking cancelled slots
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user