From 86ad9ee64f8b417133ef0c3e4ad6ed88ef3e2631 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 7 Sep 2026 17:21:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=80=81=E5=B8=88=E4=BB=A3=E7=BA=A6?= =?UTF-8?q?=E5=8F=AA=E5=85=81=E8=AE=B8=E5=B7=B2=E5=8F=91=E5=B8=83=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=B8=B4=E6=97=B6=E5=8A=A0?= =?UTF-8?q?=E5=BC=80=E6=97=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../app/src/pages/admin/member-arrange.vue | 150 ++---------------- .../booking/__tests__/booking.service.spec.ts | 95 ----------- .../server/src/booking/booking.service.ts | 77 +-------- .../booking/dto/admin-arrange-booking.dto.ts | 32 +--- packages/shared/src/types/booking.ts | 6 +- 5 files changed, 25 insertions(+), 335 deletions(-) diff --git a/packages/app/src/pages/admin/member-arrange.vue b/packages/app/src/pages/admin/member-arrange.vue index 48d544a..5cdff5d 100644 --- a/packages/app/src/pages/admin/member-arrange.vue +++ b/packages/app/src/pages/admin/member-arrange.vue @@ -47,7 +47,7 @@ 这天还没有可安排的课表 - 可以去排课管理发布,或在下方加开一个时段 + 请先去排课管理发布时段 前往排课管理 @@ -56,7 +56,7 @@ - - 加开此时段 - - - - {{ customStart }} – {{ customEnd }} - 选择开始时间 - - - - 加开并安排 - - - - 立即确认 @@ -127,7 +108,7 @@ import type { AdminMemberDetail, ScheduleSlotPreview, } from '@mp-pilates/shared' -import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, TimeSlotSource } from '@mp-pilates/shared' +import { MembershipStatus, TIME_PERIODS, TimeSlotStatus } from '@mp-pilates/shared' import CustomNavBar from '../../components/CustomNavBar.vue' import DateSelector from '../../components/DateSelector.vue' import TimePeriodFilter from '../../components/TimePeriodFilter.vue' @@ -147,7 +128,6 @@ const selectedDate = ref(formatDate(new Date())) const selectedPeriod = ref(null) const slots = ref([]) const slotsLoading = ref(false) -const customStart = ref('10:00') const confirmVisible = ref(false) const pendingSlot = ref(null) const arranging = ref(false) @@ -175,34 +155,27 @@ const deductHint = computed(() => { return '将立即确认该课并扣除 1 次,会员无需再确认。' }) +const publishedSlots = computed(() => + slots.value.filter((slot): slot is ScheduleSlotPreview & { id: string } => + Boolean(slot.isPublished && slot.id), + ), +) + const filteredSlots = computed(() => { - if (!selectedPeriod.value) return slots.value + if (!selectedPeriod.value) return publishedSlots.value const period = TIME_PERIODS[selectedPeriod.value] - return slots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end) + return publishedSlots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end) }) -const customEnd = computed(() => addHour(customStart.value)) - -function addHour(time: string): string { - const [hours, minutes] = time.split(':').map(Number) - const total = hours * 60 + (minutes || 0) + 60 - const nextHours = Math.floor(total / 60) % 24 - const nextMinutes = total % 60 - return `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}` -} - function canPickSlot(slot: ScheduleSlotPreview): boolean { if (!canArrange.value) return false if (isSlotPast(slot.date, slot.startTime)) return false if (slot.status === TimeSlotStatus.CLOSED) return false - if (slot.isPublished && (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity)) { - return false - } + if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return false return true } function slotLabel(slot: ScheduleSlotPreview): string { - if (!slot.isPublished || !slot.id) return '未发布时段' if (slot.status === TimeSlotStatus.CLOSED) return '已关闭' if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员' if (isSlotPast(slot.date, slot.startTime)) return '已过点' @@ -210,7 +183,6 @@ function slotLabel(slot: ScheduleSlotPreview): string { } function slotActionLabel(slot: ScheduleSlotPreview): string { - if (!slot.isPublished || !slot.id) return '加开' if (!canPickSlot(slot)) return '—' return '安排' } @@ -239,63 +211,26 @@ function onDateSelect(date: string) { loadSlots(date) } -function onCustomStartChange(e: { detail: { value: string } }) { - customStart.value = e.detail.value -} - function goSchedule() { uni.navigateTo({ url: '/pages/admin/schedule' }) } function onPickSlot(slot: ScheduleSlotPreview) { - if (!canPickSlot(slot)) return + if (!canPickSlot(slot) || !slot.id) return pendingSlot.value = slot confirmVisible.value = true } -function onCustomArrange() { - if (!canArrange.value) { - uni.showToast({ title: '请先开通有效会员卡', icon: 'none' }) - return - } - if (customEnd.value <= customStart.value) { - uni.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' }) - return - } - if (isSlotPast(selectedDate.value, customStart.value)) { - uni.showToast({ title: '不能安排已经过去的时间', icon: 'none' }) - return - } - pendingSlot.value = { - id: null, - date: selectedDate.value, - startTime: customStart.value, - endTime: customEnd.value, - capacity: 1, - bookedCount: 0, - status: TimeSlotStatus.OPEN, - source: TimeSlotSource.MANUAL, - templateId: null, - isPublished: false, - } - confirmVisible.value = true -} - async function confirmArrange() { const slot = pendingSlot.value const membership = selectedMembership.value - if (!slot || !membership || arranging.value) return + if (!slot?.id || !membership || arranging.value) return arranging.value = true try { - let timeSlotId = slot.id await adminStore.arrangeMemberBooking({ userId: userId.value, membershipId: membership.id, - ...(timeSlotId ? { timeSlotId } : {}), - date: slot.date, - startTime: slot.startTime.slice(0, 5), - endTime: slot.endTime.slice(0, 5), - capacity: slot.capacity || 1, + timeSlotId: slot.id, }) confirmVisible.value = false uni.showToast({ title: '已安排并确认', icon: 'success' }) @@ -539,61 +474,6 @@ onMounted(async () => { color: $accent-color; } -.custom-block { - margin: 12rpx 24rpx 40rpx; - padding: 24rpx; - background: #fff; - border-radius: 20rpx; - border: 1rpx dashed rgba(180, 160, 130, 0.28); -} - -.custom-title { - display: block; - font-size: 22rpx; - letter-spacing: 3rpx; - color: $text-hint; - margin-bottom: 16rpx; -} - -.custom-row { - display: flex; - align-items: center; - gap: 16rpx; -} - -.custom-picker { - flex: 1; - display: flex; - flex-direction: column; - gap: 4rpx; -} - -.custom-picker-text { - font-size: 30rpx; - font-weight: 700; - color: $text-primary; - font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; -} - -.custom-picker-arrow { - font-size: 20rpx; - color: $text-hint; -} - -.custom-btn { - padding: 16rpx 22rpx; - border-radius: 14rpx; - background: $brand-color; -} - -.custom-btn--disabled { opacity: 0.4; } - -.custom-btn-text { - font-size: 24rpx; - font-weight: 700; - color: #fff8f0; -} - .mask { position: fixed; inset: 0; diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index 59d98e5..2abb525 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -1318,100 +1318,5 @@ describe('BookingService', () => { ) expect(tx.booking.create).not.toHaveBeenCalled() }) - - it('reuses an existing slot when arranging by date and time', async () => { - const tx = buildTxMock() - stubArrangeSuccess(tx) - - await service.adminArrangeBooking(MOCK_ADMIN_ID, { - userId: MOCK_USER_ID, - membershipId: MOCK_MEMBERSHIP_ID, - date: '2099-12-31', - startTime: '09:00', - endTime: '10:00', - }) - - expect(tx.timeSlot.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - date_startTime_endTime: { - date: new Date('2099-12-31T00:00:00.000Z'), - startTime: '09:00', - endTime: '10:00', - }, - }, - }), - ) - expect(tx.timeSlot.create).not.toHaveBeenCalled() - expect(tx.booking.create).toHaveBeenCalled() - }) - - it('creates a manual slot when arranging a missing date and time', async () => { - const tx = buildTxMock() - const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' } - tx.timeSlot.findUnique - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 }) - tx.timeSlot.create.mockResolvedValue(createdSlot) - tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) - tx.booking.findUnique.mockResolvedValue(null) - tx.membership.findUnique.mockResolvedValue(mockActiveMembership) - tx.timeSlot.updateMany.mockResolvedValue({ count: 1 }) - tx.booking.create.mockResolvedValue({ - ...mockConfirmedBooking, - timeSlotId: createdSlot.id, - status: BookingStatus.CONFIRMED, - }) - tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 }) - ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ - ...mockConfirmedBooking, - timeSlotId: createdSlot.id, - status: BookingStatus.CONFIRMED, - timeSlot: createdSlot, - membership: mockActiveMembership, - }) - ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' }) - studioService.getInfo.mockResolvedValue({ - ...mockStudioConfig, - name: 'FocusCore Pilates', - }) - subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true) - - await service.adminArrangeBooking(MOCK_ADMIN_ID, { - userId: MOCK_USER_ID, - membershipId: MOCK_MEMBERSHIP_ID, - date: '2099-12-31', - startTime: '09:00', - endTime: '10:00', - }) - - expect(tx.timeSlot.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - startTime: '09:00', - endTime: '10:00', - source: 'MANUAL', - }), - }), - ) - }) - - it('rejects custom slots whose end time is not after start time', async () => { - const tx = buildTxMock() - ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - - await expect( - service.adminArrangeBooking(MOCK_ADMIN_ID, { - userId: MOCK_USER_ID, - membershipId: MOCK_MEMBERSHIP_ID, - date: '2099-12-31', - startTime: '23:00', - endTime: '00:00', - }), - ).rejects.toThrow(BadRequestException) - expect(tx.timeSlot.findUnique).not.toHaveBeenCalled() - expect(tx.timeSlot.create).not.toHaveBeenCalled() - }) }) }) diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index 15c0313..21fba5d 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -5,13 +5,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common' -import { Booking, Membership, Prisma, TimeSlot, BookingStatusHistory } from '@prisma/client' +import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client' import { BookingStatus, CardTypeCategory, - DEFAULT_SLOT_CAPACITY, MembershipStatus, - TimeSlotSource, TimeSlotStatus, type TeachingScheduleSlot, } from '@mp-pilates/shared' @@ -51,23 +49,6 @@ function buildSlotStartMs(slotDate: Date, startTime: string): number { return d.getTime() } -function normalizeClock(time: string): string { - return time.slice(0, 5) -} - -function clockToMinutes(time: string): number { - const [hours, minutes] = normalizeClock(time).split(':').map(Number) - return hours * 60 + minutes -} - -function parseSlotDate(date: string): Date { - const parsed = new Date(`${date}T00:00:00.000Z`) - if (Number.isNaN(parsed.getTime())) { - throw new BadRequestException('Invalid date') - } - return parsed -} - // ─── Service ─────────────────────────────────────────────────────────────── @Injectable() @@ -298,7 +279,12 @@ export class BookingService { dto: AdminArrangeBookingDto, ): Promise { const booking = await this.prisma.$transaction(async (tx) => { - const timeSlot = await this.resolveArrangeSlot(tx, dto) + 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})`, @@ -433,55 +419,6 @@ export class BookingService { return arrangedBooking } - private async resolveArrangeSlot( - tx: Prisma.TransactionClient, - dto: AdminArrangeBookingDto, - ): Promise { - if (dto.timeSlotId) { - const slot = await tx.timeSlot.findUnique({ - where: { id: dto.timeSlotId }, - }) - if (!slot) { - throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`) - } - return slot - } - - if (!dto.date || !dto.startTime || !dto.endTime) { - throw new BadRequestException('timeSlotId or date+startTime+endTime is required') - } - - const startTime = normalizeClock(dto.startTime) - const endTime = normalizeClock(dto.endTime) - if (clockToMinutes(endTime) <= clockToMinutes(startTime)) { - throw new BadRequestException('End time must be after start time') - } - - const date = parseSlotDate(dto.date) - const existing = await tx.timeSlot.findUnique({ - where: { - date_startTime_endTime: { - date, - startTime, - endTime, - }, - }, - }) - if (existing) { - return existing - } - - return tx.timeSlot.create({ - data: { - date, - startTime, - endTime, - capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY, - source: TimeSlotSource.MANUAL, - }, - }) - } - // ─── Complete / NoShow Booking (Admin) ────────────────────────────────── async completeBooking( diff --git a/packages/server/src/booking/dto/admin-arrange-booking.dto.ts b/packages/server/src/booking/dto/admin-arrange-booking.dto.ts index 9153ae9..bbfec9d 100644 --- a/packages/server/src/booking/dto/admin-arrange-booking.dto.ts +++ b/packages/server/src/booking/dto/admin-arrange-booking.dto.ts @@ -1,13 +1,4 @@ -import { Type } from 'class-transformer' -import { - IsDateString, - IsInt, - IsOptional, - IsUUID, - Matches, - Min, - ValidateIf, -} from 'class-validator' +import { IsUUID } from 'class-validator' export class AdminArrangeBookingDto { @IsUUID() @@ -16,25 +7,6 @@ export class AdminArrangeBookingDto { @IsUUID() membershipId!: string - @IsOptional() @IsUUID() - timeSlotId?: string - - @ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId) - @IsDateString() - date?: string - - @ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId) - @Matches(/^\d{2}:\d{2}(:\d{2})?$/) - startTime?: string - - @ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId) - @Matches(/^\d{2}:\d{2}(:\d{2})?$/) - endTime?: string - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - capacity?: number + timeSlotId!: string } diff --git a/packages/shared/src/types/booking.ts b/packages/shared/src/types/booking.ts index d3ba9bb..22bd3e1 100644 --- a/packages/shared/src/types/booking.ts +++ b/packages/shared/src/types/booking.ts @@ -73,9 +73,5 @@ export interface CreateBookingDto { export interface AdminArrangeBookingDto { readonly userId: string readonly membershipId: string - readonly timeSlotId?: string - readonly date?: string - readonly startTime?: string - readonly endTime?: string - readonly capacity?: number + readonly timeSlotId: string }