diff --git a/docs/lesson-apparatus-feedback.md b/docs/lesson-apparatus-feedback.md
new file mode 100644
index 0000000..5867bdf
--- /dev/null
+++ b/docs/lesson-apparatus-feedback.md
@@ -0,0 +1,24 @@
+# 按器械填写课后评价
+
+教练从已完成课程详情的「为这次课写评语」进入,评价继续关联该预约与学员。成长档案的「寄语 → 写评价」也可以记录不关联课程的教练笔记。
+
+- 保留整体评价,新增 M、R、TT、Chair、ARC、SC、LB 七项自由文本,每项最多 1000 字。
+- 所有字段均选填;整体评价或任意一项器械有非空白内容即可保存。空白器械不入库、不展示。
+- 按器械展开填写,收起保留摘要;保存失败保留当前输入,可重试。主动关闭时会提醒放弃未保存内容。
+- 继续沿用「分享给学员」开关,默认关闭。学员只可读取已共享笔记。历史笔记无需转换。
+
+## 部署
+
+新增可空 MySQL JSON 字段 `member_notes.apparatus_notes`,迁移为 `20261018100000_lesson_apparatus_notes`。先在目标环境执行 `pnpm --filter @mp-pilates/server exec prisma migrate deploy`,重新生成 Prisma Client 并部署后端,然后发布小程序。此命令会部署目标库中所有尚未执行的迁移,应按现有发布流程核对。
+
+本次仅添加迁移文件,未对任何数据库执行迁移。
+
+## 验收
+
+1. 从一节已完成课程进入,仅填写 M、仅填写整体评价、填写多种器械,分别保存;检查关联课程、换行及各器械内容。
+2. 所有输入为空或只有空格时不可保存。填满 1000 字,检查计数和输入上限。
+3. 展开、收起、切换器械,检查内容摘要与填写数量;滚动至 LB,检查软键盘、输入框和底部按钮在微信 iOS / Android 的可见性。
+4. 保存时连续点击不重复提交;断网保存后内容保留,恢复网络可重试;关闭未保存表单可选择继续填写。
+5. 默认私密记录仅馆主可见,开启共享后学员端按器械分段展示;未填写的器械不显示,历史整体评价仍显示。
+
+本地已验证共享包构建、前端类型检查、小程序构建、后端构建,以及完整 Jest 测试。本地浏览器组件预览用于验证展开、切换、填写计数、仅器械保存及分段展示;不替代微信真机验收。
diff --git a/docs/membership-periods.md b/docs/membership-periods.md
new file mode 100644
index 0000000..0353682
--- /dev/null
+++ b/docs/membership-periods.md
@@ -0,0 +1,51 @@
+# 月卡周期与续费规则
+
+## 已确认规则
+
+- 月卡(DURATION)每次购买生成独立会员卡周期,不向旧周期累加课时,不覆盖旧卡、订单和预约关联。
+- 旧周期尚未到期时,新周期从同款卡已购周期的最晚到期时间开始。连续购买按支付确认顺序排期,每期额度独立。
+- 旧周期已结束时,新周期从支付确认时间开始。旧月卡即使账面还有 6 次,也不可继续使用,不会与新购 10 次合成 16 次。
+- 次卡(TIMES)保留现有规则:有效期内续费累加次数、延长有效期;过期余次不结转。体验卡仍为独立新卡。
+- 原有月卡沿用已经约定的有效期,不按开卡日倒推缩短,不批量改写历史余额。
+
+## 状态与展示
+
+新增 `SCHEDULED`(待生效)状态,数据库迁移只在原枚举末尾追加值,不改写现有记录。
+
+读取会员权益时,依据服务端时间实时判断生效、到期和可用次数,不依赖凌晨任务。到期的限次月卡返回 0 次可用权益;原始账面余额留在旧记录中用于历史核对。待生效额度不会计入当前余额。
+
+会员页分为「正在使用」「下期待生效」「历史记录」。续费页显示本期剩余与下期起止时间/独立额度,提前续费提示「支付后排期待生效」。页面打开期间在生效和到期边界刷新展示。
+
+## 提前启用
+
+接口:`POST /membership/:id/activate`,要求登录及 `{ "confirmed": true }`,仅操作登录用户自己的周期。
+
+只能启用同款卡最早的待生效周期。本期必须已经用完次数;不限次月卡或仍有余次的周期不允许提前结束。
+
+启用前先检查本期未完成预约;如仍有未来待确认/已确认课程,需上完或取消后再启用。新周期从启用时刻开始,保留完整已购时长,后续待生效周期连续重新衔接。已经预约到新周期或后续周期的课程必须仍在调整后的范围内,否则拒绝操作并提示调整预约,不自动取消课程。
+
+购卡排期、提前启用及预约写入使用同一会员的数据库行锁串行处理。启用事务只修改周期日期与状态,不改变订单和预约所关联的周期 ID。
+
+## 预约与扣次
+
+月卡可提前预约已付款周期内的课程,但课程起止时间必须完全落在所选周期内。上课时间按场馆中国时区计算。学员预约、馆主代约及确认预约均验证周期,不能借用另一期额度。
+
+仍由教练确认预约时扣次;未来周期被确认预约后保留待生效状态。取消后退回原周期,不转入其他新卡;已过期月卡的退回次数不会变为可用权益。
+
+## 发布与兼容
+
+1. 备份生产数据库和当前 server/shared 构建及 Prisma Client。
+2. 应用 `20261018090000_scheduled_membership_periods`,生成对应 Prisma Client,再发布 shared/server。
+3. 发布小程序新版本,才能显示独立周期、正确续卡预览及提前启用入口。旧版内置的续卡计算不会随服务端更新,应配套发布小程序。
+
+没有新增环境变量,不需要清空或重建已有会员数据。新周期与历史记录共用原有 memberships、orders、bookings 关系,既有购买不会重新发卡。
+
+验证包括月卡 6 + 新购 10 分期保存、过期与临界时刻、连续购买排期、提前启用确认与归属、已有预约保护、课程周期边界、支付确认发放新周期、次卡原逻辑,以及共享/服务端/小程序构建。
+
+## 本次验证(2026-10-18)
+
+- 当前工作区完整服务端测试通过:34 个套件、545 项测试。
+- shared/server 构建、小程序类型检查和微信小程序生产构建通过。
+- `pnpm lint` 无法执行:现有环境未安装脚本引用的 `eslint`;未为此调整依赖或锁文件。
+- 使用真实 Vue 页面与模拟数据检查了 390px 宽度下的未过期续卡、已过期续卡、待生效周期列表、提前启用确认及成功后的日期/额度更新;未发起真实支付或修改真实会员权益。
+- 本次月卡迁移及服务端改动尚未部署生产,新版小程序尚未上传或发布。
diff --git a/packages/app/src/components/ApparatusNoteList.vue b/packages/app/src/components/ApparatusNoteList.vue
new file mode 100644
index 0000000..7d7342c
--- /dev/null
+++ b/packages/app/src/components/ApparatusNoteList.vue
@@ -0,0 +1,16 @@
+
+
+ 器械练习{{ entries.length }} 项记录
+ {{ entry.apparatus }}{{ entry.content }}
+
+
+
+
diff --git a/packages/app/src/components/BookingConfirmPopup.vue b/packages/app/src/components/BookingConfirmPopup.vue
index a38a6cb..953a252 100644
--- a/packages/app/src/components/BookingConfirmPopup.vue
+++ b/packages/app/src/components/BookingConfirmPopup.vue
@@ -44,6 +44,7 @@
{{ memberships[0].cardType.name }}
+ 周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}
剩余 {{ memberships[0].remainingTimes }} 次
@@ -68,6 +69,7 @@
>
{{ m.cardType.name }}
+ 周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}
剩余 {{ m.remainingTimes }} 次
@@ -92,7 +94,7 @@
{{ selectedMembership.remainingTimes === null
? `「${selectedMembership.cardType.name}」有效期内不限次`
- : `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
+ : `教练确认后将从「${selectedMembership.cardType.name}」对应周期扣除 1 次课时` }}
@@ -124,6 +126,7 @@
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
+import { formatChinaDate } from '../utils/format'
const props = defineProps<{
visible: boolean
diff --git a/packages/app/src/components/CoachNoteEditor.vue b/packages/app/src/components/CoachNoteEditor.vue
new file mode 100644
index 0000000..01d1ea2
--- /dev/null
+++ b/packages/app/src/components/CoachNoteEditor.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+ 整体评价选填
+
+
+ {{ content.length }} / {{ LESSON_NOTE_MAX_LENGTH }}
+
+ 器械练习记录点击器械展开,记录本次上课情况已填写 {{ filledCount }} / 7
+
+
+
+
+
+
+
+
+
+ {{ notes[apparatus]?.trim() }}
+
+
+ 分享给学员{{ shared ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }}
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/MemberProgress.vue b/packages/app/src/components/MemberProgress.vue
index 5a4d665..135c3a5 100644
--- a/packages/app/src/components/MemberProgress.vue
+++ b/packages/app/src/components/MemberProgress.vue
@@ -12,12 +12,13 @@
- {{ admin ? '教练笔记' : '教练寄语' }}
+ {{ admin ? '教练笔记' : '教练寄语' }}
{{ admin ? '私密笔记仅馆主可见,开启共享后学员才能看到。' : '留住被看见的进步,也记下下一次练习的方向。' }}
静待第一份练习寄语教练分享的课后反馈会留在这里。
{{ note.shared ? '教练寄语 · 学员可见' : '私密 · 仅馆主可见' }}
- {{ note.content }}
+ {{ note.content }}
+
课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }}
{{ formatChinaDate(note.createdAt) }}
@@ -44,12 +45,12 @@
A MOMENT TO REMEMBER{{ n }}节{{ milestoneTitles[n] }}{{ archive.milestones.includes(n) ? '你已走过这段旅程' : '还差 ' + (n - archive.completedCount) + ' 节,一步步来' }}
-
- {{ editor === 'notes' ? '写一份教练笔记' : '添加成长照片' }}
- 记录日期{{ recordDate }} ›
- 已关联本次完成课程{{ noteContent.length }} / 1000分享给学员{{ shared ? '这份评语将出现在学员档案' : '仅馆主自己可见' }}
- 仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。
- {{ saveError }}
+
+
+ 添加成长照片
+ 记录日期{{ recordDate }} ›
+ 仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。
+ {{ saveError }}
@@ -59,6 +60,8 @@
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import BodyAssessmentRecords from './BodyAssessmentRecords.vue'
+import CoachNoteEditor from './CoachNoteEditor.vue'
+import ApparatusNoteList from './ApparatusNoteList.vue'
import type { ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared'
import { get, post, del } from '../utils/request'
import { formatChinaDate } from '../utils/format'
@@ -66,7 +69,7 @@ const props = defineProps<{ userId?: string; admin?: boolean; bookingId?: string
const archive = ref(null), loading = ref(false), error = ref(''), tab = ref('metrics'), editor = ref(''), busy = ref(false), saveError = ref('')
const tabs = [{ key: 'metrics', name: '体测' }, { key: 'notes', name: '寄语' }, { key: 'photos', name: '照片' }, { key: 'milestones', name: '里程碑' }]
const base = computed(() => props.admin ? `/admin/members/${props.userId}/progress` : '/user/progress')
-const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), recordDate = ref(today), noteContent = ref(''), shared = ref(false), caption = ref(''), photoPath = ref('')
+const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), recordDate = ref(today), caption = ref(''), photoPath = ref('')
const photoUrls = ref>({}), selectedPhotos = ref([]), comparison = ref<{ id: string; url: string; date: string }[]>([])
const milestoneTitles: Record = { 10: '开始,与身体相识', 30: '坚持,找到自己的节奏', 50: '积累,看见自己的力量' }
const instance = getCurrentInstance()
@@ -85,14 +88,11 @@ async function load() {
finally { if (seq === sequence && isInitial) loading.value = false }
}
function previewComparison(url: string) { uni.previewImage({ urls: comparison.value.map(p => p.url), current: url }) }
-function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
function message(e: unknown) { return e instanceof Error ? e.message : '操作失败,请重试' }
function openEditor(kind: string) {
openedAt = Date.now()
editor.value = kind
saveError.value = ''
- noteContent.value = ''
- shared.value = false
caption.value = ''
photoPath.value = ''
recordDate.value = today
@@ -103,15 +103,10 @@ async function save() {
if (busy.value) return
busy.value = true; saveError.value = ''
try {
- if (editor.value === 'notes') {
- if (!noteContent.value.trim()) throw new Error('请先填写笔记')
- await post(base.value + '/notes', { content: noteContent.value, shared: shared.value, ...(props.bookingId ? { bookingId: props.bookingId } : {}) })
- } else {
- if (!photoPath.value) throw new Error('请先选择照片')
- const credentials = await post<{ id: string; uploadUrl: string; formData: Record }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
- await new Promise((resolve, reject) => uni.uploadFile({ url: credentials.uploadUrl, filePath: photoPath.value, name: 'file', formData: credentials.formData, success: res => res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('照片上传失败')), fail: () => reject(new Error('照片上传失败,请检查网络')) }))
- await post(base.value + `/photos/${credentials.id}/complete`)
- }
+ if (!photoPath.value) throw new Error('请先选择照片')
+ const credentials = await post<{ id: string; uploadUrl: string; formData: Record }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
+ await new Promise((resolve, reject) => uni.uploadFile({ url: credentials.uploadUrl, filePath: photoPath.value, name: 'file', formData: credentials.formData, success: res => res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('照片上传失败')), fail: () => reject(new Error('照片上传失败,请检查网络')) }))
+ await post(base.value + `/photos/${credentials.id}/complete`)
editor.value = ''; await load(); uni.showToast({ title: '记录已保存', icon: 'success' })
} catch (e) { saveError.value = message(e) } finally { busy.value = false }
}
diff --git a/packages/app/src/pages/admin/member-arrange.vue b/packages/app/src/pages/admin/member-arrange.vue
index dc34a00..c3ab80f 100644
--- a/packages/app/src/pages/admin/member-arrange.vue
+++ b/packages/app/src/pages/admin/member-arrange.vue
@@ -29,6 +29,7 @@
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
+ {{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}
@@ -108,7 +109,7 @@ import type {
AdminMemberDetail,
ScheduleSlotPreview,
} from '@mp-pilates/shared'
-import { MembershipStatus, TIME_PERIODS, TimeSlotStatus } from '@mp-pilates/shared'
+import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, canUseMembershipForSlot } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
@@ -134,7 +135,7 @@ const arranging = ref(false)
const usableMemberships = computed(() =>
(detail.value?.memberships ?? []).filter((item) =>
- item.status === MembershipStatus.ACTIVE
+ (item.status === MembershipStatus.ACTIVE || item.status === MembershipStatus.SCHEDULED)
&& (item.remainingTimes === null || item.remainingTimes > 0)
&& new Date(item.expireDate) > new Date(),
),
@@ -169,6 +170,7 @@ const filteredSlots = computed(() => {
function canPickSlot(slot: ScheduleSlotPreview): boolean {
if (!canArrange.value) return false
+ if (!selectedMembership.value || !canUseMembershipForSlot(selectedMembership.value, slot)) return false
if (isSlotPast(slot.date, slot.startTime)) return false
if (slot.status === TimeSlotStatus.CLOSED) return false
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return false
@@ -176,6 +178,7 @@ function canPickSlot(slot: ScheduleSlotPreview): boolean {
}
function slotLabel(slot: ScheduleSlotPreview): string {
+ if (selectedMembership.value && !canUseMembershipForSlot(selectedMembership.value, slot)) return '周期外'
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
diff --git a/packages/app/src/pages/admin/member-detail.vue b/packages/app/src/pages/admin/member-detail.vue
index 7af4dc6..b76ef05 100644
--- a/packages/app/src/pages/admin/member-detail.vue
+++ b/packages/app/src/pages/admin/member-detail.vue
@@ -187,13 +187,14 @@ const detail = ref(null)
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
function isArrangableMembership(membership: MembershipWithCardType): boolean {
- if (membership.status !== MembershipStatus.ACTIVE) return false
+ if (membership.status !== MembershipStatus.ACTIVE && membership.status !== MembershipStatus.SCHEDULED) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
return new Date(membership.expireDate) > new Date()
}
function membershipStatusLabel(status: string): string {
const map: Record = {
+ [MembershipStatus.SCHEDULED]: '待生效',
ACTIVE: '有效',
EXPIRED: '已过期',
USED_UP: '已用完',
diff --git a/packages/app/src/pages/booking/index.vue b/packages/app/src/pages/booking/index.vue
index 37460ec..ea65ce2 100644
--- a/packages/app/src/pages/booking/index.vue
+++ b/packages/app/src/pages/booking/index.vue
@@ -77,7 +77,7 @@
v-if="showConfirmPopup"
:visible="true"
:time-slot="pendingSlot"
- :memberships="userStore.activeMemberships as MembershipWithCardType[]"
+ :memberships="slotMemberships"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
/>
@@ -88,7 +88,7 @@
import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
-import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
+import { BookingStatus, TIME_PERIODS, canUseMembershipForSlot } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
@@ -111,6 +111,7 @@ const selectedDate = ref(formatDate(new Date()))
const selectedPeriod = ref(null)
const showConfirmPopup = ref(false)
const pendingSlot = ref(null)
+const slotMemberships = computed(() => pendingSlot.value ? userStore.memberships.filter(m => canUseMembershipForSlot(m, pendingSlot.value!)) : [])
const refreshing = ref(false)
const targetSlotId = ref('')
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后,
@@ -327,10 +328,10 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
}
// 2. Ensure has valid membership
- if (!userStore.hasValidMembership) {
+ if (!userStore.memberships.some(m => canUseMembershipForSlot(m, slot))) {
uni.showModal({
title: '暂无可用会员卡',
- content: '您当前没有有效的会员卡,购买后即可预约课程',
+ content: '该课程时间不在现有会员卡可用周期内,或对应周期课时已用完。可查看会员卡或购买新的周期。',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue
index 83f533e..eb3ea4d 100644
--- a/packages/app/src/pages/card/detail.vue
+++ b/packages/app/src/pages/card/detail.vue
@@ -221,6 +221,7 @@
{{ renewalPreview.currentHighlight }}
{{ renewalPreview.highlightUnit }}
{{ renewalPreview.currentExpire }}
+ {{ renewalPreview.currentAllowance }}
@@ -231,7 +232,9 @@
确认后
{{ renewalPreview.afterHighlight }}
{{ renewalPreview.highlightUnit }}
+ {{ renewalPreview.afterStart }}
{{ renewalPreview.afterExpire }}
+ {{ renewalPreview.afterAllowance }}
@@ -341,8 +344,8 @@
diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue
index 7a48ff0..a563bf2 100644
--- a/packages/app/src/pages/profile/membership.vue
+++ b/packages/app/src/pages/profile/membership.vue
@@ -43,6 +43,15 @@
已用次数包含预约扣次,不等同于已完成上课;进度条表示已用次数占比。
+
+
+
+ {{ m.cardType.name }}{{ m.remainingTimes === null ? '有效期内不限次' : '周期额度剩余 ' + m.remainingTimes + ' 次' }}待生效
+ {{ formatDateTimeFull(m.startDate) }} 生效{{ formatDateTimeFull(m.expireDate) }} 到期
+
+ 下期课时与本期分别计算,不计入当前可用余额。预约需落在对应周期内。
+
+
- 剩余 {{ m.remainingTimes }} 次
+ 原周期未用课时已失效
+ 剩余 {{ m.remainingTimes }} 次
{{ m.expireDate.slice(0, 10) }} 到期
@@ -75,13 +85,15 @@
diff --git a/packages/app/src/stores/user.ts b/packages/app/src/stores/user.ts
index 084088f..4300b26 100644
--- a/packages/app/src/stores/user.ts
+++ b/packages/app/src/stores/user.ts
@@ -11,6 +11,7 @@ import {
UserRole,
MembershipStatus,
getMembershipRenewalHint,
+ getMembershipEffectiveState,
} from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { setUnauthorizedHandler, setRenewedHandler } from '../utils/session'
@@ -120,7 +121,8 @@ export const useUserStore = defineStore('user', () => {
try {
const result = await get('/membership/my')
if (id !== membershipRequestId || session !== sessionId) return false
- memberships.value = [...result]
+ const now = new Date()
+ memberships.value = result.map(membership => ({ ...membership, ...getMembershipEffectiveState(membership, now) }))
membershipsLoaded.value = true
return true
} catch {
diff --git a/packages/server/prisma/migrations/20261018090000_scheduled_membership_periods/migration.sql b/packages/server/prisma/migrations/20261018090000_scheduled_membership_periods/migration.sql
new file mode 100644
index 0000000..963c545
--- /dev/null
+++ b/packages/server/prisma/migrations/20261018090000_scheduled_membership_periods/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `memberships`
+ MODIFY `status` ENUM('ACTIVE', 'EXPIRED', 'USED_UP', 'SCHEDULED') NOT NULL DEFAULT 'ACTIVE';
diff --git a/packages/server/prisma/migrations/20261018100000_lesson_apparatus_notes/migration.sql b/packages/server/prisma/migrations/20261018100000_lesson_apparatus_notes/migration.sql
new file mode 100644
index 0000000..a6fc5ce
--- /dev/null
+++ b/packages/server/prisma/migrations/20261018100000_lesson_apparatus_notes/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `member_notes` ADD COLUMN `apparatus_notes` JSON NULL;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 1934d78..bf50195 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -24,6 +24,7 @@ enum MembershipStatus {
ACTIVE
EXPIRED
USED_UP
+ SCHEDULED
}
enum TimeSlotStatus {
@@ -404,6 +405,7 @@ model MemberNote {
userId String @map("user_id")
bookingId String? @map("booking_id")
content String @db.VarChar(1000)
+ apparatusNotes Json? @map("apparatus_notes")
shared Boolean @default(false)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
diff --git a/packages/server/src/auth/__tests__/auth.service.spec.ts b/packages/server/src/auth/__tests__/auth.service.spec.ts
index ede6647..a128e54 100644
--- a/packages/server/src/auth/__tests__/auth.service.spec.ts
+++ b/packages/server/src/auth/__tests__/auth.service.spec.ts
@@ -230,7 +230,10 @@ describe('AuthService', () => {
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
where: {
userId: USER_ID,
- status: MembershipStatus.ACTIVE,
+ status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
+ startDate: { lte: expect.any(Date) },
+ expireDate: { gt: expect.any(Date) },
+ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
},
})
expect(result.user.activeMembershipCount).toBe(2)
diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts
index ae9f29a..eb79d0d 100644
--- a/packages/server/src/auth/auth.service.ts
+++ b/packages/server/src/auth/auth.service.ts
@@ -1,3 +1,4 @@
+import { currentMembershipWhere } from '../membership/membership-query'
import { Inject, Injectable, Optional, UnauthorizedException, forwardRef } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
@@ -99,7 +100,7 @@ export class AuthService {
const activeMembershipCount = await this.prisma.membership.count({
where: {
userId: user.id,
- status: MembershipStatus.ACTIVE,
+ ...currentMembershipWhere(),
},
})
diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts
index 10da4d0..a8a5e3a 100644
--- a/packages/server/src/booking/__tests__/booking.service.spec.ts
+++ b/packages/server/src/booking/__tests__/booking.service.spec.ts
@@ -84,6 +84,7 @@ const mockActiveMembership = {
const mockDurationMembership = {
...mockActiveMembership,
id: 'mem-duration-001',
+ expireDate: new Date('2100-01-01'),
cardTypeId: mockDurationCardType.id,
remainingTimes: null,
cardType: mockDurationCardType,
@@ -138,6 +139,7 @@ const mockStudioConfig = {
function buildTxMock(overrides: Record = {}) {
return {
+ $queryRaw: jest.fn(),
timeSlot: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -255,6 +257,54 @@ describe('BookingService', () => {
afterEach(() => jest.clearAllMocks())
+ describe('monthly period booking boundaries', () => {
+ const planned = { ...mockLimitedDurationMembership, startDate:new Date('2099-12-30T00:00:00Z'),expireDate:new Date('2100-01-29T00:00:00Z'),status:MembershipStatus.SCHEDULED }
+ it('lets a paid future period reserve only its own dates and keeps its quota separate',async()=>{
+ const tx=buildTxMock()
+ tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
+ tx.booking.findUnique.mockResolvedValue(null)
+ tx.membership.findUnique.mockResolvedValue(planned)
+ tx.booking.create.mockResolvedValue(mockConfirmedBooking)
+ ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
+ ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned})
+ ;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
+ await service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})
+ expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})}))
+ expect(tx.membership.update).not.toHaveBeenCalled()
+ })
+ it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{
+ const tx=buildTxMock()
+ const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')}
+ tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
+ tx.booking.findUnique.mockResolvedValue(null)
+ tx.membership.findUnique.mockResolvedValue(membership)
+ ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
+ await expect(service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:membership.id})).rejects.toThrow('周期')
+ expect(tx.booking.create).not.toHaveBeenCalled()
+ })
+ it('deducts a reserved future period without activating it ahead of time',async()=>{
+ const tx=buildTxMock()
+ tx.booking.findUnique.mockResolvedValue({...mockConfirmedBooking,status:BookingStatus.PENDING_CONFIRMATION,timeSlot:mockOpenSlot,membership:planned,membershipId:planned.id})
+ tx.booking.update.mockResolvedValue(mockConfirmedBooking)
+ ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
+ ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned})
+ ;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
+ studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
+ await service.confirmBooking(MOCK_BOOKING_ID,MOCK_ADMIN_ID)
+ expect(tx.membership.update).toHaveBeenCalledWith({where:{id:planned.id},data:{remainingTimes:4,status:MembershipStatus.SCHEDULED}})
+ })
+ it('rejects administrative arrangement outside the selected period',async()=>{
+ const tx=buildTxMock()
+ tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
+ tx.user.findUnique.mockResolvedValue({id:MOCK_USER_ID})
+ tx.booking.findUnique.mockResolvedValue(null)
+ tx.membership.findUnique.mockResolvedValue({...planned,startDate:new Date('2100-01-01T00:00:00Z')})
+ ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
+ await expect(service.adminArrangeBooking(MOCK_ADMIN_ID,{userId:MOCK_USER_ID,timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})).rejects.toThrow('周期')
+ expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
+ })
+ })
+
describe('confirmBooking', () => {
it('sends booking confirmed subscription message after admin confirmation', async () => {
const tx = buildTxMock({
diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts
index 776068a..f3fc53f 100644
--- a/packages/server/src/booking/booking.service.ts
+++ b/packages/server/src/booking/booking.service.ts
@@ -14,6 +14,8 @@ import {
TimeSlotStatus,
type TeachingScheduleSlot,
type PracticeActivity,
+ canUseMembershipForSlot,
+ getMembershipEffectiveState,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
@@ -76,6 +78,7 @@ export class BookingService {
dto: CreateBookingDto,
): Promise {
const booking = await this.prisma.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
// 1. Fetch and validate timeSlot
const timeSlot = await tx.timeSlot.findUnique({
where: { id: dto.timeSlotId },
@@ -117,7 +120,7 @@ export class BookingService {
if (membership.userId !== userId) {
throw new ForbiddenException('This membership does not belong to you')
}
- if (membership.status !== MembershipStatus.ACTIVE) {
+ if (membership.status !== MembershipStatus.ACTIVE && !(membership.cardType.type === CardTypeCategory.DURATION && membership.status === MembershipStatus.SCHEDULED)) {
throw new BadRequestException(
`Membership is not active (status: ${membership.status})`,
)
@@ -127,6 +130,7 @@ export class BookingService {
if (membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
+ if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
let originAssessmentId: string | undefined
if (dto.originAssessmentId) {
@@ -214,6 +218,7 @@ export class BookingService {
remark?: string,
): Promise {
const booking = await this.prisma.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT u.id FROM users u INNER JOIN bookings b ON b.user_id = u.id WHERE b.id = ${bookingId} FOR UPDATE`
// 1. Find booking with timeSlot and membership
const existing = await tx.booking.findUnique({
where: { id: bookingId },
@@ -237,6 +242,7 @@ export class BookingService {
if (existing.membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
+ if (existing.membership.status === MembershipStatus.EXPIRED) throw new BadRequestException('Membership has expired')
const isCountLimited = existing.membership.remainingTimes !== null
@@ -245,6 +251,7 @@ export class BookingService {
throw new BadRequestException('No remaining times on this membership')
}
}
+ if (existing.membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(existing.membership, existing.timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
// 3. Update booking status to CONFIRMED
const updated = await tx.booking.update({
@@ -273,8 +280,7 @@ export class BookingService {
// 5. Deduct membership times
if (isCountLimited) {
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
- const newMembershipStatus =
- newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
+ const newMembershipStatus = getMembershipEffectiveState({ ...existing.membership, remainingTimes: newRemainingTimes, status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE }).status
await tx.membership.update({
where: { id: existing.membershipId },
@@ -309,6 +315,7 @@ export class BookingService {
dto: AdminArrangeBookingDto,
): Promise {
const booking = await this.prisma.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT id FROM users WHERE id = ${dto.userId} FOR UPDATE`
const timeSlot = await tx.timeSlot.findUnique({
where: { id: dto.timeSlotId },
})
@@ -354,7 +361,7 @@ export class BookingService {
if (membership.userId !== dto.userId) {
throw new ForbiddenException('This membership does not belong to the member')
}
- if (membership.status !== MembershipStatus.ACTIVE) {
+ if (membership.status !== MembershipStatus.ACTIVE && !(membership.cardType.type === CardTypeCategory.DURATION && membership.status === MembershipStatus.SCHEDULED)) {
throw new BadRequestException(
`Membership is not active (status: ${membership.status})`,
)
@@ -368,6 +375,7 @@ export class BookingService {
if (isCountLimited && (membership.remainingTimes ?? 0) <= 0) {
throw new BadRequestException('No remaining times on this membership')
}
+ if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
const occupied = await tx.timeSlot.updateMany({
where: {
@@ -423,7 +431,7 @@ export class BookingService {
where: { id: membership.id },
data: {
remainingTimes: newRemainingTimes,
- status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
+ status: getMembershipEffectiveState({ ...membership, remainingTimes: newRemainingTimes, status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE }).status,
},
})
}
@@ -588,6 +596,7 @@ export class BookingService {
const withinLimit = slotStartMs >= deadlineMs
const updatedBooking = await this.prisma.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT id FROM users WHERE id = ${booking.userId} FOR UPDATE`
const cancelled = await tx.booking.update({
where: { id: bookingId },
data: {
@@ -625,10 +634,10 @@ export class BookingService {
// whether this booking earns a refund.
if (booking.membership.remainingTimes !== null) {
const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1
- const newStatus =
- booking.membership.status === MembershipStatus.USED_UP
+ const restoredStatus = booking.membership.status === MembershipStatus.USED_UP
? MembershipStatus.ACTIVE
: booking.membership.status
+ const newStatus = getMembershipEffectiveState({ ...booking.membership, remainingTimes: newRemainingTimes, status: restoredStatus }).status
await tx.membership.update({
where: { id: booking.membershipId },
diff --git a/packages/server/src/membership/__tests__/membership-renewal.spec.ts b/packages/server/src/membership/__tests__/membership-renewal.spec.ts
index 6566df7..bd04832 100644
--- a/packages/server/src/membership/__tests__/membership-renewal.spec.ts
+++ b/packages/server/src/membership/__tests__/membership-renewal.spec.ts
@@ -6,6 +6,7 @@ import {
computeMembershipGrant,
getMembershipRenewalHint,
pickRenewalTarget,
+ getMembershipEffectiveState,
} from '@mp-pilates/shared'
import type { RenewalHintMembership } from '@mp-pilates/shared'
@@ -143,7 +144,23 @@ describe('computeMembershipGrant', () => {
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
})
- it('stacks sessions for an active count-limited DURATION card', () => {
+ it.each([
+ { expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.EXPIRED },
+ { expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.ACTIVE },
+ { expireDate: now.toISOString(), status: MembershipStatus.ACTIVE },
+ { expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED },
+ { expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.USED_UP },
+ ])('resets an expired 10-session monthly plan with 6 unused sessions: %j', expiry => {
+ const result = computeMembershipGrant({ existing: { remainingTimes: 6, totalTimes: 10, ...expiry }, cardType: limitedDurationCard, now })
+ expect(result).toEqual({ startDate: now, isRenewal: true, remainingTimes: 10, totalTimes: 10, expireDate: new Date(now.getTime() + 30 * 86_400_000) })
+ })
+
+ it('does not extend an explicitly expired unlimited monthly plan from a future stored date', () => {
+ const result = computeMembershipGrant({ existing: { remainingTimes: null, totalTimes: null, expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED }, cardType: durationCard, now })
+ expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
+ })
+
+ it('keeps a renewed monthly quota separate and schedules it after the current period', () => {
const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({
existing: {
@@ -158,13 +175,39 @@ describe('computeMembershipGrant', () => {
expect(result).toMatchObject({
isRenewal: true,
- remainingTimes: 13,
- totalTimes: 20,
+ remainingTimes: 10,
+ totalTimes: 10,
+ startDate: expireDate,
})
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
})
})
+describe('Effective monthly-card benefits', () => {
+ it('expires unused monthly sessions immediately even before the scheduled status update', () => {
+ const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, status: MembershipStatus.ACTIVE, expireDate: now.toISOString(), cardType: { type: CardTypeCategory.DURATION } })
+ expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 0 })
+ expect(original.remainingTimes).toBe(6)
+ expect(original.status).toBe(MembershipStatus.ACTIVE)
+ })
+ it('keeps unused monthly sessions available until the exact expiry instant', () => {
+ const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, expireDate: new Date(now.getTime() + 1).toISOString(), cardType: { type: CardTypeCategory.DURATION } })
+ expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.ACTIVE, remainingTimes: 6 })
+ })
+ it('honors explicit EXPIRED status even when an administrator left a future date', () => {
+ const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, status: MembershipStatus.EXPIRED, cardType: { type: CardTypeCategory.DURATION } })
+ expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 0 })
+ })
+ it('keeps expired times-card balances for history without marking them usable', () => {
+ const original = membership({ cardTypeId: 'times', remainingTimes: 6, expireDate: now.toISOString() })
+ expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 6 })
+ })
+ it('preserves the unlimited-plan representation after expiry', () => {
+ const original = membership({ cardTypeId: 'monthly', remainingTimes: null, expireDate: now.toISOString(), cardType: { type: CardTypeCategory.DURATION } })
+ expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: null })
+ })
+})
+
describe('getMembershipRenewalHint', () => {
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
const hint = getMembershipRenewalHint(
@@ -303,6 +346,13 @@ describe('getMembershipRenewalHint', () => {
})
describe('pickRenewalTarget', () => {
+ it('queues monthly purchases after the live tail instead of an explicitly expired future-dated record', () => {
+ const target = pickRenewalTarget([
+ membership({cardTypeId:'monthly',expireDate:'2099-01-01T00:00:00Z',status:MembershipStatus.EXPIRED,cardType:{type:CardTypeCategory.DURATION}}),
+ membership({cardTypeId:'monthly',startDate:'2026-07-01T00:00:00Z',expireDate:'2026-07-31T00:00:00Z',status:MembershipStatus.SCHEDULED,cardType:{type:CardTypeCategory.DURATION}}),
+ ],'monthly')
+ expect(target?.expireDate).toBe('2026-07-31T00:00:00Z')
+ })
it('picks the latest expiring membership of the same card type', () => {
const target = pickRenewalTarget(
[
diff --git a/packages/server/src/membership/__tests__/membership.service.spec.ts b/packages/server/src/membership/__tests__/membership.service.spec.ts
index 42440b7..89f2f20 100644
--- a/packages/server/src/membership/__tests__/membership.service.spec.ts
+++ b/packages/server/src/membership/__tests__/membership.service.spec.ts
@@ -71,6 +71,7 @@ const mockDurationMembership = {
// ─── Mock Prisma ──────────────────────────────────────────────────────────
const mockPrismaService = {
+ $queryRaw: jest.fn(),
cardType: {
findMany: jest.fn(),
findUnique: jest.fn(),
@@ -133,6 +134,18 @@ describe('MembershipService', () => {
// ─── getValidMembership ─────────────────────────────────────────────────
+ describe('getUserMemberships()', () => {
+ it('returns zero effective monthly sessions at expiry without rewriting historical balances', async () => {
+ const expired = { ...mockDurationMembership, remainingTimes: 6, totalTimes: 10, expireDate: new Date('2020-01-01T00:00:00Z'), status: MembershipStatus.ACTIVE }
+ mockPrismaService.membership.findMany.mockResolvedValue([expired, mockActiveMembership])
+ const result = await service.getUserMemberships('user-001')
+ expect(result[0]).toMatchObject({ remainingTimes: 0, status: MembershipStatus.EXPIRED })
+ expect(result[1]).toMatchObject({ remainingTimes: mockActiveMembership.remainingTimes, status: MembershipStatus.ACTIVE })
+ expect(expired.remainingTimes).toBe(6)
+ expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
+ })
+ })
+
describe('getValidMembership()', () => {
it('should return the earliest expiring valid membership', async () => {
const earlyExpiring = {
@@ -150,7 +163,8 @@ describe('MembershipService', () => {
expect.objectContaining({
where: expect.objectContaining({
userId: 'user-001',
- status: MembershipStatus.ACTIVE,
+ status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
+ startDate: { lte: expect.any(Date) },
}),
orderBy: { expireDate: 'asc' },
}),
@@ -422,31 +436,14 @@ describe('MembershipService', () => {
expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
})
- it('extends a DURATION card from the current expireDate when still active', async () => {
- const existing = {
- ...mockDurationMembership,
- expireDate: new Date('2026-07-01T00:00:00Z'),
- }
+ it('creates an independent scheduled monthly period without changing the current card', async () => {
+ const existing = { ...mockDurationMembership, expireDate: new Date('2026-07-01T00:00:00Z') }
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
- mockPrismaService.membership.update.mockResolvedValue(existing)
-
- await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
-
- expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.objectContaining({
- remainingTimes: null,
- totalTimes: null,
- status: MembershipStatus.ACTIVE,
- }),
- }),
- )
- const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
- expireDate: Date
- }
- expect(updateData.expireDate.getTime()).toBe(
- existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
- )
+ mockPrismaService.membership.create.mockImplementation(async ({data}) => ({id:'next-period',...data}))
+ const result = await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
+ expect(mockPrismaService.membership.create).toHaveBeenCalledWith({data: {userId:'user-001',cardTypeId:mockDurationCardType.id,startDate:existing.expireDate,expireDate:new Date('2026-07-31T00:00:00Z'),remainingTimes:null,totalTimes:null,status:MembershipStatus.SCHEDULED}})
+ expect(result.id).toBe('next-period')
+ expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
})
it('does not carry leftover times when renewing an expired TIMES card', async () => {
@@ -483,6 +480,15 @@ describe('MembershipService', () => {
)
})
+ it('resets the expired monthly balance from 6 to 10 when the status job has not run', async () => {
+ const existing = { ...mockDurationMembership, remainingTimes: 6, totalTimes: 10, status: MembershipStatus.ACTIVE, expireDate: new Date('2026-05-01T00:00:00Z') }
+ mockPrismaService.membership.findFirst.mockResolvedValue(existing)
+ mockPrismaService.membership.create.mockResolvedValue({ ...existing, id: 'new-period', remainingTimes: 10 })
+ await service.grantPurchasedCard(tx, 'user-001', { ...mockDurationCardType, totalTimes: 10 }, now)
+ expect(mockPrismaService.membership.create).toHaveBeenCalledWith({ data: { userId: 'user-001', cardTypeId: mockDurationCardType.id, startDate: now, remainingTimes: 10, totalTimes: 10, expireDate: new Date('2026-07-01T00:00:00Z'), status: MembershipStatus.ACTIVE } })
+ expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
+ })
+
it('always creates a new membership for TRIAL cards', async () => {
const trialCardType = {
...mockTimesCardType,
diff --git a/packages/server/src/membership/__tests__/monthly-periods.spec.ts b/packages/server/src/membership/__tests__/monthly-periods.spec.ts
new file mode 100644
index 0000000..0d2d565
--- /dev/null
+++ b/packages/server/src/membership/__tests__/monthly-periods.spec.ts
@@ -0,0 +1,106 @@
+import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
+import { MembershipService } from '../membership.service'
+import { PrismaService } from '../../prisma/prisma.service'
+import { canUseMembershipForSlot, getMembershipEffectiveState } from '@mp-pilates/shared'
+
+const now = new Date('2026-06-10T00:00:00Z')
+const cardType = { id: 'monthly', type: 'DURATION', totalTimes: 10, durationDays: 30 }
+function period(id: string, start: string, end: string, remainingTimes = 10, status = 'SCHEDULED') {
+ return { id, userId: 'member', cardTypeId: 'monthly', remainingTimes, totalTimes: 10, startDate: new Date(start), expireDate: new Date(end), status, createdAt: new Date(start), cardType }
+}
+const current = () => period('current', '2026-06-01T00:00:00Z', '2026-07-01T00:00:00Z', 0, 'USED_UP')
+const next = () => period('next', '2026-07-01T00:00:00Z', '2026-07-31T00:00:00Z')
+const later = () => period('later', '2026-07-31T00:00:00Z', '2026-08-30T00:00:00Z')
+
+describe('Independent monthly periods', () => {
+ function setup() {
+ const tx = { $queryRaw: jest.fn(), membership: { findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn() }, booking: { findMany: jest.fn().mockResolvedValue([]) } }
+ const db = { $transaction: jest.fn(async (callback: (value: typeof tx) => unknown) => callback(tx)) }
+ return { tx, service: new MembershipService(db as unknown as PrismaService) }
+ }
+ it('queues repeated purchases in distinct records and never changes the current 6-session balance', async () => {
+ const { tx, service } = setup()
+ const old = { ...current(), remainingTimes: 6, status: 'ACTIVE' }
+ const periods = [old]
+ tx.membership.findFirst.mockImplementation(async () => periods[periods.length - 1])
+ tx.membership.create.mockImplementation(async ({data}) => { const created = { ...data, id: 'purchase-' + periods.length, cardType, createdAt: now }; periods.push(created); return created })
+ await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
+ await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
+ expect(periods.map(p => p.remainingTimes)).toEqual([6, 10, 10])
+ expect(periods[1]).toMatchObject({ startDate: old.expireDate, expireDate: new Date('2026-07-31T00:00:00Z'), status: 'SCHEDULED' })
+ expect(periods[2]).toMatchObject({ startDate: periods[1].expireDate, expireDate: new Date('2026-08-30T00:00:00Z'), status: 'SCHEDULED' })
+ expect(tx.membership.update).not.toHaveBeenCalled()
+ })
+ it('activates the earliest next period and shifts later periods while preserving their full durations', async () => {
+ const { tx, service } = setup()
+ tx.membership.findFirst.mockResolvedValue(next())
+ tx.membership.findMany.mockResolvedValue([current(), next(), later()])
+ tx.membership.update.mockImplementation(async ({where,data}) => ({ id:where.id,...data }))
+ const result = await service.activateNextPeriod('member', 'next', now)
+ expect(result).toMatchObject({ id: 'next', startDate: now, expireDate: new Date('2026-07-10T00:00:00Z'), status: 'ACTIVE' })
+ expect(tx.membership.update).toHaveBeenCalledWith({ where:{id:'current'},data:{expireDate:now,status:'EXPIRED'} })
+ expect(tx.membership.update).toHaveBeenCalledWith({ where:{id:'later'},data:{startDate:new Date('2026-07-10T00:00:00Z'),expireDate:new Date('2026-08-09T00:00:00Z'),status:'SCHEDULED'} })
+ })
+ it.each([6, null])('does not forfeit a current period with unused or unlimited benefits: %s', async remainingTimes => {
+ const { tx, service } = setup()
+ tx.membership.findFirst.mockResolvedValue(next())
+ tx.membership.findMany.mockResolvedValue([{ ...current(), remainingTimes }, next()])
+ await expect(service.activateNextPeriod('member', 'next', now)).rejects.toBeInstanceOf(BadRequestException)
+ expect(tx.membership.update).not.toHaveBeenCalled()
+ })
+ it('prevents another member from activating a period', async () => {
+ const { tx, service } = setup()
+ tx.membership.findFirst.mockResolvedValue(null)
+ await expect(service.activateNextPeriod('other', 'next', now)).rejects.toBeInstanceOf(NotFoundException)
+ expect(tx.membership.findFirst).toHaveBeenCalledWith(expect.objectContaining({where:{id:'next',userId:'other'}}))
+ expect(tx.membership.update).not.toHaveBeenCalled()
+ })
+ it('cannot activate a later period out of order or activate the same period twice', async () => {
+ const { tx, service } = setup()
+ tx.membership.findFirst.mockResolvedValue(later())
+ tx.membership.findMany.mockResolvedValue([current(), next(), later()])
+ await expect(service.activateNextPeriod('member', 'later', now)).rejects.toThrow('顺序')
+ tx.membership.findFirst.mockResolvedValue({ ...next(), startDate: now, status: 'ACTIVE' })
+ await expect(service.activateNextPeriod('member', 'next', now)).rejects.toThrow('待生效')
+ expect(tx.membership.update).not.toHaveBeenCalled()
+ })
+ it.each(['current', 'next'])('protects reservations that would be invalidated on %s', async membershipId => {
+ const { tx, service } = setup()
+ tx.membership.findFirst.mockResolvedValue(next())
+ tx.membership.findMany.mockResolvedValue([current(), next()])
+ tx.booking.findMany.mockResolvedValue([{ membershipId, timeSlot:{date:new Date(membershipId === 'current' ? '2026-06-20' : '2026-07-20'),startTime:'09:00',endTime:'10:00'} }])
+ await expect(service.activateNextPeriod('member', 'next', now)).rejects.toBeInstanceOf(ConflictException)
+ expect(tx.membership.update).not.toHaveBeenCalled()
+ })
+ it('keeps valid next-period reservations attached to their original period record', async () => {
+ const { tx, service } = setup()
+ tx.membership.findFirst.mockResolvedValue(next())
+ tx.membership.findMany.mockResolvedValue([current(), next()])
+ tx.booking.findMany.mockResolvedValue([{ membershipId:'next',timeSlot:{date:new Date('2026-07-02'),startTime:'09:00',endTime:'10:00'} }])
+ tx.membership.update.mockImplementation(async ({where,data}) => ({id:where.id,...data}))
+ await expect(service.activateNextPeriod('member','next',now)).resolves.toMatchObject({id:'next'})
+ })
+})
+
+describe('Period timestamps and slot eligibility', () => {
+ const planned = next()
+ it('automatically exposes a due scheduled period without waiting for the daily job', () => {
+ expect(getMembershipEffectiveState(planned, new Date('2026-07-01T00:00:00Z'))).toEqual({ status:'ACTIVE',remainingTimes:10 })
+ expect(getMembershipEffectiveState({...planned,remainingTimes:0},new Date('2026-07-01T00:00:00Z')).status).toBe('USED_UP')
+ expect(getMembershipEffectiveState(planned, now).status).toBe('SCHEDULED')
+ })
+ it.each([
+ ['2026-06-30','23:00','23:50',false],
+ ['2026-07-01','07:00','08:00',false],
+ ['2026-07-01','08:00','09:00',true],
+ ['2026-07-31','07:00','08:00',true],
+ ['2026-07-31','08:00','09:00',false],
+ ])('matches China-local course timestamps to one paid period: %s %s–%s', (date,startTime,endTime,eligible) => {
+ expect(canUseMembershipForSlot(planned,{date,startTime,endTime},now)).toBe(eligible)
+ })
+ it('does not borrow a remaining balance from another period or revive an expired one', () => {
+ const slot = {date:'2026-07-02',startTime:'09:00',endTime:'10:00'}
+ expect(canUseMembershipForSlot({...planned,remainingTimes:0},slot,now)).toBe(false)
+ expect(canUseMembershipForSlot({...planned,status:'EXPIRED'},slot,now)).toBe(false)
+ })
+})
diff --git a/packages/server/src/membership/__tests__/period-activation.controller.spec.ts b/packages/server/src/membership/__tests__/period-activation.controller.spec.ts
new file mode 100644
index 0000000..0bc4585
--- /dev/null
+++ b/packages/server/src/membership/__tests__/period-activation.controller.spec.ts
@@ -0,0 +1,25 @@
+import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
+import { Test } from '@nestjs/testing'
+import type { ExecutionContext } from '@nestjs/common'
+import type { AddressInfo } from 'node:net'
+import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
+import { MembershipController } from '../membership.controller'
+import { MembershipService } from '../membership.service'
+
+describe('Explicit monthly-period activation', () => {
+ let app: INestApplication
+ const service = { activateNextPeriod: jest.fn().mockResolvedValue({id:'next'}) }
+ beforeAll(async () => {
+ const module = await Test.createTestingModule({controllers:[MembershipController],providers:[{provide:MembershipService,useValue:service}]})
+ .overrideGuard(JwtAuthGuard).useValue({canActivate(context:ExecutionContext){const req=context.switchToHttp().getRequest();if(!req.headers.authorization)throw new UnauthorizedException();req.user={sub:'owner',role:'MEMBER'};return true}}).compile()
+ app=module.createNestApplication()
+ app.useGlobalPipes(new ValidationPipe({whitelist:true,forbidNonWhitelisted:true,transform:true}))
+ await app.listen(0,'127.0.0.1')
+ })
+ afterAll(async()=>{await app.close()})
+ beforeEach(()=>service.activateNextPeriod.mockClear())
+ async function request(body:unknown, authenticated=true){const address=app.getHttpServer().address() as AddressInfo;return fetch(`http://127.0.0.1:${address.port}/membership/next/activate`,{method:'POST',headers:{'Content-Type':'application/json',...(authenticated?{Authorization:'test'}:{})},body:JSON.stringify(body)})}
+ it('requires authentication',async()=>{expect((await request({confirmed:true},false)).status).toBe(401);expect(service.activateNextPeriod).not.toHaveBeenCalled()})
+ it.each([{}, {confirmed:false}, {confirmed:'true'}, {confirmed:true,userId:'other'}])('rejects missing consent and client-specified ownership: %j',async body=>{expect((await request(body)).status).toBe(400);expect(service.activateNextPeriod).not.toHaveBeenCalled()})
+ it('uses the authenticated owner only after explicit confirmation',async()=>{expect((await request({confirmed:true})).status).toBe(201);expect(service.activateNextPeriod).toHaveBeenCalledWith('owner','next')})
+})
diff --git a/packages/server/src/membership/dto/activate-period.dto.ts b/packages/server/src/membership/dto/activate-period.dto.ts
new file mode 100644
index 0000000..614578a
--- /dev/null
+++ b/packages/server/src/membership/dto/activate-period.dto.ts
@@ -0,0 +1,6 @@
+import { Equals } from 'class-validator'
+
+export class ActivatePeriodDto {
+ @Equals(true, { message: '请确认提前启用新周期' })
+ confirmed!: true
+}
diff --git a/packages/server/src/membership/membership-query.ts b/packages/server/src/membership/membership-query.ts
new file mode 100644
index 0000000..2405300
--- /dev/null
+++ b/packages/server/src/membership/membership-query.ts
@@ -0,0 +1,7 @@
+import type { Prisma } from '@prisma/client'
+import { MembershipStatus } from '@mp-pilates/shared'
+
+/** A scheduled period becomes usable at its timestamp without waiting for cron. */
+export function currentMembershipWhere(now = new Date()): Prisma.MembershipWhereInput {
+ return { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: now }, expireDate: { gt: now }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }
+}
diff --git a/packages/server/src/membership/membership.controller.ts b/packages/server/src/membership/membership.controller.ts
index 2d1df30..03ac81d 100644
--- a/packages/server/src/membership/membership.controller.ts
+++ b/packages/server/src/membership/membership.controller.ts
@@ -16,6 +16,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
import { MembershipService } from './membership.service'
+import { ActivatePeriodDto } from './dto/activate-period.dto'
@Controller()
export class MembershipController {
@@ -36,6 +37,12 @@ export class MembershipController {
return this.membershipService.getUserMemberships(userId)
}
+ @Post('membership/:id/activate')
+ @UseGuards(JwtAuthGuard)
+ activate(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() _dto: ActivatePeriodDto) {
+ return this.membershipService.activateNextPeriod(userId, id)
+ }
+
// ─── Admin ─────────────────────────────────────────────────────────────────
@Get('admin/card-types')
diff --git a/packages/server/src/membership/membership.service.ts b/packages/server/src/membership/membership.service.ts
index 0095859..48978fc 100644
--- a/packages/server/src/membership/membership.service.ts
+++ b/packages/server/src/membership/membership.service.ts
@@ -1,9 +1,10 @@
-import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
+import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { CardType, Membership, Prisma } from '@prisma/client'
-import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
+import { CardTypeCategory, MembershipStatus, computeMembershipGrant, getMembershipEffectiveState, getStudioSlotRange } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
+import { currentMembershipWhere } from './membership-query'
@Injectable()
export class MembershipService {
@@ -27,23 +28,22 @@ export class MembershipService {
include: { cardType: true },
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
})
- return memberships.map((m) => ({ ...m, cardType: { ...m.cardType } }))
+ const now = new Date()
+ return memberships.map((m) => ({ ...m, ...getMembershipEffectiveState(m, now), cardType: { ...m.cardType } }))
}
async getValidMembership(userId: string): Promise<(Membership & { cardType: CardType }) | null> {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
- status: MembershipStatus.ACTIVE,
- expireDate: { gt: new Date() },
- OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
+ ...currentMembershipWhere(),
},
include: { cardType: true },
orderBy: { expireDate: 'asc' },
})
if (!membership) return null
- return { ...membership, cardType: { ...membership.cardType } }
+ return { ...membership, ...getMembershipEffectiveState(membership), cardType: { ...membership.cardType } }
}
async deductMembership(membershipId: string): Promise {
@@ -55,7 +55,7 @@ export class MembershipService {
if (!membership) {
throw new NotFoundException(`Membership ${membershipId} not found`)
}
- if (membership.status !== MembershipStatus.ACTIVE) {
+ if (getMembershipEffectiveState(membership).status !== MembershipStatus.ACTIVE) {
throw new BadRequestException(`Membership ${membershipId} is not active`)
}
@@ -117,11 +117,13 @@ export class MembershipService {
cardType: Pick,
now = new Date(),
): Promise {
+ // Serialize purchases and period activation for the same member.
+ await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
const existing =
cardType.type === CardTypeCategory.TRIAL
? null
: await tx.membership.findFirst({
- where: { userId, cardTypeId: cardType.id },
+ where: { userId, cardTypeId: cardType.id, ...(cardType.type === CardTypeCategory.DURATION ? { status: { not: MembershipStatus.EXPIRED } } : {}) },
orderBy: { expireDate: 'desc' },
})
@@ -135,16 +137,16 @@ export class MembershipService {
now,
})
- if (!grant.isRenewal || !existing) {
+ if (cardType.type === CardTypeCategory.DURATION || !grant.isRenewal || !existing) {
return tx.membership.create({
data: {
userId,
cardTypeId: cardType.id,
- startDate: now,
+ startDate: grant.startDate,
expireDate: grant.expireDate,
remainingTimes: grant.remainingTimes,
totalTimes: grant.totalTimes,
- status: MembershipStatus.ACTIVE,
+ status: grant.startDate > now ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
},
})
}
@@ -160,6 +162,48 @@ export class MembershipService {
})
}
+ async activateNextPeriod(userId: string, membershipId: string, now = new Date()) {
+ return this.prisma.$transaction(async tx => {
+ await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
+ const target = await tx.membership.findFirst({ where: { id: membershipId, userId }, include: { cardType: true } })
+ if (!target) throw new NotFoundException('月卡周期不存在')
+ if (target.cardType.type !== CardTypeCategory.DURATION || target.startDate <= now || target.status === MembershipStatus.EXPIRED) throw new BadRequestException('只能提前启用待生效的月卡周期')
+ if (target.remainingTimes === 0) throw new BadRequestException('该周期课时已全部预约,请先检查预约记录')
+ const periods = await tx.membership.findMany({ where: { userId, cardTypeId: target.cardTypeId, status: { not: MembershipStatus.EXPIRED }, expireDate: { gt: now } }, orderBy: [{ startDate: 'asc' }, { createdAt: 'asc' }] })
+ const current = periods.filter(period => period.startDate <= now)
+ const queued = periods.filter(period => period.startDate > now)
+ if (queued[0]?.id !== membershipId) throw new BadRequestException('请按顺序启用最早的待生效周期')
+ if (current.some(period => period.remainingTimes !== 0)) throw new BadRequestException('本期课时尚未用完,请继续使用本期权益')
+
+ const reservations = await tx.booking.findMany({ where: { membershipId: { in: periods.map(period => period.id) }, status: { in: ['PENDING_CONFIRMATION', 'CONFIRMED'] } }, include: { timeSlot: true } })
+ const currentIds = new Set(current.map(period => period.id))
+ if (reservations.some(booking => currentIds.has(booking.membershipId) && getStudioSlotRange(booking.timeSlot.date, booking.timeSlot.startTime, booking.timeSlot.endTime).end > now)) throw new ConflictException('本期仍有未完成的预约,请上完或取消后再启用下一期')
+
+ let cursor = now
+ const shifted = queued.map(period => {
+ const duration = period.expireDate.getTime() - period.startDate.getTime()
+ if (duration <= 0) throw new BadRequestException('月卡周期日期无效,请联系馆主')
+ const dates = { startDate: cursor, expireDate: new Date(cursor.getTime() + duration) }
+ cursor = dates.expireDate
+ return { period, ...dates }
+ })
+ for (const change of shifted) {
+ if (reservations.some(booking => {
+ if (booking.membershipId !== change.period.id) return false
+ const slot = getStudioSlotRange(booking.timeSlot.date, booking.timeSlot.startTime, booking.timeSlot.endTime)
+ return slot.start < change.startDate || slot.end > change.expireDate
+ })) throw new ConflictException('提前启用会影响已约课程的有效期,请先调整相关预约')
+ }
+ for (const period of current) await tx.membership.update({ where: { id: period.id }, data: { expireDate: now, status: MembershipStatus.EXPIRED } })
+ let activated: Membership | undefined
+ for (const change of shifted) {
+ const updated = await tx.membership.update({ where: { id: change.period.id }, data: { startDate: change.startDate, expireDate: change.expireDate, status: change.period.id === membershipId ? MembershipStatus.ACTIVE : MembershipStatus.SCHEDULED } })
+ if (change.period.id === membershipId) activated = updated
+ }
+ return activated!
+ })
+ }
+
// ─── Admin ─────────────────────────────────────────────────────────────────
async getAllCardTypes(): Promise {
diff --git a/packages/server/src/payment/__tests__/payment.service.spec.ts b/packages/server/src/payment/__tests__/payment.service.spec.ts
index af3536b..b2e4436 100644
--- a/packages/server/src/payment/__tests__/payment.service.spec.ts
+++ b/packages/server/src/payment/__tests__/payment.service.spec.ts
@@ -70,6 +70,7 @@ const mockPaymentParams = {
function buildPrismaMock() {
return {
+ $queryRaw: jest.fn(),
inviteReferral: { findUnique: jest.fn().mockResolvedValue(null) },
cardType: {
findUnique: jest.fn(),
@@ -379,6 +380,19 @@ describe('PaymentService', () => {
)
})
+ it('grants a fresh monthly period without restoring 6 expired sessions after payment confirmation', async () => {
+ prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type: 'DURATION', durationDays: 30 })
+ const existing = { id: 'expired-monthly', userId: pendingOrder.userId, cardTypeId: pendingOrder.cardTypeId, remainingTimes: 6, totalTimes: 10, expireDate: new Date('2020-01-01T00:00:00Z'), status: MembershipStatus.ACTIVE }
+ prisma.membership.findFirst.mockResolvedValue(existing)
+ prisma.membership.create.mockImplementation(async ({data}) => ({ ...existing, ...data, id: 'new-monthly-period' }))
+ await service.handleWxNotify(headers, successBody)
+ const data = prisma.membership.create.mock.calls[0][0].data
+ expect(data).toMatchObject({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, startDate: expect.any(Date) })
+ expect(data.expireDate.getTime() - data.startDate.getTime()).toBe(30 * 86_400_000)
+ expect(prisma.membership.update).not.toHaveBeenCalled()
+ expect(prisma.order.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ membershipId: 'new-monthly-period' }) }))
+ })
+
it('is idempotent: duplicate callback for already-PAID order skips transaction', async () => {
const paidOrder = buildMockOrder({ status: OrderStatus.PAID })
prisma.order.findUnique.mockResolvedValue(paidOrder)
diff --git a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts
index 9d038a9..29daa6d 100644
--- a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts
+++ b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts
@@ -283,6 +283,7 @@ describe('SlotGeneratorService', () => {
it('marks expired-date memberships as EXPIRED', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 2 }) // date-expired
+ .mockResolvedValueOnce({ count: 0 }) // activated
.mockResolvedValueOnce({ count: 1 }) // used-up
const total = await service.checkExpiredMemberships()
@@ -299,6 +300,7 @@ describe('SlotGeneratorService', () => {
it('marks zero-remaining memberships as USED_UP', async () => {
mockPrisma.membership.updateMany
+ .mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 4 })
@@ -307,7 +309,7 @@ describe('SlotGeneratorService', () => {
expect(total).toBe(4)
const secondCall = (
- mockPrisma.membership.updateMany.mock.calls[1][0] as {
+ mockPrisma.membership.updateMany.mock.calls[2][0] as {
where: { remainingTimes: number }
data: { status: MembershipStatus }
}
@@ -316,15 +318,16 @@ describe('SlotGeneratorService', () => {
expect(secondCall.data.status).toBe(MembershipStatus.USED_UP)
})
- it('runs both updates in parallel and sums results', async () => {
+ it('expires old periods before activation and sums all transitions', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 3 })
+ .mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 2 })
const total = await service.checkExpiredMemberships()
- expect(total).toBe(5)
- expect(mockPrisma.membership.updateMany).toHaveBeenCalledTimes(2)
+ expect(total).toBe(6)
+ expect(mockPrisma.membership.updateMany).toHaveBeenCalledTimes(3)
})
})
diff --git a/packages/server/src/time-slot/slot-generator.service.ts b/packages/server/src/time-slot/slot-generator.service.ts
index 22609fd..5f5af7b 100644
--- a/packages/server/src/time-slot/slot-generator.service.ts
+++ b/packages/server/src/time-slot/slot-generator.service.ts
@@ -169,24 +169,20 @@ export class SlotGeneratorService {
async checkExpiredMemberships(): Promise {
const now = new Date()
- const [expired, usedUp] = await Promise.all([
- this.prisma.membership.updateMany({
- where: {
- status: MembershipStatus.ACTIVE,
- expireDate: { lt: now },
- },
- data: { status: MembershipStatus.EXPIRED },
- }),
- this.prisma.membership.updateMany({
- where: {
- status: MembershipStatus.ACTIVE,
- remainingTimes: 0,
- },
- data: { status: MembershipStatus.USED_UP },
- }),
- ])
+ const expired = await this.prisma.membership.updateMany({
+ where: { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED, MembershipStatus.USED_UP] }, expireDate: { lte: now } },
+ data: { status: MembershipStatus.EXPIRED },
+ })
+ const activated = await this.prisma.membership.updateMany({
+ where: { status: MembershipStatus.SCHEDULED, startDate: { lte: now }, expireDate: { gt: now } },
+ data: { status: MembershipStatus.ACTIVE },
+ })
+ const usedUp = await this.prisma.membership.updateMany({
+ where: { status: MembershipStatus.ACTIVE, remainingTimes: 0 },
+ data: { status: MembershipStatus.USED_UP },
+ })
- const total = expired.count + usedUp.count
+ const total = expired.count + activated.count + usedUp.count
this.logger.log(
`Expired ${expired.count} memberships by date, ${usedUp.count} by sessions`,
)
diff --git a/packages/server/src/user/__tests__/lesson-feedback.spec.ts b/packages/server/src/user/__tests__/lesson-feedback.spec.ts
new file mode 100644
index 0000000..0e6e378
--- /dev/null
+++ b/packages/server/src/user/__tests__/lesson-feedback.spec.ts
@@ -0,0 +1,72 @@
+import 'reflect-metadata'
+import { validate } from 'class-validator'
+import { LESSON_APPARATUS } from '@mp-pilates/shared'
+import { MemberNoteDto } from '../dto/member-progress.dto'
+import { MemberProgressService } from '../member-progress.service'
+import { PrismaService } from '../../prisma/prisma.service'
+import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
+
+describe('Coach lesson feedback', () => {
+ const db = {
+ user: { findUnique: jest.fn() },
+ booking: { findFirst: jest.fn() },
+ memberNote: { create: jest.fn() },
+ }
+ let service: MemberProgressService
+ beforeEach(() => {
+ jest.resetAllMocks()
+ db.user.findUnique.mockResolvedValue({ nickname: '学员' })
+ db.booking.findFirst.mockResolvedValue({ id: 'lesson' })
+ service = new MemberProgressService(db as unknown as PrismaService, {} as ProgressPhotoStorageService)
+ })
+
+ it('keeps legacy overall-only notes and their visibility', async () => {
+ await service.addNote('member', 'coach', { content: ' 今天控制力有进步 ', shared: false })
+ expect(db.memberNote.create).toHaveBeenCalledWith({ data: {
+ userId: 'member', operatorId: 'coach', content: '今天控制力有进步', apparatusNotes: {}, shared: false, bookingId: null,
+ } })
+ })
+
+ it.each(LESSON_APPARATUS)('allows %s on its own without an overall note or other apparatus', async apparatus => {
+ const dto = Object.assign(new MemberNoteDto(), { shared: true, apparatusNotes: { [apparatus]: ' 控制稳定,继续保持 ' }, bookingId: 'lesson' })
+ expect(await validate(dto)).toHaveLength(0)
+ await service.addNote('member', 'coach', dto)
+ expect(db.memberNote.create).toHaveBeenCalledWith({ data: {
+ userId: 'member', operatorId: 'coach', content: '', apparatusNotes: { [apparatus]: '控制稳定,继续保持' }, shared: true, bookingId: 'lesson',
+ } })
+ expect(db.booking.findFirst).toHaveBeenCalledWith({ where: { id: 'lesson', userId: 'member', status: 'COMPLETED' } })
+ })
+
+ it('stores all seven fields independently, preserving line breaks and removing blank fields', async () => {
+ const apparatusNotes = Object.fromEntries(LESSON_APPARATUS.map(apparatus => [apparatus, apparatus + ' 第一行\n第二行']))
+ await service.addNote('member', 'coach', { content: '整体反馈', shared: false, apparatusNotes })
+ expect(db.memberNote.create.mock.calls[0][0].data.apparatusNotes).toEqual(apparatusNotes)
+ await service.addNote('member', 'coach', { shared: false, apparatusNotes: { M: ' ', R: '稳定', Chair: '' } })
+ expect(db.memberNote.create.mock.calls[1][0].data.apparatusNotes).toEqual({ R: '稳定' })
+ })
+
+ it.each([{}, { content: '' }, { content: ' \n ', apparatusNotes: { M: ' ', LB: '' } }])('rejects an entirely empty record %j', async patch => {
+ await expect(service.addNote('member', 'coach', { shared: false, ...patch })).rejects.toThrow('请填写整体评价或至少一项器械记录')
+ expect(db.memberNote.create).not.toHaveBeenCalled()
+ })
+
+ it.each([null, [], 'M', { unknown: '内容' }, { m: '内容' }, { M: 5 }, { R: null }, { SC: '字'.repeat(1001) }])('rejects malformed apparatus payload %j before saving', async apparatusNotes => {
+ const dto = Object.assign(new MemberNoteDto(), { shared: false, content: '有效整体评价', apparatusNotes })
+ expect((await validate(dto)).length).toBeGreaterThan(0)
+ await expect(service.addNote('member', 'coach', dto)).rejects.toThrow('器械记录内容无效')
+ expect(db.memberNote.create).not.toHaveBeenCalled()
+ })
+
+ it('accepts the length limit and rejects oversized overall notes and invalid visibility', async () => {
+ const valid = { content: '字'.repeat(1000), shared: true, apparatusNotes: { TT: '字'.repeat(1000) } }
+ expect(await validate(Object.assign(new MemberNoteDto(), valid))).toHaveLength(0)
+ expect((await validate(Object.assign(new MemberNoteDto(), valid, { content: '字'.repeat(1001) }))).length).toBeGreaterThan(0)
+ expect((await validate(Object.assign(new MemberNoteDto(), valid, { shared: 'true' }))).length).toBeGreaterThan(0)
+ })
+
+ it('rejects foreign or unfinished lessons even for apparatus-only feedback', async () => {
+ db.booking.findFirst.mockResolvedValue(null)
+ await expect(service.addNote('member', 'coach', { shared: true, bookingId: 'foreign', apparatusNotes: { M: '完成训练' } })).rejects.toThrow('只能批注该学员已完成的课程')
+ expect(db.memberNote.create).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/server/src/user/__tests__/user.service.spec.ts b/packages/server/src/user/__tests__/user.service.spec.ts
index e2c2e34..de87b6d 100644
--- a/packages/server/src/user/__tests__/user.service.spec.ts
+++ b/packages/server/src/user/__tests__/user.service.spec.ts
@@ -93,6 +93,8 @@ const mockConfigService = {
// Tests
// ---------------------------------------------------------------------------
+const usableWhere = { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }
+
describe('UserService', () => {
let service: UserService
@@ -125,7 +127,7 @@ describe('UserService', () => {
include: {
_count: {
select: {
- memberships: { where: { status: MembershipStatus.ACTIVE } },
+ memberships: { where: usableWhere },
},
},
},
@@ -286,7 +288,7 @@ describe('UserService', () => {
include: {
_count: {
select: {
- memberships: { where: { status: MembershipStatus.ACTIVE } },
+ memberships: { where: usableWhere },
},
},
},
@@ -321,7 +323,7 @@ describe('UserService', () => {
include: {
_count: {
select: {
- memberships: { where: { status: MembershipStatus.ACTIVE } },
+ memberships: { where: usableWhere },
},
},
},
@@ -474,11 +476,11 @@ describe('UserService', () => {
describe('member filters', () => {
it.each([
- ['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
- ['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
- ['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
- ['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
- ['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
+ ['ACTIVE', { memberships: { some: usableWhere } }],
+ ['NONE', { NOT: { memberships: { some: usableWhere } } }],
+ ['TIMES', { memberships: { some: { ...usableWhere, cardType: { type: CardTypeCategory.TIMES } } } }],
+ ['DURATION', { memberships: { some: { ...usableWhere, cardType: { type: CardTypeCategory.DURATION } } } }],
+ ['TRIAL', { memberships: { some: { ...usableWhere, cardType: { type: CardTypeCategory.TRIAL } } } }],
[undefined, {}],
])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
mockPrisma.user.findMany.mockResolvedValue([])
@@ -569,6 +571,17 @@ describe('UserService', () => {
])
})
+ it('shows expired monthly lessons as unavailable in the administrator archive before the daily status job', async () => {
+ const expired = { ...membership, expireDate: new Date('2020-01-01T00:00:00Z'), cardType: { ...cardType, name: '月卡', type: CardTypeCategory.DURATION, durationDays: 30 } }
+ mockPrisma.user.findUnique.mockResolvedValue({ ...makeUser(), memberships: [expired] })
+ mockPrisma.booking.groupBy.mockResolvedValue([])
+ mockPrisma.booking.findMany.mockResolvedValue([])
+ mockPrisma.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: null } })
+ const result = await service.getMemberDetail('user-1')
+ expect(result.memberships[0]).toMatchObject({ remainingTimes: 0, status: MembershipStatus.EXPIRED })
+ expect(expired.remainingTimes).toBe(6)
+ })
+
it('throws NotFoundException when member does not exist', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null)
diff --git a/packages/server/src/user/dto/member-progress.dto.ts b/packages/server/src/user/dto/member-progress.dto.ts
index 7704363..5b56841 100644
--- a/packages/server/src/user/dto/member-progress.dto.ts
+++ b/packages/server/src/user/dto/member-progress.dto.ts
@@ -1,5 +1,5 @@
-import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy } from 'class-validator'
-import { isPostureAssessment, PostureAssessment } from '@mp-pilates/shared'
+import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy, ValidateIf } from 'class-validator'
+import { ApparatusNotes, isApparatusNotes, isPostureAssessment, LESSON_NOTE_MAX_LENGTH, PostureAssessment } from '@mp-pilates/shared'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsOptional() @IsNumber() @Min(30) @Max(250) height?: number | null
@@ -12,9 +12,12 @@ export class BodyMetricDto {
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {
- @IsString() @MinLength(1) @MaxLength(1000) content!: string
+ @IsOptional() @IsString() @MaxLength(LESSON_NOTE_MAX_LENGTH) content?: string
+ @ValidateIf((_object, value) => value !== undefined)
+ @ValidateBy({ name: 'apparatusNotes', validator: { validate: isApparatusNotes, defaultMessage: () => '器械记录无效,每项最多填写1000字' } })
+ apparatusNotes?: ApparatusNotes
@IsBoolean() shared!: boolean
- @IsOptional() @IsString() bookingId?: string
+ @IsOptional() @IsString() @MinLength(1) bookingId?: string
}
export class ProgressPhotoDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
diff --git a/packages/server/src/user/member-progress.service.ts b/packages/server/src/user/member-progress.service.ts
index deff5cf..0c60faa 100644
--- a/packages/server/src/user/member-progress.service.ts
+++ b/packages/server/src/user/member-progress.service.ts
@@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
-import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
+import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, filledApparatusNotes, isApparatusNotes, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
@Injectable()
export class MemberProgressService {
@@ -54,9 +54,12 @@ export class MemberProgressService {
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)
- if (!dto.content.trim()) throw new BadRequestException('请填写笔记内容')
+ if (dto.apparatusNotes !== undefined && !isApparatusNotes(dto.apparatusNotes)) throw new BadRequestException('器械记录内容无效')
+ const content = dto.content?.trim() || ''
+ const apparatusNotes = Object.fromEntries(filledApparatusNotes(dto.apparatusNotes).map(note => [note.apparatus, note.content]))
+ if (!content && !Object.keys(apparatusNotes).length) throw new BadRequestException('请填写整体评价或至少一项器械记录')
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
- return this.prisma.memberNote.create({ data: { userId, operatorId, content: dto.content.trim(), shared: dto.shared, bookingId: dto.bookingId || null } })
+ return this.prisma.memberNote.create({ data: { userId, operatorId, content, apparatusNotes, shared: dto.shared, bookingId: dto.bookingId || null } })
}
async remove(userId: string, kind: string, id: string) {
if (kind === 'metrics') {
diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts
index bad23d7..7556e30 100644
--- a/packages/server/src/user/user.service.ts
+++ b/packages/server/src/user/user.service.ts
@@ -5,6 +5,7 @@ import {
UserRole,
CardTypeCategory,
SubscriptionMessageScene,
+ getMembershipEffectiveState,
} from '@mp-pilates/shared'
import type {
PaginatedData,
@@ -23,6 +24,8 @@ import type {
UpdateAdminMemberProfileDto,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
+import type { Prisma } from '@prisma/client'
+import { currentMembershipWhere } from '../membership/membership-query'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
@@ -57,15 +60,16 @@ function serializeMembership(membership: {
}
}): MembershipWithCardType {
const { cardType, ...rest } = membership
+ const effective = getMembershipEffectiveState(membership)
return {
id: rest.id,
userId: rest.userId,
cardTypeId: rest.cardTypeId,
- remainingTimes: rest.remainingTimes,
+ remainingTimes: effective.remainingTimes,
totalTimes: rest.totalTimes,
startDate: rest.startDate.toISOString(),
expireDate: rest.expireDate.toISOString(),
- status: rest.status as MembershipStatus,
+ status: effective.status,
createdAt: rest.createdAt.toISOString(),
updatedAt: rest.updatedAt.toISOString(),
cardType: {
@@ -167,7 +171,7 @@ export class UserService {
_count: {
select: {
memberships: {
- where: { status: MembershipStatus.ACTIVE },
+ where: currentMembershipWhere(),
},
},
},
@@ -195,7 +199,7 @@ export class UserService {
_count: {
select: {
memberships: {
- where: { status: MembershipStatus.ACTIVE },
+ where: currentMembershipWhere(),
},
},
},
@@ -329,7 +333,7 @@ export class UserService {
_count: {
select: {
memberships: {
- where: { status: MembershipStatus.ACTIVE },
+ where: currentMembershipWhere(),
},
},
},
@@ -355,7 +359,7 @@ export class UserService {
_count: {
select: {
memberships: {
- where: { status: MembershipStatus.ACTIVE },
+ where: currentMembershipWhere(),
},
},
},
@@ -429,12 +433,9 @@ export class UserService {
const where: {
OR?: Array<{ [key: string]: unknown }>
memberships?: {
- some: {
- status: MembershipStatus
- cardType?: { type: CardTypeCategory }
- }
+ some: Prisma.MembershipWhereInput
}
- NOT?: { memberships?: { some: { status: MembershipStatus } } }
+ NOT?: { memberships?: { some: Prisma.MembershipWhereInput } }
} = search
? {
OR: [
@@ -447,13 +448,13 @@ export class UserService {
// ACTIVE and NONE are complementary membership-status filters.
if (cardType === 'NONE') {
- where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
+ where.NOT = { memberships: { some: currentMembershipWhere() } }
} else if (cardType === 'ACTIVE') {
- where.memberships = { some: { status: MembershipStatus.ACTIVE } }
+ where.memberships = { some: currentMembershipWhere() }
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
where.memberships = {
some: {
- status: MembershipStatus.ACTIVE,
+ ...currentMembershipWhere(),
cardType: { type: cardType as CardTypeCategory },
},
}
@@ -471,7 +472,7 @@ export class UserService {
createdAt: true,
lastLoginAt: true,
memberships: {
- where: { status: MembershipStatus.ACTIVE },
+ where: currentMembershipWhere(),
include: { cardType: { select: { name: true, type: true } } },
orderBy: { expireDate: 'desc' },
take: 1,
diff --git a/packages/shared/src/enums.ts b/packages/shared/src/enums.ts
index ec04c72..33f627c 100644
--- a/packages/shared/src/enums.ts
+++ b/packages/shared/src/enums.ts
@@ -13,6 +13,7 @@ export enum CardTypeCategory {
// ===== Membership =====
export enum MembershipStatus {
+ SCHEDULED = 'SCHEDULED',
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
USED_UP = 'USED_UP',
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 920b492..213eb98 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -55,6 +55,10 @@ export {
MEMBERSHIP_DAY_MS,
addDurationDays,
getMembershipDaysLeft,
+ isMembershipExpired,
+ getMembershipEffectiveState,
+ getStudioSlotRange,
+ canUseMembershipForSlot,
canRenewCardType,
computeMembershipGrant,
pickRenewalTarget,
@@ -137,6 +141,7 @@ export type {
} from './types/index'
export * from './types/member-care'
+export * from './lesson-feedback'
export * from './posture-assessment'
export * from './types/body-portrait'
diff --git a/packages/shared/src/lesson-feedback.ts b/packages/shared/src/lesson-feedback.ts
new file mode 100644
index 0000000..95eca74
--- /dev/null
+++ b/packages/shared/src/lesson-feedback.ts
@@ -0,0 +1,20 @@
+export const LESSON_APPARATUS = ['M', 'R', 'TT', 'Chair', 'ARC', 'SC', 'LB'] as const
+export type LessonApparatus = typeof LESSON_APPARATUS[number]
+export type ApparatusNotes = Partial>
+export const LESSON_NOTE_MAX_LENGTH = 1000
+
+export function isApparatusNotes(value: unknown): value is ApparatusNotes {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+ return Object.entries(value).every(([key, content]) =>
+ LESSON_APPARATUS.includes(key as LessonApparatus) &&
+ typeof content === 'string' && content.length <= LESSON_NOTE_MAX_LENGTH,
+ )
+}
+
+// Keep a stable apparatus order and omit fields the coach left blank.
+export function filledApparatusNotes(notes?: ApparatusNotes | null) {
+ return LESSON_APPARATUS.flatMap(apparatus => {
+ const content = notes?.[apparatus]?.trim()
+ return content ? [{ apparatus, content }] : []
+ })
+}
diff --git a/packages/shared/src/membership-renewal.ts b/packages/shared/src/membership-renewal.ts
index 1023e73..76d4bdd 100644
--- a/packages/shared/src/membership-renewal.ts
+++ b/packages/shared/src/membership-renewal.ts
@@ -18,6 +18,7 @@ export interface MembershipGrantCardType {
}
export interface MembershipGrantResult {
+ readonly startDate: Date
readonly remainingTimes: number | null
readonly expireDate: Date
readonly totalTimes: number | null
@@ -34,6 +35,7 @@ export interface MembershipRenewalHint {
}
export interface RenewalHintMembership {
+ readonly startDate?: Date | string
readonly cardTypeId: string
readonly remainingTimes: number | null
readonly expireDate: Date | string
@@ -55,6 +57,30 @@ export function canRenewCardType(type: CardTypeCategory): boolean {
return type !== CardTypeCategory.TRIAL
}
+export function isMembershipExpired(membership: { readonly status: string; readonly expireDate: Date | string }, now: Date = new Date()): boolean {
+ return membership.status === MembershipStatus.EXPIRED || new Date(membership.expireDate).getTime() <= now.getTime()
+}
+
+/** Effective benefits at read time; retain the stored balance for historical auditing. */
+export function getMembershipEffectiveState(membership: {
+ readonly startDate?: Date | string
+ readonly status: string
+ readonly expireDate: Date | string
+ readonly remainingTimes: number | null
+ readonly cardType: { readonly type: string }
+}, now: Date = new Date()): { status: MembershipStatus; remainingTimes: number | null } {
+ const expired = isMembershipExpired(membership, now)
+ const future = membership.startDate != null && new Date(membership.startDate).getTime() > now.getTime()
+ return {
+ status: expired ? MembershipStatus.EXPIRED : future ? MembershipStatus.SCHEDULED
+ : membership.status === MembershipStatus.SCHEDULED
+ ? membership.remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
+ : membership.status as MembershipStatus,
+ remainingTimes: expired && membership.cardType.type === CardTypeCategory.DURATION && membership.remainingTimes !== null
+ ? 0 : membership.remainingTimes,
+ }
+}
+
export function computeMembershipGrant(params: {
readonly existing: MembershipGrantExisting | null
readonly cardType: MembershipGrantCardType
@@ -64,6 +90,7 @@ export function computeMembershipGrant(params: {
if (!existing || cardType.type === CardTypeCategory.TRIAL) {
return {
+ startDate: now,
remainingTimes: cardType.totalTimes ?? null,
expireDate: addDurationDays(now, cardType.durationDays),
totalTimes: cardType.totalTimes ?? null,
@@ -72,13 +99,17 @@ export function computeMembershipGrant(params: {
}
const existingExpire = new Date(existing.expireDate)
- const isExpired =
- existing.status === MembershipStatus.EXPIRED || existingExpire.getTime() <= now.getTime()
- const baseDate = existingExpire.getTime() > now.getTime() ? existingExpire : now
+ const isExpired = isMembershipExpired(existing, now)
+ const baseDate = isExpired ? now : existingExpire
const expireDate = addDurationDays(baseDate, cardType.durationDays)
+ if (cardType.type === CardTypeCategory.DURATION) {
+ return { startDate: baseDate, remainingTimes: cardType.totalTimes, totalTimes: cardType.totalTimes, expireDate, isRenewal: true }
+ }
+
if (cardType.totalTimes == null) {
return {
+ startDate: now,
remainingTimes: null,
expireDate,
totalTimes: null,
@@ -88,6 +119,7 @@ export function computeMembershipGrant(params: {
if (isExpired) {
return {
+ startDate: now,
remainingTimes: cardType.totalTimes,
expireDate: addDurationDays(now, cardType.durationDays),
totalTimes: cardType.totalTimes,
@@ -96,6 +128,7 @@ export function computeMembershipGrant(params: {
}
return {
+ startDate: now,
remainingTimes: (existing.remainingTimes ?? 0) + cardType.totalTimes,
expireDate,
totalTimes: (existing.totalTimes ?? existing.remainingTimes ?? 0) + cardType.totalTimes,
@@ -112,12 +145,14 @@ export function pickRenewalTarget(
.slice()
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
+ if (matches[0]?.cardType.type === CardTypeCategory.DURATION) {
+ return matches.find(m => m.status !== MembershipStatus.EXPIRED) ?? matches[0]
+ }
return matches[0] ?? null
}
function isUsableMembership(membership: RenewalHintMembership, now: Date): boolean {
- if (membership.status !== MembershipStatus.ACTIVE) return false
- if (new Date(membership.expireDate).getTime() <= now.getTime()) return false
+ if (getMembershipEffectiveState(membership, now).status !== MembershipStatus.ACTIVE) return false
return membership.remainingTimes === null || membership.remainingTimes > 0
}
@@ -187,6 +222,7 @@ export function getMembershipRenewalHint(
}
if (memberships.length === 0) return null
+ if (memberships.some(m => getMembershipEffectiveState(m, now).status === MembershipStatus.SCHEDULED)) return null
const renewable = memberships
.filter((m) => canRenewCardType(m.cardType.type))
@@ -197,7 +233,26 @@ export function getMembershipRenewalHint(
return {
kind: 'expired',
cardTypeId: recommended?.cardTypeId ?? null,
- remainingTimes: recommended?.remainingTimes ?? null,
+ remainingTimes: recommended ? getMembershipEffectiveState(recommended, now).remainingTimes : null,
daysLeft: recommended ? getMembershipDaysLeft(recommended.expireDate, now) : null,
}
}
+
+/** Studio dates are stored as UTC date-only values; clock times are China Standard Time. */
+export function getStudioSlotRange(date: Date | string, startTime: string, endTime: string): { start: Date; end: Date } {
+ const day = typeof date === 'string' ? date.slice(0, 10) : date.toISOString().slice(0, 10)
+ return { start: new Date(`${day}T${startTime.slice(0, 5)}:00+08:00`), end: new Date(`${day}T${endTime.slice(0, 5)}:00+08:00`) }
+}
+
+export function canUseMembershipForSlot(membership: {
+ readonly startDate?: Date | string; readonly expireDate: Date | string; readonly status: string
+ readonly remainingTimes: number | null; readonly cardType: { readonly type: string }
+}, slot: { readonly date: Date | string; readonly startTime: string; readonly endTime: string }, now: Date = new Date()): boolean {
+ const state = getMembershipEffectiveState(membership, now)
+ if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) return false
+ if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
+ if (membership.cardType.type !== CardTypeCategory.DURATION) return state.status === MembershipStatus.ACTIVE
+ const { start, end } = getStudioSlotRange(slot.date, slot.startTime, slot.endTime)
+ return Number.isFinite(start.getTime()) && end > start
+ && (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate)
+}
diff --git a/packages/shared/src/types/member-care.ts b/packages/shared/src/types/member-care.ts
index 5ec209f..fc7afce 100644
--- a/packages/shared/src/types/member-care.ts
+++ b/packages/shared/src/types/member-care.ts
@@ -14,6 +14,7 @@ export interface BodyMetricRecord {
}
export interface MemberNoteRecord {
id: string; content: string; shared: boolean; createdAt: string; bookingId: string | null
+ apparatusNotes?: import('../lesson-feedback').ApparatusNotes | null
booking: { timeSlot: { date: string; startTime: string } } | null
}
export interface ProgressPhotoRecord {