feat: add monthly card periods and apparatus lesson feedback
This commit is contained in:
24
docs/lesson-apparatus-feedback.md
Normal file
24
docs/lesson-apparatus-feedback.md
Normal file
@@ -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 测试。本地浏览器组件预览用于验证展开、切换、填写计数、仅器械保存及分段展示;不替代微信真机验收。
|
||||
51
docs/membership-periods.md
Normal file
51
docs/membership-periods.md
Normal file
@@ -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 宽度下的未过期续卡、已过期续卡、待生效周期列表、提前启用确认及成功后的日期/额度更新;未发起真实支付或修改真实会员权益。
|
||||
- 本次月卡迁移及服务端改动尚未部署生产,新版小程序尚未上传或发布。
|
||||
16
packages/app/src/components/ApparatusNoteList.vue
Normal file
16
packages/app/src/components/ApparatusNoteList.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<view v-if="entries.length" class="apparatus-notes">
|
||||
<view class="notes-heading"><text>器械练习</text><text class="count">{{ entries.length }} 项记录</text></view>
|
||||
<view v-for="entry in entries" :key="entry.apparatus" class="apparatus-note"><text class="apparatus-label">{{ entry.apparatus }}</text><text class="apparatus-copy">{{ entry.content }}</text></view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { filledApparatusNotes } from '@mp-pilates/shared'
|
||||
import type { ApparatusNotes } from '@mp-pilates/shared'
|
||||
const props = defineProps<{ notes?: ApparatusNotes | null }>()
|
||||
const entries = computed(() => filledApparatusNotes(props.notes))
|
||||
</script>
|
||||
<style scoped>
|
||||
.apparatus-notes{margin:24rpx 0 20rpx;padding-top:22rpx;border-top:1rpx solid #e8e3d9;}.notes-heading{display:flex;align-items:center;justify-content:space-between;color:#8a8275;font-size:22rpx;margin-bottom:8rpx;}.count{font-size:20rpx;color:#a0978a;}.apparatus-note{padding:20rpx 0;border-bottom:1rpx solid #eee9e1;}.apparatus-note:last-child{border:0;padding-bottom:0;}.apparatus-label{display:inline-block;min-width:52rpx;border-radius:8rpx;padding:4rpx 12rpx;background:#eaf0e5;color:#60735b;font-family:'Baskerville','Songti SC',serif;font-size:26rpx;text-align:center;}.apparatus-copy{display:block;margin-top:12rpx;font-size:27rpx;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere;color:#5e574d;}
|
||||
</style>
|
||||
@@ -44,6 +44,7 @@
|
||||
<view class="card-item selected">
|
||||
<view class="card-info">
|
||||
<text class="card-name">{{ memberships[0].cardType.name }}</text>
|
||||
<text v-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</text>
|
||||
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
|
||||
剩余 {{ memberships[0].remainingTimes }} 次
|
||||
</text>
|
||||
@@ -68,6 +69,7 @@
|
||||
>
|
||||
<view class="card-info">
|
||||
<text class="card-name">{{ m.cardType.name }}</text>
|
||||
<text v-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text>
|
||||
<text class="card-remain" v-if="m.remainingTimes !== null">
|
||||
剩余 {{ m.remainingTimes }} 次
|
||||
</text>
|
||||
@@ -92,7 +94,7 @@
|
||||
<text class="deduction-text">
|
||||
{{ selectedMembership.remainingTimes === null
|
||||
? `「${selectedMembership.cardType.name}」有效期内不限次`
|
||||
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
|
||||
: `教练确认后将从「${selectedMembership.cardType.name}」对应周期扣除 1 次课时` }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
@@ -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
|
||||
|
||||
99
packages/app/src/components/CoachNoteEditor.vue
Normal file
99
packages/app/src/components/CoachNoteEditor.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<view class="note-overlay" :class="{ leaving: closing }" @tap="requestClose" @touchmove.stop.prevent>
|
||||
<view class="note-sheet" @tap.stop @touchmove.stop>
|
||||
<view class="sheet-header">
|
||||
<view class="handle" />
|
||||
<view class="title-row">
|
||||
<view><text class="eyebrow">AFTER CLASS · 练习手记</text><text class="title">记录这一课的进步</text></view>
|
||||
<button class="close" :disabled="busy || closing" aria-label="关闭课后评价" @tap="requestClose">×</button>
|
||||
</view>
|
||||
<text class="subtitle">{{ bookingId ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="sheet-body" :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<view class="section-heading"><text>整体评价</text><text class="optional">选填</text></view>
|
||||
<view class="summary-input">
|
||||
<textarea v-model="content" aria-label="整体评价(选填)" :maxlength="LESSON_NOTE_MAX_LENGTH" :disabled="busy" :cursor-spacing="100" placeholder="本次练习的整体表现、进步或下次建议…" placeholder-class="note-placeholder" />
|
||||
<text class="counter">{{ content.length }} / {{ LESSON_NOTE_MAX_LENGTH }}</text>
|
||||
</view>
|
||||
<view class="apparatus-heading"><view><text class="section-heading">器械练习记录</text><text class="section-caption">点击器械展开,记录本次上课情况</text></view><text class="progress">已填写 {{ filledCount }} / 7</text></view>
|
||||
<view class="apparatus-list">
|
||||
<view v-for="(apparatus, index) in LESSON_APPARATUS" :key="apparatus" class="apparatus-card" :class="{ expanded: active === apparatus, filled: !!notes[apparatus]?.trim() }">
|
||||
<button class="apparatus-toggle" :disabled="busy" :aria-expanded="active === apparatus" @tap="toggle(apparatus)">
|
||||
<text class="apparatus-index">0{{ index + 1 }}</text><text class="apparatus-name">{{ apparatus }}</text>
|
||||
<text class="apparatus-state">{{ notes[apparatus]?.trim() ? '已填写' : '选填' }}</text><text class="chevron" :class="{ rotated: active === apparatus }">⌄</text>
|
||||
</button>
|
||||
<view class="apparatus-body" :class="{ open: active === apparatus }" :aria-hidden="active !== apparatus">
|
||||
<view class="apparatus-input">
|
||||
<textarea v-if="active === apparatus" v-model="notes[apparatus]" :aria-label="apparatus + ' 器械记录(选填)'" :maxlength="LESSON_NOTE_MAX_LENGTH" :disabled="busy" :cursor-spacing="100" :placeholder="'记录 ' + apparatus + ' 的练习内容与表现…'" placeholder-class="note-placeholder" />
|
||||
<view class="input-foot"><text>由教练自由填写</text><text>{{ notes[apparatus]?.length || 0 }} / {{ LESSON_NOTE_MAX_LENGTH }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="active !== apparatus && notes[apparatus]?.trim()" class="preview">{{ notes[apparatus]?.trim() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="visibility"><view><text class="visibility-title">分享给学员</text><text class="section-caption">{{ shared ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }}</text></view><switch :checked="shared" :disabled="busy" color="#617d70" @change="changeShared" /></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="sheet-footer">
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<button class="save" :disabled="busy || closing || !hasContent" :loading="busy" @tap="save">{{ busy ? '正在保存…' : '保存课后评价' }}</button>
|
||||
<text class="footer-hint">填写整体评价或任意一项器械即可保存</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from '@mp-pilates/shared'
|
||||
import type { ApparatusNotes, LessonApparatus } from '@mp-pilates/shared'
|
||||
import { post } from '../utils/request'
|
||||
const props = defineProps<{ userId: string; bookingId?: string }>()
|
||||
const emit = defineEmits<{ close: []; saved: [] }>()
|
||||
const content = ref(''), notes = ref<ApparatusNotes>({}), active = ref<LessonApparatus | null>(null), shared = ref(false)
|
||||
const confirmingClose = ref(false)
|
||||
const busy = ref(false), closing = ref(false), error = ref('')
|
||||
const filledCount = computed(() => filledApparatusNotes(notes.value).length)
|
||||
const hasContent = computed(() => !!content.value.trim() || filledCount.value > 0)
|
||||
const hasDraft = computed(() => !!content.value || Object.values(notes.value).some(Boolean))
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
function toggle(apparatus: LessonApparatus) { active.value = active.value === apparatus ? null : apparatus }
|
||||
function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
|
||||
function close() {
|
||||
uni.hideKeyboard()
|
||||
closing.value = true
|
||||
closeTimer = setTimeout(() => emit('close'), 220)
|
||||
}
|
||||
async function requestClose() {
|
||||
if (busy.value || closing.value || confirmingClose.value) return
|
||||
if (hasDraft.value) {
|
||||
confirmingClose.value = true
|
||||
const result = await uni.showModal({ title: '放弃这份未保存的评价?', content: '已填写的内容尚未保存,可以继续填写。', confirmText: '继续填写', cancelText: '放弃' }).finally(() => { confirmingClose.value = false })
|
||||
if (!result.cancel) return
|
||||
}
|
||||
close()
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || closing.value || !hasContent.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await post(`/admin/members/${props.userId}/progress/notes`, {
|
||||
content: content.value.trim(),
|
||||
apparatusNotes: Object.fromEntries(filledApparatusNotes(notes.value).map(note => [note.apparatus, note.content])),
|
||||
shared: shared.value,
|
||||
...(props.bookingId ? { bookingId: props.bookingId } : {}),
|
||||
})
|
||||
emit('saved')
|
||||
uni.showToast({ title: '课后评价已保存', icon: 'success' })
|
||||
close()
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { if (closeTimer) clearTimeout(closeTimer) })
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.note-overlay{position:fixed;inset:0;z-index:220;display:flex;align-items:flex-end;background:rgba(42,48,40,.42);animation:veil-in .22s ease both;color:#514943;}
|
||||
.note-sheet{display:flex;flex-direction:column;width:100%;height:88vh;box-sizing:border-box;background:#fbf9f5;border-radius:36rpx 36rpx 0 0;overflow:hidden;animation:sheet-in .3s cubic-bezier(.2,.8,.2,1) both;}
|
||||
button{font-weight:400;}button::after{border:0;}.sheet-header{padding:16rpx 32rpx 24rpx;border-bottom:1rpx solid #e8e3d9;}.handle{width:64rpx;height:6rpx;border-radius:8rpx;background:#d6d8cd;margin:0 auto 26rpx;}.title-row{display:flex;align-items:center;justify-content:space-between;gap:16rpx;}.eyebrow{display:block;color:#75816f;letter-spacing:3rpx;font-size:18rpx;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:38rpx;line-height:1.5;margin-top:10rpx;}.close{margin:0;flex-shrink:0;width:72rpx;height:72rpx;line-height:66rpx;padding:0;border-radius:50%;background:#efeee7;color:#827b70;font-size:42rpx;}.subtitle{display:block;font-size:23rpx;color:#827b70;margin-top:14rpx;}.sheet-body{flex:1;height:0;min-height:0;}.body-inner{padding:30rpx 32rpx 10rpx;}.section-heading{display:flex;align-items:center;gap:16rpx;font-size:28rpx;font-weight:500;}.optional{font-size:21rpx;color:#948a7b;font-weight:400;}.summary-input{margin-top:18rpx;background:#f0ede6;border:1rpx solid #e6e1d7;border-radius:20rpx;padding:20rpx;}.summary-input textarea{height:148rpx;}.counter{display:block;text-align:right;font-size:20rpx;color:#938b7f;margin-top:10rpx;}textarea{width:100%;box-sizing:border-box;font-size:27rpx;line-height:1.8;color:#514943;}.note-placeholder{color:#9a9488;font-size:25rpx;}.apparatus-heading{display:flex;justify-content:space-between;align-items:center;gap:12rpx;margin:34rpx 0 20rpx;}.section-caption{display:block;font-size:22rpx;color:#8b8276;line-height:1.7;margin-top:8rpx;}.progress{flex-shrink:0;font-size:21rpx;padding:10rpx 14rpx;background:#eaf0e6;border-radius:30rpx;color:#61775c;}.apparatus-list{display:flex;flex-direction:column;gap:14rpx;}.apparatus-card{border:1rpx solid #e6e1d8;border-radius:20rpx;background:#fffdf9;overflow:hidden;transition:border-color .22s,background-color .22s;}.apparatus-card.expanded{border-color:#91a28a;background:#f6f8f1;}.apparatus-toggle{display:flex;align-items:center;width:100%;margin:0;padding:0 22rpx;height:96rpx;line-height:1.5;background:transparent;text-align:left;border-radius:0;color:#514943;}.apparatus-index{font-family:'Baskerville',serif;font-size:24rpx;color:#aaa18f;width:48rpx;}.apparatus-name{flex:1;font-family:'Baskerville','Songti SC',serif;font-size:34rpx;letter-spacing:1rpx;}.apparatus-state{font-size:21rpx;color:#a09789;}.filled .apparatus-state{color:#617d70;}.chevron{display:block;font-size:30rpx;margin-left:20rpx;color:#8b9381;transition:transform .22s;}.chevron.rotated{transform:rotate(180deg);}.apparatus-body{height:0;opacity:0;overflow:hidden;transition:height .24s ease,opacity .2s ease;}.apparatus-body.open{height:292rpx;opacity:1;}.apparatus-input{padding:0 22rpx 20rpx;}.apparatus-input textarea{height:220rpx;padding:16rpx;background:#fffdf9;border-radius:12rpx;border:1rpx solid #e7e9df;}.input-foot{display:flex;justify-content:space-between;color:#959180;font-size:20rpx;line-height:32rpx;margin-top:12rpx;}.preview{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:23rpx;line-height:1.7;color:#8a8276;margin:-6rpx 22rpx 20rpx 70rpx;}.visibility{display:flex;align-items:center;justify-content:space-between;gap:16rpx;margin:28rpx 0 20rpx;padding:24rpx 0;border-top:1rpx solid #e6e1d8;}.visibility-title{font-size:27rpx;}.sheet-footer{padding:20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));border-top:1rpx solid #e8e3d9;background:#fbf9f5;}.save{display:block;width:100%;margin:0;border-radius:20rpx;background:#617d70;color:#fff;line-height:88rpx;font-size:28rpx;transition:background .2s,transform .15s;}.save:active{transform:scale(.985);}.save[disabled]{background:#dce1d7;color:#8e9788;}.footer-hint{display:block;text-align:center;color:#9a9285;font-size:20rpx;margin-top:14rpx;}.error{display:block;font-size:24rpx;line-height:1.6;color:#a35645;margin-bottom:16rpx;}.leaving{animation:veil-out .22s ease both;}.leaving .note-sheet{animation:sheet-out .22s ease both;}@keyframes veil-in{from{background:rgba(42,48,40,0)}to{background:rgba(42,48,40,.42)}}@keyframes veil-out{to{background:rgba(42,48,40,0)}}@keyframes sheet-in{from{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes sheet-out{to{transform:translateY(100%)}}@media(prefers-reduced-motion:reduce){.note-overlay,.note-sheet,.leaving,.leaving .note-sheet{animation:none;}.apparatus-body,.chevron,.save{transition:none;}}
|
||||
</style>
|
||||
@@ -12,12 +12,13 @@
|
||||
<view class="tabs"><button v-for="item in tabs" :key="item.key" :class="{ active: tab === item.key }" @tap="tab = item.key">{{ item.name }}</button></view>
|
||||
<BodyAssessmentRecords v-if="tab === 'metrics'" :records="archive.metrics" :admin="admin" :user-id="userId" @remove="remove('metrics', $event)" />
|
||||
<template v-if="tab === 'notes'">
|
||||
<view class="heading"><text>{{ admin ? '教练笔记' : '教练寄语' }}</text><button v-if="admin" class="small" @tap="openEditor('notes')">+ 写笔记</button></view>
|
||||
<view class="heading"><text>{{ admin ? '教练笔记' : '教练寄语' }}</text><button v-if="admin" class="small" @tap="openEditor('notes')">+ 写评价</button></view>
|
||||
<text class="muted intro">{{ admin ? '私密笔记仅馆主可见,开启共享后学员才能看到。' : '留住被看见的进步,也记下下一次练习的方向。' }}</text>
|
||||
<view v-if="!archive.notes.length" class="empty">静待第一份练习寄语<text>教练分享的课后反馈会留在这里。</text></view>
|
||||
<view v-for="note in archive.notes" :key="note.id" class="panel note" :class="{ private: !note.shared }">
|
||||
<view class="heading"><text class="badge">{{ note.shared ? '教练寄语 · 学员可见' : '私密 · 仅馆主可见' }}</text><button v-if="admin" class="text-btn" @tap="remove('notes', note.id)">删除</button></view>
|
||||
<text class="body-copy">{{ note.content }}</text>
|
||||
<text v-if="note.content" class="body-copy">{{ note.content }}</text>
|
||||
<ApparatusNoteList :notes="note.apparatusNotes" />
|
||||
<text v-if="note.booking" class="lesson">课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }}</text>
|
||||
<text class="muted">{{ formatChinaDate(note.createdAt) }}</text>
|
||||
</view>
|
||||
@@ -44,12 +45,12 @@
|
||||
<view v-for="n in [10, 30, 50]" :key="n" class="milestone" :class="{ locked: !archive.milestones.includes(n) }"><text class="kicker">A MOMENT TO REMEMBER</text><text class="milestone-number">{{ n }}<text>节</text></text><text class="milestone-title">{{ milestoneTitles[n] }}</text><text class="muted">{{ archive.milestones.includes(n) ? '你已走过这段旅程' : '还差 ' + (n - archive.completedCount) + ' 节,一步步来' }}</text><button v-if="archive.milestones.includes(n) && !admin" class="small" :disabled="busy" @tap="saveMilestone(n)">保存纪念卡</button></view>
|
||||
</template>
|
||||
</template>
|
||||
<view v-if="editor" class="overlay" @tap="onBackdropTap">
|
||||
<scroll-view scroll-y class="sheet" @tap.stop><view class="heading"><text>{{ editor === 'notes' ? '写一份教练笔记' : '添加成长照片' }}</text><button class="text-btn" :disabled="busy" @tap="closeEditor">关闭</button></view>
|
||||
<template v-if="editor !== 'notes'"><text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker></template>
|
||||
<template v-if="editor === 'notes'"><text v-if="bookingId" class="lesson">已关联本次完成课程</text><textarea v-model="noteContent" maxlength="1000" placeholder="今天观察到了什么?下一次练习需要注意什么?" /><text class="muted">{{ noteContent.length }} / 1000</text><view class="switch-row"><view><text>分享给学员</text><text class="muted">{{ shared ? '这份评语将出现在学员档案' : '仅馆主自己可见' }}</text></view><switch :checked="shared" color="#617d70" @change="changeShared" /></view></template>
|
||||
<template v-if="editor === 'photos'"><textarea v-model="caption" maxlength="200" placeholder="例如:正面站姿 · 第一个月" /><button class="btn pale" :disabled="busy" @tap="choosePhoto">{{ photoPath ? '重新选择照片' : '从相册选择照片' }}</button><image v-if="photoPath" class="upload-preview" :src="photoPath" mode="aspectFit" /><text class="muted">仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。</text></template>
|
||||
<text v-if="saveError" class="error">{{ saveError }}</text><button class="btn" :loading="busy" :disabled="busy" @tap="save">{{ editor === 'photos' ? '上传并请求学员授权' : '保存记录' }}</button>
|
||||
<CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="bookingId" @close="editor = ''" @saved="load" />
|
||||
<view v-if="editor === 'photos'" class="overlay" @tap="onBackdropTap">
|
||||
<scroll-view scroll-y class="sheet" @tap.stop><view class="heading"><text>添加成长照片</text><button class="text-btn" :disabled="busy" @tap="closeEditor">关闭</button></view>
|
||||
<text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker>
|
||||
<textarea v-model="caption" maxlength="200" placeholder="例如:正面站姿 · 第一个月" /><button class="btn pale" :disabled="busy" @tap="choosePhoto">{{ photoPath ? '重新选择照片' : '从相册选择照片' }}</button><image v-if="photoPath" class="upload-preview" :src="photoPath" mode="aspectFit" /><text class="muted">仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。</text>
|
||||
<text v-if="saveError" class="error">{{ saveError }}</text><button class="btn" :loading="busy" :disabled="busy" @tap="save">上传并请求学员授权</button>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<canvas canvas-id="milestoneCard" class="export-canvas" style="width:600px;height:800px" />
|
||||
@@ -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<ProgressArchive | null>(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<Record<string, string>>({}), selectedPhotos = ref<string[]>([]), comparison = ref<{ id: string; url: string; date: string }[]>([])
|
||||
const milestoneTitles: Record<number, string> = { 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<string, string> }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
|
||||
await new Promise<void>((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<string, string> }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
|
||||
await new Promise<void>((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 }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<text class="card-pill-meta">
|
||||
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
|
||||
</text>
|
||||
<text v-if="card.cardType.type === 'DURATION'" class="card-pill-meta">{{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -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 '已过点'
|
||||
|
||||
@@ -187,13 +187,14 @@ const detail = ref<AdminMemberDetail | null>(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<string, string> = {
|
||||
[MembershipStatus.SCHEDULED]: '待生效',
|
||||
ACTIVE: '有效',
|
||||
EXPIRED: '已过期',
|
||||
USED_UP: '已用完',
|
||||
|
||||
@@ -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<string>(formatDate(new Date()))
|
||||
const selectedPeriod = ref<PeriodKey>(null)
|
||||
const showConfirmPopup = ref(false)
|
||||
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(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) => {
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
<text class="renew-col-value">{{ renewalPreview.currentHighlight }}</text>
|
||||
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
|
||||
<text class="renew-col-date">{{ renewalPreview.currentExpire }}</text>
|
||||
<text v-if="renewalPreview.currentAllowance" class="renew-col-date">{{ renewalPreview.currentAllowance }}</text>
|
||||
</view>
|
||||
<view class="renew-arrow">
|
||||
<view class="renew-arrow-line" />
|
||||
@@ -231,7 +232,9 @@
|
||||
<text class="renew-col-label">确认后</text>
|
||||
<text class="renew-col-value">{{ renewalPreview.afterHighlight }}</text>
|
||||
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
|
||||
<text v-if="renewalPreview.afterStart" class="renew-col-date">{{ renewalPreview.afterStart }}</text>
|
||||
<text class="renew-col-date">{{ renewalPreview.afterExpire }}</text>
|
||||
<text v-if="renewalPreview.afterAllowance" class="renew-col-date">{{ renewalPreview.afterAllowance }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -341,8 +344,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInviteStore } from '../../stores/invite'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onLoad, onShow, onHide, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||
import {
|
||||
CardTypeCategory,
|
||||
@@ -351,6 +354,8 @@ import {
|
||||
computeMembershipGrant,
|
||||
getMembershipDaysLeft,
|
||||
pickRenewalTarget,
|
||||
isMembershipExpired,
|
||||
getMembershipEffectiveState,
|
||||
} from '@mp-pilates/shared'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { get, post } from '../../utils/request'
|
||||
@@ -415,6 +420,9 @@ const paymentStatusIssue = ref<PaymentStatusIssue>('none')
|
||||
const paymentRedirecting = ref(false)
|
||||
const paymentConfirmationSession = ref(0)
|
||||
const failedCoverIds = ref<Set<string>>(new Set())
|
||||
const renewalNow = ref(Date.now())
|
||||
let renewalClock: ReturnType<typeof setTimeout> | undefined
|
||||
let renewalPageVisible = false
|
||||
|
||||
onShareAppMessage(() => {
|
||||
const sharedCard = showAll.value ? null : card.value
|
||||
@@ -471,12 +479,29 @@ const renewalTarget = computed(() => {
|
||||
|
||||
const isRenewal = computed(() => !!renewalTarget.value)
|
||||
|
||||
function stopRenewalClock() {
|
||||
if (renewalClock) clearTimeout(renewalClock)
|
||||
renewalClock = undefined
|
||||
}
|
||||
function syncRenewalClock() {
|
||||
stopRenewalClock()
|
||||
renewalNow.value = Date.now()
|
||||
if (!renewalPageVisible) return
|
||||
const expires = renewalTarget.value ? new Date(renewalTarget.value.expireDate).getTime() : 0
|
||||
if (expires > renewalNow.value) {
|
||||
const remaining = expires - renewalNow.value
|
||||
const nextDayBoundary = remaining - (Math.ceil(remaining / 86_400_000) - 1) * 86_400_000
|
||||
renewalClock = setTimeout(syncRenewalClock, Math.max(1, nextDayBoundary + 1))
|
||||
}
|
||||
}
|
||||
watch(() => renewalTarget.value?.expireDate, syncRenewalClock)
|
||||
|
||||
const grantPreview = computed(() => {
|
||||
if (!card.value || !renewalTarget.value) return null
|
||||
return computeMembershipGrant({
|
||||
existing: renewalTarget.value,
|
||||
cardType: card.value,
|
||||
now: new Date(),
|
||||
now: new Date(renewalNow.value),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -489,39 +514,47 @@ function formatStudioDate(value: Date | string): string {
|
||||
}
|
||||
|
||||
const renewalPreview = computed(() => {
|
||||
const target = renewalTarget.value
|
||||
const grant = grantPreview.value
|
||||
const currentCard = card.value
|
||||
const now = new Date(renewalNow.value)
|
||||
const current = userStore.memberships.filter(m => m.cardTypeId === currentCard?.id && new Date(m.startDate) <= now && !isMembershipExpired(m, now)).sort((a,b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())[0]
|
||||
const target = currentCard?.type === CardTypeCategory.DURATION ? current || renewalTarget.value : renewalTarget.value
|
||||
if (!target || !grant || !currentCard) return null
|
||||
|
||||
const isTimes = grant.remainingTimes !== null
|
||||
const isExpired =
|
||||
target.status === MembershipStatus.EXPIRED ||
|
||||
new Date(target.expireDate).getTime() <= Date.now()
|
||||
const isTimes = currentCard.type === CardTypeCategory.TIMES
|
||||
const isExpired = isMembershipExpired(target, now)
|
||||
const effective = getMembershipEffectiveState(target, now)
|
||||
const awaitingPeriod = effective.status === MembershipStatus.SCHEDULED
|
||||
const statusLabel =
|
||||
target.status === MembershipStatus.USED_UP
|
||||
? '次数已用完'
|
||||
: isExpired
|
||||
? '已到期'
|
||||
awaitingPeriod ? '下期待生效' : isExpired
|
||||
? '已到期'
|
||||
: target.status === MembershipStatus.USED_UP
|
||||
? '次数已用完'
|
||||
: '使用中'
|
||||
|
||||
return {
|
||||
isTimes,
|
||||
statusLabel,
|
||||
currentHighlight: isTimes
|
||||
? String(target.remainingTimes ?? 0)
|
||||
: String(Math.max(0, getMembershipDaysLeft(target.expireDate))),
|
||||
? String(effective.remainingTimes ?? 0)
|
||||
: String(isExpired || awaitingPeriod ? 0 : Math.max(0, getMembershipDaysLeft(target.expireDate, now))),
|
||||
afterHighlight: isTimes
|
||||
? String(grant.remainingTimes ?? 0)
|
||||
: String(Math.max(0, getMembershipDaysLeft(grant.expireDate))),
|
||||
: String(currentCard.durationDays),
|
||||
highlightUnit: isTimes ? '次剩余' : '天有效',
|
||||
currentExpire: formatStudioDate(target.expireDate),
|
||||
afterExpire: formatStudioDate(grant.expireDate),
|
||||
afterStart: isTimes ? '' : formatStudioDate(grant.startDate) + ' 生效',
|
||||
afterExpire: formatStudioDate(grant.expireDate) + (isTimes ? '' : ' 到期'),
|
||||
currentAllowance: !isTimes && target.remainingTimes !== null
|
||||
? awaitingPeriod ? '当前无生效周期' : isExpired ? '原周期余次已失效' : '本周期剩余 ' + String(effective.remainingTimes) + ' 次' : '',
|
||||
afterAllowance: !isTimes ? (grant.startDate > now ? '下期待生效 · ' : '新周期 · ') + (grant.remainingTimes === null ? '不限次' : String(grant.remainingTimes) + ' 次') : '',
|
||||
addedLabel: isTimes
|
||||
? '本次预计续入 +' + String(currentCard.totalTimes ?? 0) + ' 次 · +' + String(currentCard.durationDays) + ' 天'
|
||||
: '本次预计续入 +' + String(currentCard.durationDays) + ' 天',
|
||||
forfeitNote: isExpired && isTimes && (target.remainingTimes ?? 0) > 0
|
||||
? '到期余次不结转,按新购次数起算'
|
||||
: '本次月卡' + (currentCard.totalTimes !== null ? '包含 ' + String(currentCard.totalTimes) + ' 次 · ' : ' · ') + String(currentCard.durationDays) + ' 天有效',
|
||||
forfeitNote: !isTimes
|
||||
? grant.startDate > now ? '本期权益保留至到期,下期单独生效;月卡余次不跨期累计。' : '原周期已结束,剩余课时不结转;新周期按本次购买额度起算。'
|
||||
: isExpired && grant.remainingTimes !== null
|
||||
? '到期余次不结转,按新购 ' + String(currentCard.totalTimes) + ' 次起算'
|
||||
: '',
|
||||
}
|
||||
})
|
||||
@@ -532,14 +565,19 @@ const renewalPreviewText = computed(() => {
|
||||
if (preview.isTimes) {
|
||||
return '确认后预计剩余 ' + preview.afterHighlight + ' 次,有效期至 ' + preview.afterExpire
|
||||
}
|
||||
return '确认后预计有效期至 ' + preview.afterExpire
|
||||
return preview.afterAllowance + ',' + preview.afterStart + ',' + preview.afterExpire
|
||||
})
|
||||
|
||||
const purchaseNotes = computed(() => {
|
||||
if (!card.value) return []
|
||||
const notes: string[] = []
|
||||
|
||||
if (isRenewal.value) {
|
||||
if (renewalTarget.value && isMembershipExpired(renewalTarget.value, new Date(renewalNow.value))) {
|
||||
notes.push('旧卡已到期,原周期剩余课时失效;支付确认后按新购权益重新生效,有效期为 ' + String(card.value.durationDays) + ' 天。')
|
||||
} else if (isRenewal.value && card.value.type === CardTypeCategory.DURATION) {
|
||||
notes.push('提前续费将生成独立的下期月卡,本期权益保留至到期,未用完的课时不结转。')
|
||||
notes.push('本期课时用完后,可在「我的会员卡」确认提前启用下一期;原周期随之结束。')
|
||||
} else if (isRenewal.value) {
|
||||
notes.push('支付确认后,现有会员有效期预计再延长 ' + String(card.value.durationDays) + ' 天。')
|
||||
} else {
|
||||
notes.push('支付确认后会员卡生效,有效期为 ' + String(card.value.durationDays) + ' 天。')
|
||||
@@ -548,7 +586,7 @@ const purchaseNotes = computed(() => {
|
||||
if (card.value.totalTimes === null) {
|
||||
notes.push('有效期内可不限次预约课程。')
|
||||
} else {
|
||||
notes.push('共 ' + String(card.value.totalTimes) + ' 次课时,每次预约扣除 1 次。')
|
||||
notes.push('共 ' + String(card.value.totalTimes) + ' 次课时,教练确认预约后扣除 1 次。')
|
||||
}
|
||||
|
||||
if (card.value.type === CardTypeCategory.DURATION) {
|
||||
@@ -556,7 +594,9 @@ const purchaseNotes = computed(() => {
|
||||
}
|
||||
|
||||
notes.push(
|
||||
card.value.totalTimes === null
|
||||
card.value.type === CardTypeCategory.DURATION
|
||||
? '每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。'
|
||||
: card.value.totalTimes === null
|
||||
? '到期后自动失效。'
|
||||
: '到期或课时用完后自动失效,不可退款。',
|
||||
)
|
||||
@@ -580,8 +620,9 @@ const buyButtonLabel = computed(() => {
|
||||
})
|
||||
|
||||
const bottomPriceHint = computed(() => {
|
||||
if (savingsLabel.value) return savingsLabel.value + ' · 支付确认后生效'
|
||||
return '支付确认后生效'
|
||||
const effectiveHint = card.value?.type === CardTypeCategory.DURATION && grantPreview.value && grantPreview.value.startDate.getTime() > renewalNow.value ? '支付后排期待生效' : '支付确认后生效'
|
||||
if (savingsLabel.value) return savingsLabel.value + ' · ' + effectiveHint
|
||||
return effectiveHint
|
||||
})
|
||||
|
||||
const purchaseConfirmationNote = computed(() => {
|
||||
@@ -772,6 +813,7 @@ async function preparePurchaseConfirmation() {
|
||||
uni.showToast({ title: '暂时无法核对会员权益,请稍后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
syncRenewalClock()
|
||||
purchaseConfirmVisible.value = true
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
|
||||
@@ -848,7 +890,8 @@ async function settlePaidOrder(orderId: string) {
|
||||
|
||||
pendingOrderId.value = ''
|
||||
paymentRedirecting.value = true
|
||||
uni.showToast({ title: isRenewal.value ? '续卡权益已到账' : '会员卡已到账', icon: 'success' })
|
||||
const scheduledMonthly = card.value?.type === CardTypeCategory.DURATION && userStore.memberships.some(m => m.cardTypeId === card.value?.id && m.status === MembershipStatus.SCHEDULED)
|
||||
uni.showToast({ title: scheduledMonthly ? '下期月卡已安排' : isRenewal.value ? '续卡权益已到账' : '会员卡已到账', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
if (session === paymentConfirmationSession.value) goToMembership()
|
||||
}, 900)
|
||||
@@ -941,13 +984,19 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
renewalPageVisible = true
|
||||
syncRenewalClock()
|
||||
if (userStore.loggedIn) void invite.refresh().catch(() => {})
|
||||
if (!paymentConfirming.value && !paymentPending.value) {
|
||||
void refreshMembershipContext()
|
||||
}
|
||||
})
|
||||
|
||||
onHide(() => { renewalPageVisible = false; stopRenewalClock() })
|
||||
|
||||
onUnmounted(() => {
|
||||
renewalPageVisible = false
|
||||
stopRenewalClock()
|
||||
paymentConfirmationSession.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -43,6 +43,15 @@
|
||||
<text class="usage-note">已用次数包含预约扣次,不等同于已完成上课;进度条表示已用次数占比。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="scheduledMemberships.length" class="group-section">
|
||||
<view class="group-header"><text class="group-title">下期待生效</text><text class="group-count">{{ scheduledMemberships.length }} 个独立周期</text></view>
|
||||
<view v-for="m in scheduledMemberships" :key="m.id" class="mc mc--scheduled">
|
||||
<view class="mc-top"><view class="mc-name-area"><text class="mc-name">{{ m.cardType.name }}</text><text class="mc-type-text">{{ m.remainingTimes === null ? '有效期内不限次' : '周期额度剩余 ' + m.remainingTimes + ' 次' }}</text></view><text class="mc-status">待生效</text></view>
|
||||
<view class="mc-history-bottom"><view class="mc-history-info"><text class="mc-date-label">{{ formatDateTimeFull(m.startDate) }} 生效</text><text class="mc-date-label">{{ formatDateTimeFull(m.expireDate) }} 到期</text></view><button v-if="canActivate(m)" class="mc-renew mc-renew--inactive" :disabled="!!activating" @tap="activate(m)">{{ activating === m.id ? '启用中…' : '提前启用' }}</button></view>
|
||||
</view>
|
||||
<text class="usage-note">下期课时与本期分别计算,不计入当前可用余额。预约需落在对应周期内。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="inactiveMemberships.length" class="group-section">
|
||||
<view class="group-header">
|
||||
<text class="group-title">历史记录</text>
|
||||
@@ -58,7 +67,8 @@
|
||||
</view>
|
||||
<view class="mc-history-bottom">
|
||||
<view class="mc-history-info">
|
||||
<text v-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
|
||||
<text v-if="m.cardType.type === CardTypeCategory.DURATION && m.status === MembershipStatus.EXPIRED" class="mc-date-label">原周期未用课时已失效</text>
|
||||
<text v-else-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
|
||||
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
|
||||
</view>
|
||||
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
|
||||
@@ -75,13 +85,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onResize } from '@dcloudio/uni-app'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { onShow, onHide, onResize } from '@dcloudio/uni-app'
|
||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, CardTypeCategory, getMembershipEffectiveState } from '@mp-pilates/shared'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getCardTypeLabel } from '../../utils/format'
|
||||
import { getCardTypeLabel, formatDateTimeFull } from '../../utils/format'
|
||||
import { post } from '../../utils/request'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
|
||||
|
||||
@@ -93,19 +105,34 @@ onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
||||
const loading = computed(() => userStore.membershipsLoading)
|
||||
const membershipNow = ref(Date.now())
|
||||
const refreshing = ref(false)
|
||||
const activating = ref('')
|
||||
let boundaryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pageVisible = false
|
||||
function stopBoundaryClock() { if (boundaryTimer) clearTimeout(boundaryTimer); boundaryTimer = undefined }
|
||||
function syncBoundaryClock() {
|
||||
stopBoundaryClock()
|
||||
membershipNow.value = Date.now()
|
||||
if (!pageVisible) return
|
||||
const boundaries = userStore.memberships.flatMap(m => [new Date(m.startDate).getTime(), new Date(m.expireDate).getTime()]).filter(time => time > membershipNow.value)
|
||||
const delay = Math.min(60_000, ...boundaries.map(time => time - membershipNow.value + 1))
|
||||
boundaryTimer = setTimeout(syncBoundaryClock, delay)
|
||||
}
|
||||
watch(() => userStore.memberships, syncBoundaryClock)
|
||||
|
||||
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
||||
const allMemberships = computed(() => userStore.memberships.map(m => ({ ...m, ...getMembershipEffectiveState(m, new Date(membershipNow.value)) })))
|
||||
const scheduledMemberships = computed(() => allMemberships.value.filter(m => m.status === MembershipStatus.SCHEDULED).sort((a,b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()))
|
||||
|
||||
const activeMemberships = computed(() =>
|
||||
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||
)
|
||||
|
||||
const inactiveMemberships = computed(() =>
|
||||
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
||||
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE && m.status !== MembershipStatus.SCHEDULED),
|
||||
)
|
||||
|
||||
function statusLabel(status: MembershipStatus): string {
|
||||
const map: Record<MembershipStatus, string> = {
|
||||
[MembershipStatus.SCHEDULED]: '待生效',
|
||||
[MembershipStatus.ACTIVE]: '有效',
|
||||
[MembershipStatus.EXPIRED]: '已过期',
|
||||
[MembershipStatus.USED_UP]: '已用完',
|
||||
@@ -153,7 +180,27 @@ function goRenew(m: MembershipWithCardType) {
|
||||
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
|
||||
}
|
||||
|
||||
onShow(loadMemberships)
|
||||
function canActivate(m: MembershipWithCardType): boolean {
|
||||
return m.cardType.type === CardTypeCategory.DURATION && m.remainingTimes !== 0
|
||||
&& scheduledMemberships.value.find(period => period.cardTypeId === m.cardTypeId)?.id === m.id
|
||||
&& !activeMemberships.value.some(period => period.cardTypeId === m.cardTypeId && period.remainingTimes !== 0)
|
||||
}
|
||||
async function activate(m: MembershipWithCardType) {
|
||||
if (activating.value || !canActivate(m)) return
|
||||
activating.value = m.id
|
||||
try {
|
||||
const confirmation = await uni.showModal({ title: '提前启用下一期月卡?', content: '原周期将立即结束,新一期从现在重新计算有效期,后续待生效周期按新日期重新衔接。已有预约若超出新的周期范围,需要先调整。', confirmText: '确认启用' })
|
||||
if (!confirmation.confirm) return
|
||||
await post('/membership/' + encodeURIComponent(m.id) + '/activate', { confirmed: true })
|
||||
await loadMemberships()
|
||||
uni.showToast({ title: '新周期已启用', icon: 'success' })
|
||||
} catch (error) { uni.showToast({ title: getErrorMessage(error, '启用失败,请重试'), icon: 'none' }) }
|
||||
finally { activating.value = '' }
|
||||
}
|
||||
|
||||
onShow(() => { pageVisible = true; syncBoundaryClock(); void loadMemberships() })
|
||||
onHide(() => { pageVisible = false; stopBoundaryClock() })
|
||||
onUnmounted(stopBoundaryClock)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -201,4 +248,5 @@ onShow(loadMemberships)
|
||||
.summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
|
||||
.summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
|
||||
.owned-card-wrap { margin-bottom: 20rpx; }
|
||||
.mc--scheduled { background: #f0f5f7; border-color: #dce7ed; .mc-status { color: #7291a1; } }
|
||||
</style>
|
||||
|
||||
@@ -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<MembershipWithCardType[]>('/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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `memberships`
|
||||
MODIFY `status` ENUM('ACTIVE', 'EXPIRED', 'USED_UP', 'SCHEDULED') NOT NULL DEFAULT 'ACTIVE';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `member_notes` ADD COLUMN `apparatus_notes` JSON NULL;
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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({
|
||||
|
||||
@@ -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<BookingWithRelations> {
|
||||
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<BookingWithRelations> {
|
||||
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<BookingWithRelations> {
|
||||
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 },
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
packages/server/src/membership/__tests__/monthly-periods.spec.ts
Normal file
106
packages/server/src/membership/__tests__/monthly-periods.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Equals } from 'class-validator'
|
||||
|
||||
export class ActivatePeriodDto {
|
||||
@Equals(true, { message: '请确认提前启用新周期' })
|
||||
confirmed!: true
|
||||
}
|
||||
7
packages/server/src/membership/membership-query.ts
Normal file
7
packages/server/src/membership/membership-query.ts
Normal file
@@ -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 }] }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<Membership & { cardType: CardType }> {
|
||||
@@ -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<CardType, 'id' | 'type' | 'totalTimes' | 'durationDays'>,
|
||||
now = new Date(),
|
||||
): Promise<Membership> {
|
||||
// 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<CardType[]> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -169,24 +169,20 @@ export class SlotGeneratorService {
|
||||
async checkExpiredMemberships(): Promise<number> {
|
||||
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`,
|
||||
)
|
||||
|
||||
72
packages/server/src/user/__tests__/lesson-feedback.spec.ts
Normal file
72
packages/server/src/user/__tests__/lesson-feedback.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum CardTypeCategory {
|
||||
|
||||
// ===== Membership =====
|
||||
export enum MembershipStatus {
|
||||
SCHEDULED = 'SCHEDULED',
|
||||
ACTIVE = 'ACTIVE',
|
||||
EXPIRED = 'EXPIRED',
|
||||
USED_UP = 'USED_UP',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
20
packages/shared/src/lesson-feedback.ts
Normal file
20
packages/shared/src/lesson-feedback.ts
Normal file
@@ -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<Record<LessonApparatus, string>>
|
||||
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 }] : []
|
||||
})
|
||||
}
|
||||
@@ -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<T extends RenewalHintMembership>(
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user