570 lines
18 KiB
TypeScript
570 lines
18 KiB
TypeScript
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<T> {
|
|
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<BookingWithRelations> {
|
|
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<BookingWithRelations> {
|
|
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<BookingWithRelations> {
|
|
return this.markBookingStatus(bookingId, operatorId, BookingStatus.COMPLETED, remark || '老师核销完成')
|
|
}
|
|
|
|
async markNoShow(
|
|
bookingId: string,
|
|
operatorId: string,
|
|
remark?: string,
|
|
): Promise<BookingWithRelations> {
|
|
return this.markBookingStatus(bookingId, operatorId, BookingStatus.NO_SHOW, remark || '学员未出席')
|
|
}
|
|
|
|
private async markBookingStatus(
|
|
bookingId: string,
|
|
operatorId: string,
|
|
toStatus: BookingStatus,
|
|
remark: string,
|
|
): Promise<BookingWithRelations> {
|
|
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<string, unknown> = {
|
|
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<CancelBookingResult> {
|
|
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<BookingStatusHistory[]> {
|
|
const history = await this.prisma.bookingStatusHistory.findMany({
|
|
where: { bookingId },
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
return history
|
|
}
|
|
|
|
// ─── Get Booking By Id ─────────────────────────────────────────────────
|
|
|
|
async getBookingById(bookingId: string): Promise<BookingWithRelations | null> {
|
|
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<PaginatedResult<BookingWithRelations>> {
|
|
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<BookingWithRelations[]> {
|
|
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<PaginatedResult<BookingWithRelations & { user: { id: string; nickname: string; phone: string | null } }>> {
|
|
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<BookingWithRelations> {
|
|
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
|
|
}
|
|
}
|