diff --git a/CLAUDE.md b/CLAUDE.md
index b3e1bae..5ba84c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -85,4 +85,9 @@ pnpm deploy:server # 部署后端到生产环境
### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用:weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
-- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
\ No newline at end of file
+- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
+### 历史课程补录
+- `LessonSupplement` 独立记录历史累计课时,归属 user 模块;DTO 放 `user/dto`,测试放 `user/__tests__`,前端入口为 `pages/admin/member-supplement.vue`。
+- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
+- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
+- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
diff --git a/deploy-server.sh b/deploy-server.sh
index d843409..5ca76d6 100755
--- a/deploy-server.sh
+++ b/deploy-server.sh
@@ -117,6 +117,7 @@ echo_info "文件上传完成"
# 6. 服务器端安装依赖并重启
echo_info "服务器部署操作..."
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
+set -e
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
SERVER_PATH="/usr/local/web/mp-pilates-server"
PM2_APP_NAME="mp-pilates-server"
diff --git a/docs/lesson-supplement.md b/docs/lesson-supplement.md
new file mode 100644
index 0000000..a9306ef
--- /dev/null
+++ b/docs/lesson-supplement.md
@@ -0,0 +1,29 @@
+# 历史课程补录
+
+入口:会员档案 → 上课情况 → 补录上课。一次填写 1–999 节,可备注,默认仅补记录,也可选择本人有限次会员卡扣次(含已过期卡)。不限次卡无需扣次;余额不足阻止提交。
+
+一批补录展示为「系统补录 · N 节」,不是 N 个虚拟预约。累计已上课次数(个人中心、会员档案、会员列表已完成)增加 N;累计预约、月度节数/天数/时长、最近 30 天网格、邀请奖励保持正常预约口径,因为补录没有真实上课日期。记录时间明确为补录时间。
+
+老师与管理员共用 ADMIN 权限;成员只能读取自己的生效记录。事务保存记录与扣次快照,请求标识唯一,网络重试不重复扣次。撤销软删除,保留操作人与时间,只返还当时实际扣次,过期卡不重新激活。若原卡已转为不限次,撤销阻止并要求先核对卡状态。
+
+## 迁移与发布
+
+新增 `lesson_supplements` 表及关联索引,不修改既有预约数据。按仓库发布流程先执行 `prisma migrate deploy` 并重新生成 Prisma Client、发布后端,再发布小程序。开发检查不自动触及生产数据库。
+
+回退:先回退应用代码,保留新增表即可,旧代码不读取该表。若一定要移除表,须先导出审计数据并核对已扣会员卡余额;仅在未写入任何补录的环境可执行 `DROP TABLE lesson_supplements;`。MySQL DDL 不能靠事务回滚,不在生产自动执行删除。
+
+## 本次验证
+
+- 补录与用户统计相关 62 项测试通过,覆盖权限、数量校验、仅补记录、扣次、余额不足、过期卡、重复提交、并发冲突、撤销和统计口径。
+- 共享包与后端构建、小程序构建、vue-tsc、Prisma schema 校验通过。
+- 使用真实 Vue 页面与示例接口检查 375/320 宽度布局、十节补录、撤销、余额不足及学员历史记录;不替代微信真机与真实数据库联调。
+- 全量测试存在原有 SchedulerService 测试依赖缺失:未提供 FlashSaleService,8 项失败;其余已执行用例通过。
+- 2026-09-08 已经用户授权执行生产迁移及后端发布;小程序仍需单独发布。
+
+## 生产发布记录(2026-09-08)
+
+- 执行 `pnpm deploy:server`,迁移 `20260908090000_add_lesson_supplements` 成功,全部 4 个迁移处于最新状态。
+- 发布前数据库及后端备份保存在服务器 `/usr/local/web/mp-pilates-backups/20260908-145852/`。
+- 新表读取成功,验收时为 0 条记录;未创建测试补录或修改学员余额。
+- 内网及公网 `https://focus.richarjiang.com/api/health` 均返回 `success: true`、`status: ok`;PM2 `mp-pilates-server` 为 online,重启计数为 0。
+- 部署脚本增加远端 `set -e`,使依赖安装或迁移失败时停止发布。
diff --git a/packages/app/src/components/LessonSupplementList.vue b/packages/app/src/components/LessonSupplementList.vue
new file mode 100644
index 0000000..c17d786
--- /dev/null
+++ b/packages/app/src/components/LessonSupplementList.vue
@@ -0,0 +1,39 @@
+
+
+
+
+ 系统补录{{ item.revokedAt ? '已撤销' : '历史课程' }}
+ {{ item.quantity }} 节
+
+ {{ formatDateTimeFull(item.createdAt) }} 补录
+ {{ item.deductedTimes ? `${item.cardName} · ${item.revokedAt ? '已返还' : '扣除'} ${item.deductedTimes} 次` : '仅补记录 · 未扣会员卡' }}
+ {{ item.remark }}
+
+
+
+
+
+
diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json
index b00b98e..dc7e828 100644
--- a/packages/app/src/pages.json
+++ b/packages/app/src/pages.json
@@ -111,6 +111,10 @@
"navigationStyle": "custom"
}
},
+ {
+ "path": "pages/admin/member-supplement",
+ "style": { "navigationStyle": "custom" }
+ },
{
"path": "pages/admin/member-arrange",
"style": {
diff --git a/packages/app/src/pages/admin/member-detail.vue b/packages/app/src/pages/admin/member-detail.vue
index 9ac6de9..2dc8f87 100644
--- a/packages/app/src/pages/admin/member-detail.vue
+++ b/packages/app/src/pages/admin/member-detail.vue
@@ -41,7 +41,10 @@
- 上课情况
+
+ 上课情况
+
+
{{ detail.stats.totalBookings }}
@@ -49,7 +52,7 @@
{{ detail.stats.completedBookings }}
- 已完成
+ 累计上课
{{ detail.stats.cancelledBookings }}
@@ -221,6 +224,10 @@ async function loadDetail() {
}
}
+function goSupplement() {
+ if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
+}
+
function goEdit() {
if (!userId.value) return
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
@@ -321,6 +328,7 @@ onShow(() => {
.section--last { padding-bottom: 24rpx; }
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
+.supplement-entry { margin: 0; padding: 8rpx 18rpx; line-height: 1.5; font-size: 23rpx; border-radius: 999rpx; background: #edf2ec; color: #617d73; &::after { border: none; } }
.section-note { font-size: 22rpx; color: var(--muted); }
.stats-strip {
display: flex; padding: 26rpx 0;
diff --git a/packages/app/src/pages/admin/member-supplement.vue b/packages/app/src/pages/admin/member-supplement.vue
new file mode 100644
index 0000000..a980406
--- /dev/null
+++ b/packages/app/src/pages/admin/member-supplement.vue
@@ -0,0 +1,211 @@
+
+
+
+ 正在读取会员资料…
+ {{ loadError || '暂时无法读取会员资料' }}
+
+ {{ loadError }}
+
+ 历史课程
+ 补齐每一节练习
+ 为 {{ detail.user.nickname || '这位学员' }} 补录已经上完的课程。
+ 累计上课{{ detail.stats.completedBookings }} 节
+
+ 有一笔补录正在核对。请点击下方重试,系统会避免重复计入。
+
+ 补录节数
+
+
+ 节已上课程
+
+
+ 可填写 1–999 节,无需逐节创建预约
+
+
+ 会员卡次数
+
+
+
+
+
+ 暂无可扣次的会员卡,请选择仅补记录。
+
+ {{ cardLabels[cardIndex] || '请选择会员卡' }}⌄
+
+ {{ insufficient ? '余额不足,请调整节数或更换会员卡' : `扣除 ${count} 次后,剩余 ${selectedCard.remainingTimes! - count} 次` }}
+ 过去已扣过的课程,请选择「仅补记录」。不限次卡无需扣次。
+
+
+
+ 备注选填
+
+ {{ remark.length }} / 200
+
+
+ {{ validQuantity ? `累计上课将增加 ${count} 节` : '填写已完成的课程节数' }}
+ 记录显示为「系统补录」。不计入本月上课、练习天数与最近 30 天网格。
+
+ {{ submitError }}
+
+ 补录记录{{ records.length }} 笔
+
+ 还没有补录记录
+
+
+
+
+
+
diff --git a/packages/app/src/pages/profile/bookings.vue b/packages/app/src/pages/profile/bookings.vue
index 40e6469..c9e1557 100644
--- a/packages/app/src/pages/profile/bookings.vue
+++ b/packages/app/src/pages/profile/bookings.vue
@@ -105,6 +105,12 @@
:refresher-triggered="refreshingHistory"
@refresherrefresh="onRefreshHistory"
>
+
+ 历史课程补录
+ 正在加载补录记录…
+
+
+
@@ -117,7 +123,7 @@
-
+
@@ -164,13 +170,26 @@
diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue
index f98d736..6edce38 100644
--- a/packages/app/src/pages/profile/membership.vue
+++ b/packages/app/src/pages/profile/membership.vue
@@ -1,162 +1,95 @@
-
+
-
-
-
+
+
-
-
-
还没有会员卡
- 购买会员卡后即可预约课程
-
- 去选购
-
+ 选一张适合自己的卡,开始第一次练习。
+
-
-
-
+
-
-
-
-
-
-
-
+
{{ m.cardType.name }}
-
- {{ getCardTypeLabel(m.cardType.type) }}
-
+ {{ getCardTypeLabel(m.cardType.type) }}
-
-
- 有效
+ 有效
+
+
+
+ {{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}
+ {{ m.remainingTimes !== null ? '次可用' : '天剩余' }}
+
+ 有效期内不限次数
+
+
+ 已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} 次
-
-
-
- {{ m.remainingTimes }}
- 次剩余
-
-
-
-
-
- 已用 {{ getMembershipUsedTimes(m) }},共 {{ getMembershipTotalTimes(m) }} 次
-
-
-
-
-
-
- {{ daysRemaining(m) }}
- 天剩余
-
-
-
- 开始
+ 开始日期
{{ m.startDate.slice(0, 10) }}
-
-
- 到期
- {{ m.expireDate.slice(0, 10) }}
-
-
-
-
- 续卡
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ m.cardType.name }}
-
- {{ getCardTypeLabel(m.cardType.type) }}
-
-
-
- {{ statusLabel(m.status) }}
-
-
-
-
-
- 剩余
- {{ m.remainingTimes }} 次
-
-
+
有效期至
{{ m.expireDate.slice(0, 10) }}
+
+
+
+
+
+
-
- 续同款
+
+
+
+
+
+ {{ m.cardType.name }}
+ {{ getCardTypeLabel(m.cardType.type) }}
+
+ {{ statusLabel(m.status) }}
+
+
+
+ 剩余 {{ m.remainingTimes }} 次
+ {{ m.expireDate.slice(0, 10) }} 到期
+
+
-
-
-
-
- + 购买会员卡
+
+
diff --git a/packages/app/src/stores/admin.ts b/packages/app/src/stores/admin.ts
index a05f086..2a1b5aa 100644
--- a/packages/app/src/stores/admin.ts
+++ b/packages/app/src/stores/admin.ts
@@ -20,6 +20,8 @@ import type {
StudioUploadCredential,
AdminMemberSummary,
AdminMemberDetail,
+ LessonSupplementRecord,
+ CreateLessonSupplementDto,
UpdateAdminMemberProfileDto,
AdminArrangeBookingDto,
MembershipWithCardType,
@@ -173,6 +175,18 @@ export const useAdminStore = defineStore('admin', () => {
return get(`/admin/members/${userId}/membership`)
}
+ async function fetchLessonSupplements(userId: string): Promise {
+ return get(`/admin/members/${userId}/lesson-supplements`)
+ }
+
+ async function createLessonSupplement(userId: string, dto: CreateLessonSupplementDto): Promise {
+ return post(`/admin/members/${userId}/lesson-supplements`, { ...dto })
+ }
+
+ async function revokeLessonSupplement(userId: string, id: string): Promise {
+ await post(`/admin/members/${userId}/lesson-supplements/${id}/revoke`)
+ }
+
async function fetchMemberDetail(userId: string): Promise {
return get(`/admin/members/${userId}`)
}
@@ -295,6 +309,9 @@ export const useAdminStore = defineStore('admin', () => {
// Members
fetchMembers,
fetchMemberDetail,
+ fetchLessonSupplements,
+ createLessonSupplement,
+ revokeLessonSupplement,
updateMemberProfile,
arrangeMemberBooking,
getUserMembership,
diff --git a/packages/app/src/utils/request.ts b/packages/app/src/utils/request.ts
index 6fee6f1..c07b1e4 100644
--- a/packages/app/src/utils/request.ts
+++ b/packages/app/src/utils/request.ts
@@ -11,6 +11,13 @@ interface RequestOptions {
readonly header?: Record
}
+export class HttpRequestError extends Error {
+ constructor(message: string, readonly statusCode: number) {
+ super(message)
+ this.name = 'HttpRequestError'
+ }
+}
+
export function request(options: RequestOptions): Promise {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
@@ -34,7 +41,7 @@ export function request(options: RequestOptions): Promise {
}
if (res.statusCode >= 400) {
const body = res.data as ApiResponse
- reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
+ reject(new HttpRequestError(body?.message || `请求失败 (${res.statusCode})`, res.statusCode))
return
}
const body = res.data as ApiResponse
diff --git a/packages/server/prisma/migrations/20260908090000_add_lesson_supplements/migration.sql b/packages/server/prisma/migrations/20260908090000_add_lesson_supplements/migration.sql
new file mode 100644
index 0000000..f9a7eab
--- /dev/null
+++ b/packages/server/prisma/migrations/20260908090000_add_lesson_supplements/migration.sql
@@ -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;
+
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 644b879..d927e93 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -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")
+}
diff --git a/packages/server/src/user/__tests__/lesson-supplement.service.spec.ts b/packages/server/src/user/__tests__/lesson-supplement.service.spec.ts
new file mode 100644
index 0000000..9bc4296
--- /dev/null
+++ b/packages/server/src/user/__tests__/lesson-supplement.service.spec.ts
@@ -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)
+ })
+})
diff --git a/packages/server/src/user/__tests__/user.service.spec.ts b/packages/server/src/user/__tests__/user.service.spec.ts
index 5e1d862..2378a59 100644
--- a/packages/server/src/user/__tests__/user.service.spec.ts
+++ b/packages/server/src/user/__tests__/user.service.spec.ts
@@ -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,
})
diff --git a/packages/server/src/user/dto/create-lesson-supplement.dto.ts b/packages/server/src/user/dto/create-lesson-supplement.dto.ts
new file mode 100644
index 0000000..8d3320f
--- /dev/null
+++ b/packages/server/src/user/dto/create-lesson-supplement.dto.ts
@@ -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
+}
diff --git a/packages/server/src/user/lesson-supplement.controller.ts b/packages/server/src/user/lesson-supplement.controller.ts
new file mode 100644
index 0000000..6f3177e
--- /dev/null
+++ b/packages/server/src/user/lesson-supplement.controller.ts
@@ -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 }
+ }
+}
diff --git a/packages/server/src/user/lesson-supplement.service.ts b/packages/server/src/user/lesson-supplement.service.ts
new file mode 100644
index 0000000..ff76e11
--- /dev/null
+++ b/packages/server/src/user/lesson-supplement.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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('会员卡已发生变化,请刷新后重试')
+ }
+ })
+ }
+}
diff --git a/packages/server/src/user/user.module.ts b/packages/server/src/user/user.module.ts
index 4d450cd..984e9ef 100644
--- a/packages/server/src/user/user.module.ts
+++ b/packages/server/src/user/user.module.ts
@@ -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 {}
diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts
index b7f6be3..70497a3 100644
--- a/packages/server/src/user/user.service.ts
+++ b/packages/server/src/user/user.service.ts
@@ -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,
},
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index afd2c2f..7c81529 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -62,6 +62,8 @@ export type {
AdminMemberUpcomingBooking,
AdminMemberBookingStats,
AdminMemberDetail,
+ LessonSupplementRecord,
+ CreateLessonSupplementDto,
UpdateAdminMemberProfileDto,
CardType,
CreateCardTypeDto,
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 73a150c..c7f94df 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -8,6 +8,8 @@ export type {
AdminMemberUpcomingBooking,
AdminMemberBookingStats,
AdminMemberDetail,
+ LessonSupplementRecord,
+ CreateLessonSupplementDto,
UpdateAdminMemberProfileDto,
} from './user'
export type {
diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts
index f3f49ed..7090be0 100644
--- a/packages/shared/src/types/user.ts
+++ b/packages/shared/src/types/user.ts
@@ -95,3 +95,23 @@ export interface UpdateAdminMemberProfileDto {
readonly nickname?: string
readonly phone?: string | null
}
+
+/** Historical classes with no inferred attendance date. */
+export interface LessonSupplementRecord {
+ readonly id: string
+ readonly requestId: string
+ readonly quantity: number
+ readonly deductedTimes: number
+ readonly cardName: string | null
+ readonly remark: string | null
+ readonly operatorName: string
+ readonly createdAt: string
+ readonly revokedAt: string | null
+}
+
+export interface CreateLessonSupplementDto {
+ readonly requestId: string
+ readonly quantity: number
+ readonly membershipId?: string
+ readonly remark?: string
+}