import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common' import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client' import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared' import { PrismaService } from '../prisma/prisma.service' import { MembershipService } from '../membership/membership.service' import { StudioService } from '../studio/studio.service' import { CreateBookingDto } from './dto/create-booking.dto' // ─── Types ───────────────────────────────────────────────────────────────── export interface BookingWithRelations extends Booking { timeSlot: TimeSlot membership: Membership & { cardType: { id: string; name: string; type: string } } } export interface PaginatedResult { data: T[] total: number page: number limit: number } export interface CancelBookingResult { booking: Booking refunded: boolean } // ─── Helpers ──────────────────────────────────────────────────────────────── function buildSlotStartMs(slotDate: Date, startTime: string): number { const [hours, minutes] = startTime.split(':').map(Number) const d = new Date(slotDate) d.setUTCHours(hours, minutes, 0, 0) return d.getTime() } // ─── Service ─────────────────────────────────────────────────────────────── @Injectable() export class BookingService { constructor( private readonly prisma: PrismaService, private readonly membershipService: MembershipService, private readonly studioService: StudioService, ) {} // ─── Create Booking ────────────────────────────────────────────────────── async createBooking( userId: string, dto: CreateBookingDto, ): Promise { const booking = await this.prisma.$transaction(async (tx) => { // 1. Fetch and validate timeSlot 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})`, ) } // 2. Check for active (PENDING_CONFIRMATION or CONFIRMED) booking — cancelled bookings don't block re-booking const existing = await tx.booking.findFirst({ where: { userId, timeSlotId: dto.timeSlotId, status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, }, }) if (existing) { throw new ConflictException('You have already booked this time slot') } // 3. Fetch and validate membership const membership = await tx.membership.findUnique({ where: { id: dto.membershipId }, include: { cardType: true }, }) if (!membership) { throw new NotFoundException(`Membership ${dto.membershipId} not found`) } if (membership.userId !== userId) { throw new ForbiddenException('This membership does not belong to you') } if (membership.status !== MembershipStatus.ACTIVE) { throw new BadRequestException( `Membership is not active (status: ${membership.status})`, ) } const cardType = membership.cardType const isTimeBased = cardType.type === CardTypeCategory.TIMES || cardType.type === CardTypeCategory.TRIAL if (isTimeBased) { // 4a. TIMES / TRIAL: must have remaining times (check at confirm time, not booking time) } else { // 4b. DURATION: must not be expired if (membership.expireDate <= new Date()) { throw new BadRequestException('Membership has expired') } } // 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, }, }) // 6. Record status history: created await tx.bookingStatusHistory.create({ data: { bookingId: newBooking.id, fromStatus: null, toStatus: BookingStatus.PENDING_CONFIRMATION, operatorId: userId, remark: '学员发起预约', }, }) return newBooking }) // Re-fetch with relations after transaction return this.fetchBookingWithRelations(booking.id) } // ─── Confirm Booking (Admin) ───────────────────────────────────────────── async confirmBooking( bookingId: string, operatorId: string, remark?: string, ): Promise { const booking = await this.prisma.$transaction(async (tx) => { // 1. Find booking with timeSlot and membership const existing = await tx.booking.findUnique({ where: { id: bookingId }, include: { timeSlot: true, membership: { include: { cardType: true } }, }, }) if (!existing) { throw new NotFoundException(`Booking ${bookingId} not found`) } if (existing.status !== BookingStatus.PENDING_CONFIRMATION) { throw new BadRequestException( `Cannot confirm booking with status: ${existing.status}`, ) } // 2. Validate membership still has available times const cardType = existing.membership.cardType const isTimeBased = cardType.type === CardTypeCategory.TIMES || cardType.type === CardTypeCategory.TRIAL if (isTimeBased) { if ((existing.membership.remainingTimes ?? 0) <= 0) { throw new BadRequestException('No remaining times on this membership') } } else { if (existing.membership.expireDate <= new Date()) { throw new BadRequestException('Membership has expired') } } // 3. Update booking status to CONFIRMED const updated = await tx.booking.update({ where: { id: bookingId }, data: { status: BookingStatus.CONFIRMED, confirmedAt: new Date(), operatorId, }, }) // 4. Increment bookedCount; set FULL if at capacity const newBookedCount = existing.timeSlot.bookedCount + 1 const newSlotStatus = newBookedCount >= existing.timeSlot.capacity ? TimeSlotStatus.FULL : TimeSlotStatus.OPEN await tx.timeSlot.update({ where: { id: existing.timeSlotId }, data: { bookedCount: newBookedCount, status: newSlotStatus, }, }) // 5. Deduct membership times if (isTimeBased) { const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1 const newMembershipStatus = newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE await tx.membership.update({ where: { id: existing.membershipId }, data: { remainingTimes: newRemainingTimes, status: newMembershipStatus, }, }) } // 6. Record status history await tx.bookingStatusHistory.create({ data: { bookingId, fromStatus: BookingStatus.PENDING_CONFIRMATION, toStatus: BookingStatus.CONFIRMED, operatorId, remark: remark || '老师确认预约', }, }) return updated }) return this.fetchBookingWithRelations(booking.id) } // ─── Complete / NoShow Booking (Admin) ────────────────────────────────── async completeBooking( bookingId: string, operatorId: string, remark?: string, ): Promise { return this.markBookingStatus(bookingId, operatorId, BookingStatus.COMPLETED, remark || '老师核销完成') } async markNoShow( bookingId: string, operatorId: string, remark?: string, ): Promise { return this.markBookingStatus(bookingId, operatorId, BookingStatus.NO_SHOW, remark || '学员未出席') } private async markBookingStatus( bookingId: string, operatorId: string, toStatus: BookingStatus, remark: string, ): Promise { const booking = await this.prisma.$transaction(async (tx) => { const existing = await tx.booking.findUnique({ where: { id: bookingId }, include: { timeSlot: true }, }) if (!existing) { throw new NotFoundException(`Booking ${bookingId} not found`) } if (existing.status !== BookingStatus.CONFIRMED) { throw new BadRequestException( `Cannot mark as ${toStatus} with status: ${existing.status}`, ) } const updateData: Record = { status: toStatus, operatorId, } if (toStatus === BookingStatus.COMPLETED) { updateData.completedAt = new Date() } const updated = await tx.booking.update({ where: { id: bookingId }, data: updateData, }) await tx.bookingStatusHistory.create({ data: { bookingId, fromStatus: BookingStatus.CONFIRMED, toStatus, operatorId, remark, }, }) return updated }) return this.fetchBookingWithRelations(booking.id) } // ─── Cancel Booking ────────────────────────────────────────────────────── async cancelBooking( userId: string, bookingId: string, ): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { timeSlot: true, membership: { include: { cardType: true } }, }, }) if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`) } if (booking.userId !== userId) { throw new ForbiddenException('This booking does not belong to you') } let refunded = false // PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted) if (booking.status === BookingStatus.PENDING_CONFIRMATION) { await this.prisma.$transaction(async (tx) => { await tx.booking.update({ where: { id: bookingId }, data: { status: BookingStatus.CANCELLED }, }) await tx.bookingStatusHistory.create({ data: { bookingId, fromStatus: BookingStatus.PENDING_CONFIRMATION, toStatus: BookingStatus.CANCELLED, operatorId: userId, remark: '学员取消预约(待确认状态)', }, }) }) return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded } } // CONFIRMED: check cancel time limit and potentially refund if (booking.status !== BookingStatus.CONFIRMED) { throw new BadRequestException( `Cannot cancel booking with status: ${booking.status}`, ) } const studioConfig = await this.studioService.getInfo() const { cancelHoursLimit } = studioConfig const slotStartMs = buildSlotStartMs(booking.timeSlot.date, booking.timeSlot.startTime) const deadlineMs = Date.now() + cancelHoursLimit * 3600 * 1000 const withinLimit = slotStartMs >= deadlineMs const updatedBooking = await this.prisma.$transaction(async (tx) => { const cancelled = await tx.booking.update({ where: { id: bookingId }, data: { status: BookingStatus.CANCELLED, cancelledAt: new Date(), }, }) // Decrement bookedCount; restore OPEN if was FULL const newBookedCount = Math.max(0, booking.timeSlot.bookedCount - 1) const newSlotStatus = booking.timeSlot.status === TimeSlotStatus.FULL ? TimeSlotStatus.OPEN : booking.timeSlot.status await tx.timeSlot.update({ where: { id: booking.timeSlotId }, data: { bookedCount: newBookedCount, status: newSlotStatus, }, }) // Conditionally restore membership if (withinLimit) { const cardType = booking.membership.cardType const isTimeBased = cardType.type === CardTypeCategory.TIMES || cardType.type === CardTypeCategory.TRIAL if (isTimeBased) { const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1 const newStatus = booking.membership.status === MembershipStatus.USED_UP ? MembershipStatus.ACTIVE : booking.membership.status await tx.membership.update({ where: { id: booking.membershipId }, data: { remainingTimes: newRemainingTimes, status: newStatus, }, }) refunded = true } } await tx.bookingStatusHistory.create({ data: { bookingId, fromStatus: BookingStatus.CONFIRMED, toStatus: BookingStatus.CANCELLED, operatorId: userId, remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)', }, }) return cancelled }) return { booking: { ...updatedBooking }, refunded } } // ─── Get Booking Status History ────────────────────────────────────────── async getBookingStatusHistory(bookingId: string): Promise { const history = await this.prisma.bookingStatusHistory.findMany({ where: { bookingId }, orderBy: { createdAt: 'asc' }, }) return history } // ─── Get Booking By Id ───────────────────────────────────────────────── async getBookingById(bookingId: string): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { timeSlot: true, membership: { include: { cardType: true } }, user: { select: { id: true, nickname: true, phone: true } }, }, }) return booking as BookingWithRelations | null } // ─── Get My Bookings ───────────────────────────────────────────────────── async getMyBookings( userId: string, status?: BookingStatus, page = 1, limit = 10, ): Promise> { const where = { userId, ...(status ? { status } : {}), } const [bookings, total] = await Promise.all([ this.prisma.booking.findMany({ where, include: { timeSlot: true, membership: { include: { cardType: true } }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), this.prisma.booking.count({ where }), ]) return { data: bookings.map((b) => ({ ...b })) as BookingWithRelations[], total, page, limit, } } // ─── Get Upcoming Bookings ──────────────────────────────────────────────── async getUpcomingBookings(userId: string): Promise { const today = new Date() today.setUTCHours(0, 0, 0, 0) const bookings = await this.prisma.booking.findMany({ where: { userId, status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, timeSlot: { date: { gte: today }, }, }, include: { timeSlot: true, membership: { include: { cardType: true } }, }, orderBy: [ { timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, ], }) return bookings.map((b) => ({ ...b })) as BookingWithRelations[] } // ─── Get All Bookings (Admin) ───────────────────────────────────────────── async getAllBookings( page = 1, limit = 10, status?: BookingStatus, ): Promise> { const where = status ? { status } : {} const [bookings, total] = await Promise.all([ this.prisma.booking.findMany({ where, include: { user: { select: { id: true, nickname: true, phone: true } }, timeSlot: true, membership: { include: { cardType: true } }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), this.prisma.booking.count({ where }), ]) return { data: bookings.map((b) => ({ ...b })) as unknown as (BookingWithRelations & { user: { id: string; nickname: string; phone: string | null } })[], total, page, limit, } } // ─── Private Helpers ───────────────────────────────────────────────────── private async fetchBookingWithRelations(bookingId: string): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { timeSlot: true, membership: { include: { cardType: true } }, }, }) if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`) } return { ...booking } as BookingWithRelations } }