perf: 支持课程补录
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `lesson_supplements` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`user_id` VARCHAR(191) NOT NULL,
|
||||
`request_id` VARCHAR(80) NOT NULL,
|
||||
`quantity` INTEGER NOT NULL,
|
||||
`membership_id` VARCHAR(191) NULL,
|
||||
`deducted_times` INTEGER NOT NULL DEFAULT 0,
|
||||
`card_name` VARCHAR(191) NULL,
|
||||
`remark` VARCHAR(200) NULL,
|
||||
`operator_id` VARCHAR(191) NOT NULL,
|
||||
`operator_name` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`revoked_by` VARCHAR(191) NULL,
|
||||
|
||||
INDEX `lesson_supplements_user_id_revoked_at_created_at_idx`(`user_id`, `revoked_at`, `created_at`),
|
||||
UNIQUE INDEX `lesson_supplements_user_id_request_id_key`(`user_id`, `request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -84,6 +84,7 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
lessonSupplements LessonSupplement[]
|
||||
memberships Membership[]
|
||||
bookings Booking[]
|
||||
orders Order[]
|
||||
@@ -155,6 +156,7 @@ model Membership {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
lessonSupplements LessonSupplement[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
bookings Booking[]
|
||||
@@ -307,7 +309,7 @@ model InviteRewardGrant {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([inviterId, grantedAt])
|
||||
@@map("invite_reward_grants")
|
||||
@@ -373,3 +375,27 @@ model FlashSaleOrder {
|
||||
@@index([status])
|
||||
@@map("flash_sale_orders")
|
||||
}
|
||||
|
||||
// Historical totals without fabricated scheduled dates.
|
||||
model LessonSupplement {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
requestId String @map("request_id") @db.VarChar(80)
|
||||
quantity Int
|
||||
membershipId String? @map("membership_id")
|
||||
deductedTimes Int @default(0) @map("deducted_times")
|
||||
cardName String? @map("card_name")
|
||||
remark String? @db.VarChar(200)
|
||||
operatorId String @map("operator_id")
|
||||
operatorName String @map("operator_name")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
revokedBy String? @map("revoked_by")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([userId, requestId])
|
||||
@@index([userId, revokedAt, createdAt])
|
||||
@@map("lesson_supplements")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'reflect-metadata'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { ExecutionContext } from '@nestjs/common'
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants'
|
||||
import { LessonSupplementController } from '../lesson-supplement.controller'
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../../auth/roles.guard'
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { validate } from 'class-validator'
|
||||
import { LessonSupplementService } from '../lesson-supplement.service'
|
||||
import { CreateLessonSupplementDto } from '../dto/create-lesson-supplement.dto'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
|
||||
const dto = { requestId: 'supp_request_123456', quantity: 10 }
|
||||
const makeRecord = (extra = {}) => ({
|
||||
id: 'supp-1', userId: 'member', ...dto, membershipId: null,
|
||||
deductedTimes: 0, cardName: null, remark: null, operatorId: 'teacher', operatorName: '老师',
|
||||
createdAt: new Date('2026-09-08T04:00:00Z'), revokedAt: null, revokedBy: null, ...extra,
|
||||
})
|
||||
const makeCard = (extra = {}) => ({
|
||||
id: 'card', userId: 'member', remainingTimes: 20, status: 'ACTIVE',
|
||||
expireDate: new Date('2099-01-01'), updatedAt: new Date('2026-09-01'), cardType: { name: '私教次卡' }, ...extra,
|
||||
})
|
||||
|
||||
describe('LessonSupplementService', () => {
|
||||
let service: LessonSupplementService
|
||||
let db: any
|
||||
beforeEach(() => {
|
||||
db = {
|
||||
user: { findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: where.id, nickname: '老师' })) },
|
||||
membership: { findUnique: jest.fn().mockResolvedValue(makeCard()), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
lessonSupplement: {
|
||||
findUnique: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(makeRecord()),
|
||||
findMany: jest.fn().mockResolvedValue([makeRecord()]),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve(makeRecord(data))),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
}
|
||||
db.$transaction = jest.fn(callback => callback(db))
|
||||
service = new LessonSupplementService(db as PrismaService)
|
||||
})
|
||||
|
||||
it('records ten historical classes without a card, time slot or booking', async () => {
|
||||
const result = await service.create('member', 'teacher', dto)
|
||||
expect(result.quantity).toBe(10)
|
||||
expect(result.deductedTimes).toBe(0)
|
||||
expect(db.membership.updateMany).not.toHaveBeenCalled()
|
||||
expect(db.lessonSupplement.create).toHaveBeenCalledWith({ data: expect.objectContaining({ operatorId: 'teacher', operatorName: '老师', membershipId: null }) })
|
||||
})
|
||||
|
||||
it('atomically deducts selected card and preserves the actual deduction snapshot', async () => {
|
||||
const result = await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
|
||||
expect(result.deductedTimes).toBe(10)
|
||||
expect(result.cardName).toBe('私教次卡')
|
||||
expect(db.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(db.membership.updateMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ id: 'card', userId: 'member', remainingTimes: 20 }),
|
||||
data: { remainingTimes: { decrement: 10 }, status: 'ACTIVE' },
|
||||
})
|
||||
})
|
||||
|
||||
it('marks an exhausted card used up', async () => {
|
||||
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: 10 }))
|
||||
await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
|
||||
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('USED_UP')
|
||||
})
|
||||
|
||||
it('allows historical deduction from an expired card without reactivating it', async () => {
|
||||
db.membership.findUnique.mockResolvedValue(makeCard({ expireDate: new Date('2020-01-01') }))
|
||||
await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
|
||||
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('EXPIRED')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[makeCard({ userId: 'another-member' }), '请选择该学员的会员卡'],
|
||||
[null, '请选择该学员的会员卡'],
|
||||
[makeCard({ remainingTimes: 9 }), '会员卡剩余次数不足'],
|
||||
[makeCard({ remainingTimes: null }), '不限次会员卡无需扣次'],
|
||||
])('rejects invalid card %j before writing', async (card, message) => {
|
||||
db.membership.findUnique.mockResolvedValue(card)
|
||||
await expect(service.create('member', 'teacher', { ...dto, membershipId: 'card' })).rejects.toThrow(message)
|
||||
expect(db.lessonSupplement.create).not.toHaveBeenCalled()
|
||||
expect(db.membership.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects nonexistent members', async () => {
|
||||
db.user.findUnique.mockResolvedValue(null)
|
||||
await expect(service.create('missing', 'teacher', dto)).rejects.toBeInstanceOf(NotFoundException)
|
||||
})
|
||||
|
||||
it('fails the transaction if the membership changed concurrently', async () => {
|
||||
db.membership.updateMany.mockResolvedValue({ count: 0 })
|
||||
await expect(service.create('member', 'teacher', { ...dto, membershipId: 'card' })).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('replays a lost success response without another write or deduction', async () => {
|
||||
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord())
|
||||
expect((await service.create('member', 'teacher', dto)).id).toBe('supp-1')
|
||||
expect(db.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reuse a request identity for a different quantity', async () => {
|
||||
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord())
|
||||
await expect(service.create('member', 'teacher', { ...dto, quantity: 20 })).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('does not replay a revoked supplement', async () => {
|
||||
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord({ revokedAt: new Date() }))
|
||||
await expect(service.create('member', 'teacher', dto)).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('recovers a simultaneous duplicate submit after unique-key rejection', async () => {
|
||||
db.lessonSupplement.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(makeRecord())
|
||||
db.lessonSupplement.create.mockRejectedValue(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
|
||||
expect((await service.create('member', 'teacher', dto)).quantity).toBe(10)
|
||||
expect(db.membership.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refunds precisely the snapshot and reactivates an exhausted unexpired card', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
|
||||
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: 0, status: 'USED_UP' }))
|
||||
await service.revoke('member', 'supp-1', 'teacher')
|
||||
expect(db.membership.updateMany.mock.calls[0][0].data).toEqual({ remainingTimes: { increment: 10 }, status: 'ACTIVE' })
|
||||
expect(db.lessonSupplement.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'supp-1', userId: 'member', revokedAt: null }, data: { revokedAt: expect.any(Date), revokedBy: 'teacher' } })
|
||||
})
|
||||
|
||||
it('keeps an expired card expired on refund', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
|
||||
db.membership.findUnique.mockResolvedValue(makeCard({ expireDate: new Date('2020-01-01') }))
|
||||
await service.revoke('member', 'supp-1', 'teacher')
|
||||
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('EXPIRED')
|
||||
})
|
||||
|
||||
it('does not refund a non-deducting supplement', async () => {
|
||||
await service.revoke('member', 'supp-1', 'teacher')
|
||||
expect(db.membership.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not double-refund an already revoked record', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ revokedAt: new Date(), deductedTimes: 10, membershipId: 'card' }))
|
||||
await service.revoke('member', 'supp-1', 'teacher')
|
||||
expect(db.membership.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not double-refund a concurrent revocation', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
|
||||
db.lessonSupplement.updateMany.mockResolvedValue({ count: 0 })
|
||||
await service.revoke('member', 'supp-1', 'teacher')
|
||||
expect(db.membership.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects revocation of another member record', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(null)
|
||||
await expect(service.revoke('other', 'supp-1', 'teacher')).rejects.toBeInstanceOf(NotFoundException)
|
||||
expect(db.lessonSupplement.findFirst).toHaveBeenCalledWith({ where: { id: 'supp-1', userId: 'other' } })
|
||||
})
|
||||
|
||||
it('blocks refund after a card is changed to unlimited', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
|
||||
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: null }))
|
||||
await expect(service.revoke('member', 'supp-1', 'teacher')).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('fails revocation transaction when a concurrent card update wins', async () => {
|
||||
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
|
||||
db.membership.updateMany.mockResolvedValue({ count: 0 })
|
||||
await expect(service.revoke('member', 'supp-1', 'teacher')).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('limits member history to their non-revoked supplements', async () => {
|
||||
await service.list('member')
|
||||
expect(db.lessonSupplement.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null })
|
||||
await service.list('member', true)
|
||||
expect(db.lessonSupplement.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateLessonSupplementDto', () => {
|
||||
it.each([0, -1, 1.5, 1000, '10', null, undefined])('rejects invalid quantity %s', async quantity => {
|
||||
const errors = await validate(Object.assign(new CreateLessonSupplementDto(), dto, { quantity }))
|
||||
expect(errors.some(error => error.property === 'quantity')).toBe(true)
|
||||
})
|
||||
it('accepts a quantity-only supplement', async () => {
|
||||
expect(await validate(Object.assign(new CreateLessonSupplementDto(), dto))).toEqual([])
|
||||
})
|
||||
it('rejects oversized notes and missing retry identity', async () => {
|
||||
const errors = await validate(Object.assign(new CreateLessonSupplementDto(), { quantity: 10, remark: '字'.repeat(201) }))
|
||||
expect(errors.map(error => error.property)).toEqual(expect.arrayContaining(['requestId', 'remark']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('LessonSupplement authorization', () => {
|
||||
it('requires authentication for all supplement endpoints', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, LessonSupplementController)).toContain(JwtAuthGuard)
|
||||
})
|
||||
it.each(['list', 'create', 'revoke'] as const)('restricts %s to teachers/admins', method => {
|
||||
const handler = LessonSupplementController.prototype[method]
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
|
||||
const guard = new RolesGuard(new Reflector())
|
||||
const context = (role: string) => ({ getHandler: () => handler, getClass: () => LessonSupplementController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
|
||||
expect(guard.canActivate(context('MEMBER'))).toBe(false)
|
||||
expect(guard.canActivate(context('ADMIN'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,10 @@ const makeBooking = (
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockPrisma = {
|
||||
lessonSupplement: {
|
||||
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }),
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
@@ -374,6 +378,13 @@ describe('UserService', () => {
|
||||
expect(result.totalBookings).toBe(2)
|
||||
})
|
||||
|
||||
it('adds ten active historical classes only to lifetime total', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([makeBooking(dateInMonth(1), '09:00', '10:00')])
|
||||
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
|
||||
expect(await service.getStats('user-1')).toEqual({ totalBookings: 11, totalDays: 1, monthBookings: 1, monthDays: 1, monthHours: 1 })
|
||||
expect(mockPrisma.lessonSupplement.aggregate).toHaveBeenCalledWith({ where: { userId: 'user-1', revokedAt: null }, _sum: { quantity: true } })
|
||||
})
|
||||
|
||||
it('counts distinct dates for totalDays', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'),
|
||||
@@ -436,6 +447,16 @@ describe('UserService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('batch-adds supplements to member-list completed counts without inflating reservations', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValue([makeUser({ memberships: [] })])
|
||||
mockPrisma.user.count.mockResolvedValue(1)
|
||||
mockPrisma.booking.groupBy.mockResolvedValue([{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } }])
|
||||
mockPrisma.lessonSupplement.groupBy.mockResolvedValueOnce([{ userId: 'user-1', _sum: { quantity: 10 } }])
|
||||
const result = await service.getMembers(1, 20)
|
||||
expect(result.items[0]).toMatchObject({ totalBookings: 3, completedBookings: 13 })
|
||||
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
|
||||
})
|
||||
|
||||
describe('getMemberDetail', () => {
|
||||
const cardType = {
|
||||
id: 'ct-1',
|
||||
@@ -490,6 +511,7 @@ describe('UserService', () => {
|
||||
},
|
||||
])
|
||||
|
||||
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
|
||||
const result = await service.getMemberDetail('user-1')
|
||||
|
||||
expect(result.user.userId).toBe('user-1')
|
||||
@@ -498,7 +520,7 @@ describe('UserService', () => {
|
||||
expect(result.memberships[0].cardType.name).toBe('10次卡')
|
||||
expect(result.stats).toEqual({
|
||||
totalBookings: 5,
|
||||
completedBookings: 3,
|
||||
completedBookings: 13,
|
||||
cancelledBookings: 1,
|
||||
noShowBookings: 1,
|
||||
})
|
||||
|
||||
23
packages/server/src/user/dto/create-lesson-supplement.dto.ts
Normal file
23
packages/server/src/user/dto/create-lesson-supplement.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator'
|
||||
|
||||
export class CreateLessonSupplementDto {
|
||||
@IsString()
|
||||
@Matches(/^[a-zA-Z0-9_-]{16,80}$/)
|
||||
readonly requestId!: string
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(999)
|
||||
readonly quantity!: number
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/\S/)
|
||||
@MaxLength(191)
|
||||
readonly membershipId?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
readonly remark?: string
|
||||
}
|
||||
37
packages/server/src/user/lesson-supplement.controller.ts
Normal file
37
packages/server/src/user/lesson-supplement.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { LessonSupplementService } from './lesson-supplement.service'
|
||||
import { CreateLessonSupplementDto } from './dto/create-lesson-supplement.dto'
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LessonSupplementController {
|
||||
constructor(private readonly service: LessonSupplementService) {}
|
||||
|
||||
@Get('user/lesson-supplements')
|
||||
listMine(@CurrentUser('sub') userId: string) { return this.service.list(userId) }
|
||||
|
||||
@Get('admin/members/:userId/lesson-supplements')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
list(@Param('userId') userId: string) { return this.service.list(userId, true) }
|
||||
|
||||
@Post('admin/members/:userId/lesson-supplements')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
create(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: CreateLessonSupplementDto) {
|
||||
return this.service.create(userId, operatorId, dto)
|
||||
}
|
||||
|
||||
@Post('admin/members/:userId/lesson-supplements/:id/revoke')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async revoke(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string) {
|
||||
await this.service.revoke(userId, id, operatorId)
|
||||
return { revoked: true }
|
||||
}
|
||||
}
|
||||
115
packages/server/src/user/lesson-supplement.service.ts
Normal file
115
packages/server/src/user/lesson-supplement.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { LessonSupplement, Prisma } from '@prisma/client'
|
||||
import { LessonSupplementRecord, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { CreateLessonSupplementDto } from './dto/create-lesson-supplement.dto'
|
||||
|
||||
@Injectable()
|
||||
export class LessonSupplementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private serialize(row: LessonSupplement): LessonSupplementRecord {
|
||||
return {
|
||||
id: row.id, requestId: row.requestId, quantity: row.quantity,
|
||||
deductedTimes: row.deductedTimes, cardName: row.cardName,
|
||||
remark: row.remark, operatorName: row.operatorName,
|
||||
createdAt: row.createdAt.toISOString(), revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async list(userId: string, includeRevoked = false): Promise<LessonSupplementRecord[]> {
|
||||
const records = await this.prisma.lessonSupplement.findMany({
|
||||
where: { userId, ...(includeRevoked ? {} : { revokedAt: null }) },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
})
|
||||
return records.map(row => this.serialize(row))
|
||||
}
|
||||
|
||||
private replay(row: LessonSupplement, dto: CreateLessonSupplementDto) {
|
||||
if (row.quantity !== dto.quantity || row.membershipId !== (dto.membershipId ?? null)
|
||||
|| row.remark !== (dto.remark?.trim() || null)) {
|
||||
throw new ConflictException('这次补录已提交,请刷新记录后再操作')
|
||||
}
|
||||
if (row.revokedAt) throw new ConflictException('这次补录已撤销,请重新填写')
|
||||
return this.serialize(row)
|
||||
}
|
||||
|
||||
async create(userId: string, operatorId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
|
||||
const key = { userId_requestId: { userId, requestId: dto.requestId } }
|
||||
// Persisted request identity makes retries safe, including a lost success response.
|
||||
const existing = await this.prisma.lessonSupplement.findUnique({ where: key })
|
||||
if (existing) return this.replay(existing, dto)
|
||||
try {
|
||||
return await this.prisma.$transaction(async tx => {
|
||||
const user = await tx.user.findUnique({ where: { id: userId } })
|
||||
if (!user) throw new NotFoundException('会员不存在')
|
||||
const operator = await tx.user.findUnique({ where: { id: operatorId } })
|
||||
if (!operator) throw new NotFoundException('操作人不存在')
|
||||
const card = dto.membershipId
|
||||
? await tx.membership.findUnique({ where: { id: dto.membershipId }, include: { cardType: true } })
|
||||
: null
|
||||
if (dto.membershipId && (!card || card.userId !== userId)) {
|
||||
throw new BadRequestException('请选择该学员的会员卡')
|
||||
}
|
||||
if (card && (card.remainingTimes === null || card.remainingTimes < dto.quantity)) {
|
||||
throw new BadRequestException(card.remainingTimes === null ? '不限次会员卡无需扣次,请选择仅补记录' : '会员卡剩余次数不足')
|
||||
}
|
||||
// Historical use may belong to an expired card. Its expiry/status stays expired.
|
||||
const row = await tx.lessonSupplement.create({ data: {
|
||||
userId, requestId: dto.requestId, quantity: dto.quantity,
|
||||
membershipId: card?.id ?? null, deductedTimes: card ? dto.quantity : 0,
|
||||
cardName: card?.cardType.name ?? null, remark: dto.remark?.trim() || null,
|
||||
operatorId, operatorName: operator.nickname || '老师',
|
||||
} })
|
||||
if (card) {
|
||||
const remaining = card.remainingTimes! - dto.quantity
|
||||
const changed = await tx.membership.updateMany({
|
||||
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
|
||||
data: {
|
||||
remainingTimes: { decrement: dto.quantity },
|
||||
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
|
||||
? MembershipStatus.EXPIRED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
if (changed.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
|
||||
}
|
||||
return this.serialize(row)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
const row = await this.prisma.lessonSupplement.findUnique({ where: key })
|
||||
if (row) return this.replay(row, dto)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(userId: string, id: string, operatorId: string): Promise<void> {
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const row = await tx.lessonSupplement.findFirst({ where: { id, userId } })
|
||||
if (!row) throw new NotFoundException('补录记录不存在')
|
||||
if (row.revokedAt) return
|
||||
// Claim once before refunding; concurrent revocations cannot refund twice.
|
||||
const claimed = await tx.lessonSupplement.updateMany({
|
||||
where: { id, userId, revokedAt: null }, data: { revokedAt: new Date(), revokedBy: operatorId },
|
||||
})
|
||||
if (!claimed.count) return
|
||||
if (row.deductedTimes > 0) {
|
||||
if (!row.membershipId) throw new ConflictException('原会员卡不存在,请先核对扣次记录')
|
||||
const card = await tx.membership.findUnique({ where: { id: row.membershipId } })
|
||||
if (!card || card.remainingTimes === null) {
|
||||
throw new ConflictException('原会员卡已变更为不限次或不存在,请先核对会员卡后再撤销')
|
||||
}
|
||||
const restored = await tx.membership.updateMany({
|
||||
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
|
||||
data: {
|
||||
remainingTimes: { increment: row.deductedTimes },
|
||||
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
|
||||
? MembershipStatus.EXPIRED : MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
if (restored.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import { UserController } from './user.controller'
|
||||
import { UserService } from './user.service'
|
||||
import { SubscriptionMessageService } from './subscription-message.service'
|
||||
|
||||
import { LessonSupplementService } from './lesson-supplement.service'
|
||||
import { LessonSupplementController } from './lesson-supplement.controller'
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ConfigModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, SubscriptionMessageService],
|
||||
controllers: [UserController, LessonSupplementController],
|
||||
providers: [LessonSupplementService, UserService, SubscriptionMessageService],
|
||||
exports: [UserService, SubscriptionMessageService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -319,6 +319,9 @@ export class UserService {
|
||||
},
|
||||
})
|
||||
|
||||
const supplements = await this.prisma.lessonSupplement.aggregate({
|
||||
where: { userId, revokedAt: null }, _sum: { quantity: true },
|
||||
})
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
@@ -344,7 +347,7 @@ export class UserService {
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
totalBookings: completedBookings.length,
|
||||
totalBookings: completedBookings.length + (supplements._sum.quantity ?? 0),
|
||||
totalDays,
|
||||
monthBookings: monthBookings.length,
|
||||
monthDays,
|
||||
@@ -441,6 +444,14 @@ export class UserService {
|
||||
statsMap.set(stat.userId, entry)
|
||||
}
|
||||
|
||||
const supplements = userIds.length
|
||||
? await this.prisma.lessonSupplement.groupBy({
|
||||
by: ['userId'], where: { userId: { in: userIds }, revokedAt: null },
|
||||
_sum: { quantity: true },
|
||||
})
|
||||
: []
|
||||
const supplementMap = new Map(supplements.map(row => [row.userId, row._sum.quantity ?? 0]))
|
||||
|
||||
const items = users.map((u) => {
|
||||
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
|
||||
const active = u.memberships[0]
|
||||
@@ -456,7 +467,7 @@ export class UserService {
|
||||
? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory }
|
||||
: null,
|
||||
totalBookings: s.total,
|
||||
completedBookings: s.completed,
|
||||
completedBookings: s.completed + (supplementMap.get(u.id) ?? 0),
|
||||
cancelledBookings: s.cancelled,
|
||||
}
|
||||
})
|
||||
@@ -515,6 +526,10 @@ export class UserService {
|
||||
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
|
||||
}
|
||||
|
||||
const supplements = await this.prisma.lessonSupplement.aggregate({
|
||||
where: { userId, revokedAt: null }, _sum: { quantity: true },
|
||||
})
|
||||
|
||||
return {
|
||||
user: {
|
||||
userId: user.id,
|
||||
@@ -528,7 +543,7 @@ export class UserService {
|
||||
memberships: user.memberships.map((membership) => serializeMembership(membership)),
|
||||
stats: {
|
||||
totalBookings: stats.total,
|
||||
completedBookings: stats.completed,
|
||||
completedBookings: stats.completed + (supplements._sum.quantity ?? 0),
|
||||
cancelledBookings: stats.cancelled,
|
||||
noShowBookings: stats.noShow,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user