diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index 921ef28..0402ba3 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -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 ──────────────────────────────────────────────────────── diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index 1ad0521..bf5d9c2 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -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) { diff --git a/packages/server/src/user/subscription-message.service.ts b/packages/server/src/user/subscription-message.service.ts index 5f4cc53..0b3b55d 100644 --- a/packages/server/src/user/subscription-message.service.ts +++ b/packages/server/src/user/subscription-message.service.ts @@ -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) }, }, }