Bladeren bron

feat: migrate appointment statistics

彭峰 2 maanden geleden
bovenliggende
commit
56d335784b

+ 1 - 1
docs/migration/admin-migration-status.md

@@ -15,7 +15,7 @@
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
-- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:29 个已映射,62 个显式返回 501 阻塞原因。课程词库进度、学习次数/生词状态、预约学习记录双表写入,以及由学员/陪练角色约束的预约状态机均已完成;旧源码从未实际调用的单元聚合仍等待业务规则。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:29 个已映射,62 个显式返回 501 阻塞原因。课程词库进度、学习次数/生词状态、预约学习记录双表写入及旧版课次/时长/佣金统计公式均已完成;预约结束还涉及课时账本与抗遗忘批量创建,继续显式阻塞,避免半写。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容、8 类 addon 联表,以及学习记录详情、预约详情、生词本、21 天抗遗忘列表可走云函数;私有读取只在真实 Parse 会话下切换,其余 action 仍由显式迁移路由保留旧端点。
 

+ 5 - 5
docs/migration/wxapp-cloud-action-matrix.md

@@ -16,7 +16,7 @@
 | 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
 | 商品只读 | `product_get`, `product_list`, `product_stock_list` |
 | 账号 | `user_get`, `user_info_name`, `user_list`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
-| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_update_v2`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
+| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_tongji`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
 | 反馈 | `guestbook_add` |
 
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
@@ -44,7 +44,7 @@
 - `e_add_words`:验证词库 ID 与用户归属后批量更新 `PracticeRecord`;`save=1` 递增学习次数,`ifnew=1/0` 加入/移出生词,首次学习会创建带云端来源标识的进度记录。
 - `stu_record_update_v2`:按 `预约 GeneralID + 学员 ID` 幂等新增或更新 Model 56;校验预约归属和词库 ID,根据 `xxqs.check` 重算 `learned/ygg/djq`。线上运行时实际未暴露文档中的 `Psql.transaction`,因此新记录采用安全整数范围内的高熵数字 ID,并顺序写入 `DailyStudyRecord` 与 `CommonModel`;任一步失败都会补偿删除已创建记录,避免孤立数据。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
-- `e_order_update_v2`:恢复旧页面可相互印证的 `0 未开始 → 10 已开始 → 11 已上课 → 20 已评价 → 30 已结课` 状态机;10/11/30 只允许预约 `pl` 陪练操作,20 只允许 `szyh` 学员操作,管理员可审计代办,重复提交保持幂等并拒绝越级流转。
+- `e_order_tongji`:按交付包 `Pages/API/WXAPP.cshtml` 原公式恢复六个字符串字段。陪练按 `LessonRecord.jsmz` 统计课型 1/2/3,佣金分别为 20/40/40、时长分别为 0.5/1/1 小时;学员按 `CourseAppointment.szyh` 且 `dszt>10` 统计课型和时长,`t_total` 继续按旧逻辑统计 `PracticeRecord.yhid`。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
@@ -86,7 +86,7 @@
 | 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
-| 预约统计 | `e_order_tongji` | 首页统计口径与返回字段 |
+| 预约状态 | `e_order_update_v2` | 旧状态 11 同时扣减 `_User.legacyUserData` 课时、写余额账本并按学习记录创建最多 15 条抗遗忘双表记录;云运行时实际没有事务,需完成可补偿的一致性方案 |
 | 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
 
 ## 前端切换决策
@@ -95,12 +95,12 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 持有真实 Parse 会话时,`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_update_v2`、`e_record_detail`、`e_words_list`、`e_get_21list`、`stu_record_update_v2` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_record_detail`、`e_words_list`、`e_get_21list`、`stu_record_update_v2` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 
-在 62 个阻塞 action 中包含订单、支付和预约统计等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+在 62 个阻塞 action 中仍包含订单、支付和预约结束等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

+ 11 - 9
scripts/deploy-admin-functions.mjs

@@ -243,7 +243,7 @@ const BLOCKED = {
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
   content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
-  unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_tongji: '需确认预约统计口径',
+  unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '状态 11 还需原子扣减课时、写余额账本并创建 21 天抗遗忘记录;云运行时未提供事务',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
@@ -344,9 +344,9 @@ async function authorizeBusinessRow(current, row, subjectFields, actorFields = [
 }
 function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
 async function orderDetailRow(inputId, current, authorize = true) {
-  const id = String(inputId || ''); if (!id) fail(400, '缺少预约 ID'); const rows = await Psql.query('SELECT c.*, a."objectId" AS "__appointmentObjectId", row_to_json(a) AS "__addon", n."nodeName" AS "__courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'54\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
-  let source = rows[0]; if (!source && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const direct = await Psql.query('SELECT a.*, n."nodeName" AS "__courseTitle" FROM "CourseAppointment" a LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE a."company" = $1 AND a."objectId" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (direct[0]) source = { ...direct[0], __appointmentObjectId: direct[0].objectId, generalId: id, GeneralID: id, title: direct[0].title || '课程预约' }; }
-  if (!source) return null; const addon = source.__addon || {}; const appointmentObjectId = source.__appointmentObjectId || addon.objectId; const courseTitle = source.__courseTitle || source.subtitle || ''; delete source.__addon; delete source.__appointmentObjectId; delete source.__courseTitle; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.appointmentObjectId = appointmentObjectId; row.kcmc = row.kcmc || courseTitle; row.yykcmc = row.yykcmc || courseTitle; row.HoneyName = row.HoneyName || String(row.Title || '').split('(')[0]; if (authorize) await authorizeBusinessRow(current, row, ['szyh'], ['pl']); return row;
+  const id = String(inputId || ''); if (!id) fail(400, '缺少预约 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(a) AS "__addon", n."nodeName" AS "__courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'54\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
+  let source = rows[0]; if (!source && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const direct = await Psql.query('SELECT a.*, n."nodeName" AS "__courseTitle" FROM "CourseAppointment" a LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE a."company" = $1 AND a."objectId" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (direct[0]) source = { ...direct[0], generalId: id, GeneralID: id, title: direct[0].title || '课程预约' }; }
+  if (!source) return null; const addon = source.__addon || {}; const courseTitle = source.__courseTitle || source.subtitle || ''; delete source.__addon; delete source.__courseTitle; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.kcmc = row.kcmc || courseTitle; row.yykcmc = row.yykcmc || courseTitle; row.HoneyName = row.HoneyName || String(row.Title || '').split('(')[0]; if (authorize) await authorizeBusinessRow(current, row, ['szyh'], ['pl']); return row;
 }
 async function recordDetailData(input, current) {
   const id = String(input.id || input.gid || ''); if (!id) fail(400, '缺少学习记录 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(d) AS "__addon" FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'56\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
@@ -380,9 +380,11 @@ async function upsertStudyRecord(input, current) {
   if (record) { applyFields(record); common.set('title', title); common.set('inputer', inputer); common.set('hits', number(record.get('learned'))); await Parse.Object.saveAll([record, common], { useMasterKey: true }); return { GeneralID: number(found.generalId), generalId: number(found.generalId), objectId: common.id, recordObjectId: record.id, created: false }; }
   created = true; const baseId = Math.floor(100000000000000 + Math.random() * 800000000000000); const recordId = baseId; const generalId = baseId + 1; record = new Parse.Object('DailyStudyRecord'); record.set('company', current.get('company')); record.set('sourceKey', 'cloud:stu_record:' + uid + ':' + orderId); applyFields(record); await record.save(null, { useMasterKey: true }); try { await Psql.none('UPDATE "DailyStudyRecord" SET "id" = $1 WHERE "objectId" = $2', [recordId, record.id]); common = new Parse.Object('CommonModel'); common.set('company', current.get('company')); common.set('sourceKey', 'cloud:stu_record:' + uid + ':' + orderId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', 56); common.set('nodeId', 291); common.set('tableName', 'ZL_C_ss'); common.set('title', title); common.set('inputer', inputer); common.set('hits', number(record.get('learned'))); common.set('status', 99); await common.save(null, { useMasterKey: true }); } catch (error) { if (common && common.id) await common.destroy({ useMasterKey: true }).catch(() => undefined); await record.destroy({ useMasterKey: true }).catch(() => undefined); throw error; } return { GeneralID: generalId, generalId, objectId: common.id, recordObjectId: record.id, created };
 }
-async function updateOrderStatus(input, current) {
-  const content = parseObject(input.content, '预约更新内容'); const orderId = String(content.GeneralID ?? content.generalId ?? input.orderId ?? input.id ?? '').trim(); if (!orderId) fail(400, '缺少预约 ID'); const status = number(input.status, -1); if (![10,11,20,30].includes(status)) fail(400, '不支持的预约状态'); const requestedActor = number(input.uid || ownLegacyId(current)); if (!isAdminUser(current) && requestedActor !== ownLegacyId(current)) fail(403, '无权代替其他用户更新预约'); const order = await orderDetailRow(orderId, current, false); if (!order) fail(404, '预约记录不存在'); const studentId = number(order.szyh); const coachId = number(order.pl); const actorId = ownLegacyId(current); const requiredActor = status === 20 ? studentId : coachId; if (!isAdminUser(current) && (!requiredActor || actorId !== requiredActor)) fail(403, status === 20 ? '仅预约学员可以确认评价' : '仅预约陪练可以更新课程状态'); const previous = number(order.dszt); const allowedPrevious = { 10:[0,10], 11:[10,11], 20:[11,20], 30:[11,20,30] }[status]; if (!allowedPrevious.includes(previous)) fail(409, '预约状态不能从 ' + previous + ' 更新为 ' + status); if (previous === status) return { GeneralID: order.GeneralID ?? order.generalId ?? orderId, previousStatus: previous, status, unchanged: true };
-  const appointment = await new Parse.Query('CourseAppointment').get(String(order.appointmentObjectId), { useMasterKey: true }); appointment.set('dszt', String(status)); const updatedAt = cleanText(content.UpDateTime ?? content.updateTime, 30); if (updatedAt && !/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/.test(updatedAt)) fail(400, '预约更新时间格式错误'); if (status === 10 && updatedAt && !appointment.get('kssj')) appointment.set('kssj', updatedAt); if (status === 11 && updatedAt) appointment.set('jssj', updatedAt); await appointment.save(null, { useMasterKey: true }); return { GeneralID: order.GeneralID ?? order.generalId ?? orderId, previousStatus: previous, status, unchanged: false, objectId: appointment.id };
+async function orderStatistics(input, current) {
+  const uid = await authorizeRequestedUser(current, input.uid); let target = current; if (uid !== ownLegacyId(current)) { const fields = await fieldsOf('_User'); target = await findByLegacyId('_User', fields, uid, ['legacyUserId','userid','num']); } if (!target) fail(404, '用户不存在'); const groupId = number(target.get('legacyGroupId') || target.get('groupId')); let t30 = 0; let t60 = 0; let tTiyan = 0; let total = 0; let commission = 0; let duration = 0;
+  if (groupId === 3) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'3\')::int AS tiyan, COUNT(*)::int AS total FROM "LessonRecord" WHERE "company" = $1 AND CAST("jsmz" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(row.total); commission = t30 * 20 + t60 * 40 + tTiyan * 40; duration = t30 * 0.5 + t60 + tTiyan; }
+  else if (groupId === 1) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'3\')::int AS tiyan FROM "CourseAppointment" WHERE "company" = $1 AND CAST("szyh" AS text) = $2 AND CAST("dszt" AS text) ~ \'^[0-9]+$\' AND CAST("dszt" AS numeric) > 10', [DEFAULT_COMPANY_ID, String(uid)]); const practice = await Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(practice.total); duration = t30 * 0.5 + t60 + tTiyan; }
+  return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:String(duration), t_total:String(total), t_yongji:String(commission) };
 }
 function learnedDate(row) { const raw = String(row.dqrq || '').trim(); return /^\d{8}$/.test(raw) ? raw.slice(0,4) + '-' + raw.slice(4,6) + '-' + raw.slice(6,8) : String(row.kywsj || '').slice(0,10); }
 async function memoryPage(input, current) {
@@ -393,7 +395,7 @@ async function resolveContent(input, requireOne = false) { const fields = await
 async function handler(request, response) {
   try {
     const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
-    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_update_v2','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
+    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (action === 'user_login_passwd') {
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
@@ -419,7 +421,7 @@ async function handler(request, response) {
     if (action === 'product_get') { const result = await sqlPage('Product', { page: 1, pageSize: 1 }, [{ field: 'id', value: number(input.id) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('商品不存在')); }
     if (action === 'product_del' || action === 'product_stock_change' || action === 'product_sale_change') { const fields = await fieldsOf('Product'); const found = await findByLegacyId('Product', fields, input.id, ['id']); if (!found) return response.status(404).json(reject('商品不存在')); if (action === 'product_del') await found.destroy({ useMasterKey: true }); else { const field = action === 'product_stock_change' ? 'nums' : 'isChu'; if (fields[field]) found.set(field, action === 'product_stock_change' ? number(input.nums || input.stock) : Boolean(input.value ?? input.status)); await found.save(null, { useMasterKey: true }); } return response.json(envelope({ objectId: found.id })); }
     const legacyId = ownLegacyId(current);
-    if (action === 'e_order_update_v2') return response.json(envelope(await updateOrderStatus(input, current)));
+    if (action === 'e_order_tongji') return response.json(envelope(await orderStatistics(input, current)));
     if (action === 'stu_record_update_v2') return response.json(envelope(await upsertStudyRecord(input, current)));
     if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
     if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }

+ 20 - 15
scripts/smoke-admin-functions.mjs

@@ -61,6 +61,7 @@ let memberLegacyId = 0;
 let coachId = '';
 let coachLegacyId = 0;
 let temporaryAppointmentId = '';
+const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
   const companyId = sample.results?.[0]?.objectId;
@@ -86,7 +87,7 @@ try {
   memberLegacyId = 900000000 + Math.floor(Date.now() / 1000) % 90000000;
   const member = await jsonRequest(`${PARSE_URL}/users`, {
     method: 'POST',
-    body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, company }),
+    body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, legacyGroupId: 1, company }),
   }, true);
   memberId = member.objectId;
   const coachUsername = `codex_coach_smoke_${Date.now()}`;
@@ -97,9 +98,16 @@ try {
     body: JSON.stringify({ username: coachUsername, password: coachPassword, legacyUserId: coachLegacyId, legacyGroupId: 3, company }),
   }, true);
   coachId = coach.objectId;
+  for (const lessonType of [1, 2, 3]) {
+    const lesson = await jsonRequest(`${PARSE_URL}/classes/LessonRecord`, {
+      method: 'POST',
+      body: JSON.stringify({ company, sourceKey: `cloud:smoke-lesson:${coachLegacyId}:${lessonType}`, jsmz: String(coachLegacyId), kclx: String(lessonType) }),
+    }, true);
+    temporaryLessonIds.push(lesson.objectId);
+  }
   const createdAppointment = await jsonRequest(`${PARSE_URL}/classes/CourseAppointment`, {
     method: 'POST',
-    body: JSON.stringify({ company, sourceKey: `cloud:smoke-appointment:${memberLegacyId}`, szyh: String(memberLegacyId), kcid: '16', dszt: '0', pl: String(coachLegacyId) }),
+    body: JSON.stringify({ company, sourceKey: `cloud:smoke-appointment:${memberLegacyId}`, szyh: String(memberLegacyId), kcid: '16', dslx: '1', dszt: '30', pl: String(coachLegacyId) }),
   }, true);
   temporaryAppointmentId = createdAppointment.objectId;
   const memberLogin = await jsonRequest(`${PARSE_URL}/login`, {
@@ -196,19 +204,12 @@ try {
   if (updatedStudy.result?.created || updatedStudy.result?.GeneralID !== createdStudy.result.GeneralID) throw new Error('预约学习记录幂等更新校验失败');
   const studyList = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: `UserId=${memberLegacyId}|dsid=${temporaryAppointmentId}`, page: 1, pageSize: 2 });
   if (studyList.page?.itemCount !== 1 || Number(studyList.result?.[0]?.ygg) !== 1 || Number(studyList.result?.[0]?.learned) !== 1) throw new Error('预约学习记录聚合字段写后读校验失败');
-  const orderContent = JSON.stringify({ GeneralID: temporaryAppointmentId, UpDateTime: '2026-08-18 12:00' });
-  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 10, content: orderContent }, 403);
-  const startedOrder = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 10, content: orderContent });
-  if (startedOrder.result?.previousStatus !== 0 || startedOrder.result?.status !== 10 || startedOrder.result?.unchanged) throw new Error('预约开始状态迁移校验失败');
-  const repeatedStart = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 10, content: orderContent });
-  if (!repeatedStart.result?.unchanged) throw new Error('预约状态幂等更新校验失败');
-  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 11, content: orderContent }, 403);
-  await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 11, content: orderContent });
-  await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 20, content: orderContent }, 403);
-  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 20, content: orderContent });
-  await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 30, content: orderContent });
-  const completedOrder = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_detail', id: temporaryAppointmentId });
-  if (Number(completedOrder.result?.[0]?.dszt) !== 30) throw new Error('预约状态机写后读校验失败');
+  const blockedOrderUpdate = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 10, content: JSON.stringify({ GeneralID: temporaryAppointmentId }) }, 501);
+  if (!String(blockedOrderUpdate.retmsg).includes('课时')) throw new Error('预约状态写入未显式阻塞隐藏副作用');
+  const coachStats = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_tongji', uid: coachLegacyId });
+  if (coachStats.result?.t30 !== '1' || coachStats.result?.t60 !== '1' || coachStats.result?.t_tiyan !== '1' || coachStats.result?.t_total !== '3' || coachStats.result?.t_shichang !== '2.5' || coachStats.result?.t_yongji !== '100') throw new Error('陪练预约统计公式校验失败');
+  const memberStats = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_tongji', uid: memberLegacyId });
+  if (memberStats.result?.t30 !== '1' || memberStats.result?.t60 !== '0' || memberStats.result?.t_tiyan !== '0' || memberStats.result?.t_total !== '1' || memberStats.result?.t_shichang !== '0.5' || memberStats.result?.t_yongji !== '0') throw new Error(`学员预约与生词统计公式校验失败:${JSON.stringify(memberStats.result)}`);
   const deniedCourseWords = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_ck_list', uid: 58, nids: 40, page: 1, pageSize: 1 }, 403);
   if (!String(deniedCourseWords.retmsg).includes('无权')) throw new Error('普通用户跨用户课程词库未被拒绝');
   const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
@@ -222,6 +223,10 @@ try {
 
   console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
+  for (const lessonId of temporaryLessonIds) await jsonRequest(`${PARSE_URL}/classes/LessonRecord/${lessonId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时陪练课次清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (memberLegacyId) {
     const commonWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:stu_record:${memberLegacyId}:` } }));
     const commonRows = await jsonRequest(`${PARSE_URL}/classes/CommonModel?where=${commonWhere}&limit=1000`, {}, true).catch(() => ({ results: [] }));

+ 4 - 4
src/app/core/api.service.spec.ts

@@ -96,12 +96,12 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { GeneralID: 123456, created: true } });
   });
 
-  it('routes appointment status transitions with a Parse session', () => {
+  it('routes appointment statistics with a Parse session', () => {
     sessionToken = 'r:session-token';
-    api.post('e_order_update_v2', { uid: 23, status: 10, content: '{"GeneralID":918}' }).subscribe();
+    api.post('e_order_tongji', { uid: 23 }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
-    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_order_update_v2', uid: 23, status: 10, content: '{"GeneralID":918}' } });
-    request.flush({ retcode: 0, result: { GeneralID: 918, previousStatus: 0, status: 10 } });
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_order_tongji', uid: 23 } });
+    request.flush({ retcode: 0, result: { t30: '1', t_shichang: '0.5', t_total: '1' } });
   });
 
   it('keeps learning detail reads on legacy until a real Parse session exists', () => {

+ 1 - 1
src/app/core/cloud-action-migration.ts

@@ -15,7 +15,7 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'e_ck_list',
   'e_get_21list',
   'e_order_detail',
-  'e_order_update_v2',
+  'e_order_tongji',
   'e_record_detail',
   'e_words_list',
   'stu_record_update_v2',