diff --git a/docs/membership-periods.md b/docs/membership-periods.md index 3346f3b..10d93e1 100644 --- a/docs/membership-periods.md +++ b/docs/membership-periods.md @@ -1,51 +1,40 @@ -# 月卡周期与续费规则 +# 月卡生效、购买与续卡规则 -## 已确认规则 +## 当前规则(2026-10-30) -- 月卡(DURATION)每次购买生成独立会员卡周期,不向旧周期累加课时,不覆盖旧卡、订单和预约关联。 -- 旧周期尚未到期时,新周期从同款卡已购周期的最晚到期时间开始。连续购买按支付确认顺序排期,每期额度独立。 -- 旧周期已结束时,新周期从支付确认时间开始。旧月卡即使账面还有 6 次,也不可继续使用,不会与新购 10 次合成 16 次。 -- 次卡(TIMES)保留现有规则:有效期内续费累加次数、延长有效期;过期余次不结转。体验卡仍为独立新卡。 -- 原有月卡沿用已经约定的有效期,不按开卡日倒推缩短,不批量改写历史余额。 +- 仅新购月卡(DURATION)及后台新开月卡采用首次课程核销生效。历史卡保留原起止时间,不批量修改历史余额、订单、预约或已签合同。 +- 每次购买生成独立会员卡,不累加到旧月卡。新卡保存购买时的 `activationDurationDays`,`startDate`、`expireDate` 均为 `null`,状态为 `SCHEDULED`(待首次核销)。等待期间不会自动生效或过期。 +- 用户预约、教练确认预约、取消预约、标记未出席及课时补录都不会启动计时。原有教练确认预约时扣次的规则保留;取消退回原卡。 +- 只有已确认预约核销为 `COMPLETED`,才在同一事务中首次设置月卡日期。起点取**核销当天中国时区 00:00**,该自然日计为第 1 天,截止时间为起点加购买时的有效天数,采用左闭右开区间。例如 10 月 31 日首次核销的 30 天卡,可用至 11 月 29 日结束,数据库截止时间为 11 月 30 日 00:00(中国时区)。 +- 后续课程核销、重复请求不重新计算或延长有效期。购卡、预约、确认和核销使用同一会员行锁,避免并发重复激活。 +- 待首次核销月卡可以预约,仍须签署要求签署的合同并有可用课时。为了保证提前预约的课程落在首次生效后的有效期内,可预约课程不超过「今天中国时区零点 + 已购天数」。生效后按实际周期验证课程起止时间。 +- 每张月卡的额度、预约、退款和到期独立,月卡余次不跨期结转。次卡、体验卡原有生效和续卡规则保留。 -## 状态与展示 +## 页面与合同 -新增 `SCHEDULED`(待生效)状态,数据库迁移只在原枚举末尾追加值,不改写现有记录。 +- 月卡详情展示「第一次上课核销,才开始计时」,明确自然日、预约不计时和查询位置。使用说明中可直接查看《会员卡服务合同》范本。 +- 「我的会员卡」对新卡显示「首次预约课程核销当天生效」及已购天数,可直接预约,不提供手动提前启用。 +- 预约选卡、馆主代约、会员详情和编辑页均支持日期待定,避免显示虚假日期或将待核销卡误判过期。 +- 新月卡合同模板 `2026-10-v3` 的会员卡信息与第二条说明首次核销起算,未激活时保留日期待定,不用支付或合同创建时间代替。合同购买天数采用会员卡快照。 +- 原有有确定日期的月卡周期、自动到期/生效和提前启用规则继续兼容。已签合同内容与签名摘要保留。 -读取会员权益时,依据服务端时间实时判断生效、到期和可用次数,不依赖凌晨任务。到期的限次月卡返回 0 次可用权益;原始账面余额留在旧记录中用于历史核对。待生效额度不会计入当前余额。 +## 在线支付前端入口 -会员页分为「正在使用」「下期待生效」「历史记录」。续费页显示本期剩余与下期起止时间/独立额度,提前续费提示「支付后排期待生效」。页面打开期间在生效和到期边界刷新展示。 +- 会员卡详情底部改为「联系馆主」,使用微信原生 `button open-type="contact"` 打开小程序客服会话,可附带所咨询会员卡的页面卡片。接待方沿用小程序后台已有客服配置。 +- 支付确认弹窗和下单操作由 `packages/app/src/config/features.ts` 的 `ONLINE_PAYMENT_ENABLED = false` 屏蔽,保留原前端支付流程以供恢复。 +- 后端下单、支付回调、退款、订单、发卡与合同能力均保留;未来恢复支付时,新月卡仍遵循首次核销生效。 -## 提前启用 +## 发布步骤 -接口:`POST /membership/:id/activate`,要求登录及 `{ "confirmed": true }`,仅操作登录用户自己的周期。 +1. 备份数据库,应用 `20261030090000_monthly_first_check_in` 迁移(日期允许为空,新增有效天数快照字段;不更新历史记录)。 +2. 生成 Prisma Client,构建并配套发布 shared/server 及新版小程序;旧版小程序不支持日期待定,不应单独发布服务端。 +3. 在微信小程序真机中验收「联系馆主」客服会话,并验证新月卡购买/后台开卡、预约、确认、取消、核销后的日期与新合同范本。 -只能启用同款卡最早的待生效周期。本期必须已经用完次数;不限次月卡或仍有余次的周期不允许提前结束。 +无新增环境变量。当前改动只完成本地实现与验证,未应用生产迁移、部署服务端或发布小程序。 -启用前先检查本期未完成预约;如仍有未来待确认/已确认课程,需上完或取消后再启用。新周期从启用时刻开始,保留完整已购时长,后续待生效周期连续重新衔接。已经预约到新周期或后续周期的课程必须仍在调整后的范围内,否则拒绝操作并提示调整预约,不自动取消课程。 +## 本地验证 -购卡排期、提前启用及预约写入使用同一会员的数据库行锁串行处理。启用事务只修改周期日期与状态,不改变订单和预约所关联的周期 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 宽度下的未过期续卡、已过期续卡、待生效周期列表、提前启用确认及成功后的日期/额度更新;未发起真实支付或修改真实会员权益。 -- 本次月卡迁移及服务端改动已与器械课后评价一并部署生产;新版小程序尚未上传或发布。详见 [联合发布记录](combined-release-2026-10-18.md)。 +- 服务端完整回归:39 个套件、627 项测试通过;包含首次核销、中国时区跨日、预约确认与取消不激活、重复核销、历史卡兼容及合同条款/有效天数快照。 +- shared/server 构建、小程序类型检查、微信小程序生产构建通过;首页 10 项测试通过。 +- 390px 宽度下使用真实 Vue 页面及隔离模拟数据检查详情说明、联系馆主按钮和待首次核销月卡列表。截图见 [页面预览](screenshots/monthly-first-check-in-preview.png)。微信原生客服会话需真机验收。 +- `pnpm lint` 因环境未安装脚本引用的 `eslint` 而无法运行,未为此修改依赖或锁文件。 diff --git a/docs/screenshots/monthly-first-check-in-preview.png b/docs/screenshots/monthly-first-check-in-preview.png new file mode 100644 index 0000000..96f49f8 Binary files /dev/null and b/docs/screenshots/monthly-first-check-in-preview.png differ diff --git a/packages/app/src/components/BookingConfirmPopup.vue b/packages/app/src/components/BookingConfirmPopup.vue index 953a252..dab2f26 100644 --- a/packages/app/src/components/BookingConfirmPopup.vue +++ b/packages/app/src/components/BookingConfirmPopup.vue @@ -44,12 +44,13 @@ {{ memberships[0].cardType.name }} - 周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }} + 首次课程核销当天生效 + 周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }} 剩余 {{ memberships[0].remainingTimes }} 次 - 有效期至 {{ memberships[0].expireDate.slice(0, 10) }} + {{ memberships[0].expireDate ? '有效期至 ' + formatChinaDate(memberships[0].expireDate) : '生效后 ' + (memberships[0].activationDurationDays ?? memberships[0].cardType.durationDays) + ' 天内不限次' }} @@ -69,12 +70,13 @@ > {{ m.cardType.name }} - 周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }} + 首次课程核销当天生效 + 周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }} 剩余 {{ m.remainingTimes }} 次 - 有效期至 {{ m.expireDate.slice(0, 10) }} + {{ m.expireDate ? '有效期至 ' + formatChinaDate(m.expireDate) : '生效后 ' + (m.activationDurationDays ?? m.cardType.durationDays) + ' 天内不限次' }} diff --git a/packages/app/src/components/OwnedMembershipCard.vue b/packages/app/src/components/OwnedMembershipCard.vue index ecf778f..80003e4 100644 --- a/packages/app/src/components/OwnedMembershipCard.vue +++ b/packages/app/src/components/OwnedMembershipCard.vue @@ -13,7 +13,7 @@ - {{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期 + {{ formatChinaDate(membership.startDate).replace(/-/g, '.') }} 起{{ formatDateTimeFull(membership.expireDate) }} 到期 @@ -21,14 +21,14 @@ diff --git a/packages/app/src/components/QuickEntry.vue b/packages/app/src/components/QuickEntry.vue index 88a8034..4896d47 100644 --- a/packages/app/src/components/QuickEntry.vue +++ b/packages/app/src/components/QuickEntry.vue @@ -31,6 +31,11 @@ + + 月卡待首次核销 · 可预约课程 + 预约课程 + + 会员卡尚未生效 查看会员卡 @@ -54,7 +59,7 @@