Răsfoiți Sursa

feat(teaching): complete lessons atomically before student reviews

彭峰 4 ore în urmă
părinte
comite
0cd9c73492
32 a modificat fișierele cu 929 adăugiri și 134 ștergeri
  1. 54 0
      docs/features/lesson-workflow-2026-10-11.md
  2. 4 1
      package.json
  3. 2 0
      projects/xiaoshu-admin/src/app/admin.models.ts
  4. 1 1
      projects/xiaoshu-admin/src/app/operations.service.spec.ts
  5. 1 1
      projects/xiaoshu-admin/src/app/operations.service.ts
  6. 16 11
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  7. 1 0
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  8. 9 0
      projects/xiaoshu-mobile/src/app/core/api.service.spec.ts
  9. 6 4
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  10. 8 3
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  11. 100 0
      projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts
  12. 19 0
      projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.spec.ts
  13. 19 2
      projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts
  14. 18 19
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  15. 49 37
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  16. 1 1
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts
  17. 1 0
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  18. 34 24
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts
  19. 1 1
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts
  20. 39 0
      scripts/audit-lesson-chain.mjs
  21. 23 0
      scripts/cloud/lesson-workflow-ops.js
  22. 70 0
      scripts/cloud/lesson-workflow.js
  23. 2 2
      scripts/cloud/member-enrollment-app.js
  24. 1 1
      scripts/cloud/mobile-teaching.js
  25. 13 4
      scripts/deploy-admin-functions.mjs
  26. 51 0
      scripts/migrate-lesson-workflow.mjs
  27. 36 16
      scripts/smoke-mobile-teaching-flow.mjs
  28. 181 0
      scripts/sql/lesson-workflow.sql
  29. 4 1
      scripts/sql/member-enrollment.sql
  30. 156 0
      scripts/tests/lesson-workflow.test.mjs
  31. 8 4
      scripts/tests/member-enrollment.test.mjs
  32. 1 1
      scripts/tests/payroll-integrity.test.mjs

+ 54 - 0
docs/features/lesson-workflow-2026-10-11.md

@@ -0,0 +1,54 @@
+# 排课、结课与学生评价改造
+
+2026-10-11。源代码实现完成;用户最后要求只在本地运行,不部署线上。本文件不代表三端真实账号验收完成。
+
+## 业务与实现
+
+- 流程改为后台排课 → 老师上课 → 老师一次结课(0→10→30)→ 学生随后评价。评价不再阻塞报告、完课记录或课酬快照。
+- 管理端、师生课表及详情使用同一新库预约。归属按帐套、教师/学生编号、GeneralID、bindingId 匹配。新课表不等待旧系统实时拉取。
+- 课表和详情在页面可见时每 15 秒查询新库版本;版本变化才重载。回到前台、恢复网络与手动刷新立即查询。隐藏页面不发轮询请求,离开页面清理监听器。
+- 未来课程也可打开详情,开始权限由服务器北京时间当天规则决定。已开始课程可跨午夜继续。保留日期、学生筛选,增加清除筛选与最近课程入口。
+- 详情失败时缓存只展示,不开放写操作;开课失败给出持久提示。写作分类兼容旧 if_writing,沿用原有授课和反馈流程,无需选择单词单元,不新增音视频课堂。
+- app_lesson_complete 在一个 SQL 事务内完成报告、课堂成果快照、完课记录、预留消费、扣费和状态推进。重复请求、连点及并发由预约锁、请求键和已有结果共同约束。
+- app_lesson_review 校验预约学生、已结课状态、1–5 分、最多 200 字。首次评价生效,重复请求返回已有评价,课程状态与账务保持不变。
+- 历史 11/20 课程复用已有学习记录、评价及扣费凭据;缺失、重复或矛盾凭据进入后台异常清单,不猜测补扣。旧 11/20/40 保留读取,不批量改历史。
+- 后台按预约查看计划/实际时间、报告、评价、预留状态、上课记录数和异常。管理员原有课时纠错操作增加事务内原因记录。
+- 旧分步结课/确认写入口返回刷新提示;通用内容接口不能创建或修改正式预约、上课记录与评价。新库推进过的课次有同步保护和状态回退触发器。
+- 工资仍使用唯一有效课次和原计价快照;没有修改已锁定工资单。
+
+## 本地运行
+
+```sh
+npm run start:mobile  # http://localhost:4200/pages/home/shop
+npm run start:admin   # http://localhost:4300/admin/schedule
+```
+
+两端开发服务和 main.js 已 HTTP 验证成功。开发代理仍连接现有云后台,不是隔离后端。当前线上缺少新结课/评价接口;本地新详情会提示服务尚未更新并保持只读。不要把本地前端启动视为新业务闭环的线上验收。
+
+## 已执行验证
+
+| 检查 | 结果与边界 |
+| --- | --- |
+| 移动端 ChromeHeadless | 169 项通过,含路由、开课错误、写作分类、详情只读、结课和评价页面测试;接口为测试替身 |
+| 管理端相关测试 | 41 项通过,覆盖排课页面和服务调用 |
+| 开课、课时、教学与新流程 Node 测试 | 41 项通过;新事务测试使用 PGlite 和真实网关函数代码,非生产 PostgreSQL 多连接验收 |
+| 工资回归 | 20 项通过,保留计价快照、跨批次防重、审计与锁单规则 |
+| 云函数语法 | 11 个定义校验通过 |
+| 生产构建 | 师生端、管理端通过;保留现有样式预算和 CommonJS 警告 |
+| 本地 HTTP | 两个课表入口与主脚本均为 200 |
+| 线上能力检查 | 新接口 app_lesson_complete、app_lesson_review 尚未发布;app:actions:audit 因这两项缺失返回失败 |
+| 钱老师实际样例 | 尚未拿到账号对应的预约/接口响应,不能认定现场根因;未修改真实课程 |
+| 三端真实浏览器/手机 | 尚未验收;浏览器控制连接超时,自动测试不替代手机体验 |
+
+数据库专项覆盖:失败注入后报告/扣费/状态整体回滚、重复完成/评价、越权、过期版本、取消、同日提前开课、非当天阻止、跨午夜恢复、旧凭据复用与拒绝补扣、旧接口拒绝回退、超过一页的列表、后台凭据查询及新库版本变化。
+
+## 后续发布与验收步骤(本次未执行)
+
+1. 只读核对钱老师:运行 `node scripts/audit-lesson-chain.mjs`,先返回候选编号,再配置 `XIAOSHU_AUDIT_TEACHER_ID` 精确检查。配置 `XIAOSHU_AUDIT_TEACHER_SESSION` 可核对本人列表及详情。脚本需要环境中的 `XIAOSHU_MASTER_KEY`,不创建临时用户、不改课时。业务输出不要提交 Git。
+2. 先核对目标库已有 member-enrollment、mobile-teaching 迁移和 Parse Schema。运行 `npm run lessons:migrate -- --apply` 注册兼容字段、安装事务和回退保护;脚本无历史数据回填。
+3. 发布云函数并确认 migration_status 为 `2026-10-11-lesson-workflow-v2`,再发布管理端和师生端;执行 `npm run app:actions:audit`。
+4. 配置部署环境后运行 `npm run lessons:smoke`:创建隔离账号、排当天课程、保存真实词库学习成果、并发结课、评价、后台对账、清理测试资源。该脚本会写入隔离业务数据,不是只读探测。清理失败时保留受限 fixture 文件,禁止删除真实业务记录来补救。
+5. 用管理、老师、学生三个会话人工走同一条预约;老师课表保持打开验证下一轮 15 秒出现。测试断网恢复、双击、改期取消、会话过期,再核对一条有效课次、一份报告及一组扣费流水。
+6. 手机体验、前端实际部署版本、三端浏览器验收和账务对账分别记录。满足这些条件后才可称为全流程上线完成。
+
+复跑本地检查:`npm run test:lesson-workflow`、`npm run test:member-enrollment`、`npm run test:payroll-integrity`、`npm run test:mobile:ci`、`npm run cloud:validate`、`npm run build`。

+ 4 - 1
package.json

@@ -54,7 +54,10 @@
     "payroll:integrity:migrate": "node scripts/migrate-payroll-integrity.mjs",
     "release:admin:prepare": "node scripts/release-admin-web.mjs --prepare",
     "release:admin:verify": "node scripts/release-admin-web.mjs --verify",
-    "test:admin-release": "node --test scripts/tests/admin-release.test.mjs"
+    "test:admin-release": "node --test scripts/tests/admin-release.test.mjs",
+    "test:lesson-workflow": "node --test scripts/tests/lesson-workflow.test.mjs",
+    "lessons:migrate": "node scripts/migrate-lesson-workflow.mjs",
+    "lessons:smoke": "node scripts/smoke-mobile-teaching-flow.mjs"
   },
   "private": true,
   "dependencies": {

+ 2 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -602,6 +602,8 @@ export interface ScheduleAppointment extends Record<string, unknown> {
 }
 
 export interface ScheduleCalendarData {
+  source?: string;
+  workflowIssues?: Array<{appointmentGeneralId:string;message:string;targetId:string;updatedAt:string}>;
   dateFrom: string;
   dateTo: string;
   refreshedAt: string;

+ 1 - 1
projects/xiaoshu-admin/src/app/operations.service.spec.ts

@@ -65,7 +65,7 @@ describe('OperationsService', () => {
   it('loads the professional schedule projection with a coach filter', async () => {
     functions.operations.and.resolveTo({ dateFrom: '2026-08-20', dateTo: '2026-08-26', refreshedAt: '', coaches: [], appointments: [], unassigned: [], members: [], courses: [] });
     await service.schedule('2026-08-20', '2026-08-26', 88);
-    expect(functions.operations).toHaveBeenCalledWith('ops/schedule/calendar', { dateFrom: '2026-08-20', dateTo: '2026-08-26', filters: { coachId: 88 } });
+    expect(functions.operations).toHaveBeenCalledWith('ops/schedule/calendar', { dateFrom: '2026-08-20', dateTo: '2026-08-26', source: 'database', filters: { coachId: 88 } });
   });
 
   it('loads stores through the dedicated operations projection', async () => {

+ 1 - 1
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -129,7 +129,7 @@ export class OperationsService {
   }
 
   schedule(dateFrom: string, dateTo: string, coachId = 0, refresh = false): Promise<ScheduleCalendarData> {
-    return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId }, ...(refresh ? { refresh: true } : {}) });
+    return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, source: 'database', filters: { coachId }, ...(refresh ? { refresh: true } : {}) });
   }
 
   availability(coachId: number): Promise<OperationsPage<CoachAvailability>> {

+ 16 - 11
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -1,3 +1,8 @@
+@if(data()?.workflowIssues?.length){
+  <section class="notice error" aria-label="结课异常清单"><strong>结课凭据待核对(最近 200 条)</strong>
+    @for(issue of data()?.workflowIssues; track issue.appointmentGeneralId){<p>预约 {{issue.appointmentGeneralId}}:{{issue.message}} <button type="button" (click)="viewWorkflowIssue(issue.targetId||issue.appointmentGeneralId)">查看凭据</button></p>}
+  </section>
+}
 <header class="page-header">
   <div><p>SCHEDULING CENTER</p><h1>排课中心</h1><span>按陪练或时间快速排课,并在提交前发现冲突</span></div>
   <div class="header-actions">
@@ -43,18 +48,18 @@
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
-@if(data()?.sync?.state!=='synced'&&data()){<p class="notice error" role="status">旧系统持久化同步尚未追平。{{data()?.liveSourceAvailable?'本页已读取旧系统当前排课':'本页暂时无法核对旧系统当前排课'}};刷新页面不等于新数据库已同步,未核实记录不能据此认定缺课或结算工资。</p>}
+@if(data()?.source!=='database'&&data()?.sync?.state!=='synced'&&data()){<p class="notice error" role="status">旧系统持久化同步尚未追平。{{data()?.liveSourceAvailable?'本页已读取旧系统当前排课':'本页暂时无法核对旧系统当前排课'}};刷新页面不等于新数据库已同步,未核实记录不能据此认定缺课或结算工资。</p>}
 
 <section class="schedule-workflow" aria-label="预约到完课业务流程">
   <header>
-    <div><strong>预约到完课流程</strong><span>按原系统真实操作职责梳理</span></div>
-    <small>学员端负责查看、确认和评价,不直接创建预约</small>
+    <div><strong>预约到完课流程</strong><span>排课、上课、结课与评价</span></div>
+    <small>老师一次结课,学生随后评价</small>
   </header>
   <ol>
     <li><i>1</i><div><strong>运营创建预约</strong><span>选择学员已绑定课程、时间和陪练</span></div><em>状态 0</em></li>
-    <li><i>2</i><div><strong>陪练开始并结束</strong><span>教师端开始上课,结束后提交课堂结果</span></div><em>10 → 11</em></li>
-    <li><i>3</i><div><strong>学员确认评价</strong><span>学员端确认完成并提交评分与评价</span></div><em>状态 20</em></li>
-    <li><i>4</i><div><strong>陪练生成学习报告</strong><span>学员确认后由陪练提交点评;工资仍按上课记录而非预约计算</span></div><em>状态 30</em></li>
+    <li><i>2</i><div><strong>陪练开始上课</strong><span>预约当天均可开始,记录实际上课时间</span></div><em>上课中</em></li>
+    <li><i>3</i><div><strong>老师结课并生成报告</strong><span>课堂结果、反馈与课时扣减一次完成</span></div><em>已结课</em></li>
+    <li><i>4</i><div><strong>学生评价</strong><span>学生查看报告后评分;未评价不阻塞结课</span></div><em>评价独立</em></li>
   </ol>
 </section>
 
@@ -63,7 +68,7 @@
   <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
   <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
   <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
-  <article class="summary-card"><span>旧系统读取</span><strong>{{data()?.sourceRefreshedAt?(data()?.sourceRefreshedAt|date:'MM-dd HH:mm:ss'):'—'}}</strong><small>{{data()?.liveSourceAvailable?'当前排课已核对':'当前状态未核实'}} · {{data()?.sync?.label||'同步状态未知'}}</small></article>
+  <article class="summary-card"><span>数据库更新</span><strong>{{data()?.refreshedAt|date:'MM-dd HH:mm:ss'}}</strong><small>新排课程、教学状态以新库为准</small></article>
 </section>
 
 <section class="content-card" [class.is-loading]="loading()" [attr.aria-busy]="loading()">
@@ -83,7 +88,7 @@
   </header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openDetails(item)">查看明细</button>@if(item.statusCode>=11&&!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看完课反馈</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="openDetails(item)">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
+      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openDetails(item)">查看明细</button>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="openDetails(item)">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
       @empty{<tr><td colspan="7"><div class="empty">{{hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
   } @else {
@@ -113,7 +118,7 @@
       <div class="schedule-detail-cancel"><label for="schedule-cancel-reason">取消原因</label><textarea id="schedule-cancel-reason" rows="2" [ngModel]="cancelReason()" (ngModelChange)="cancelReason.set($event)" placeholder="填写取消原因后确认,操作将记录在业务日志中"></textarea></div>
     } @else if(item.readOnly){<p class="notice">这条排课来自旧系统实时数据。当前没有安全的旧系统写回通道,新后台暂不能取消;请在原业务端取消后重新同步。</p>}
     @if(detailError()){<p class="notice error" role="alert">{{detailError()}}</p>}
-    <footer><button type="button" (click)="closeDetails()" [disabled]="saving()">关闭</button>@if(rowActions(item).canEdit){<button type="button" (click)="editFromDetails(item)">修改排课</button>}@if(rowActions(item).canCancel){<button class="danger-action" type="button" (click)="cancelFromDetails()" [disabled]="saving()">{{saving()?'正在取消…':'确认取消排课'}}</button>}</footer>
+    <footer>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}<button type="button" (click)="closeDetails()" [disabled]="saving()">关闭</button>@if(rowActions(item).canEdit){<button type="button" (click)="editFromDetails(item)">修改排课</button>}@if(rowActions(item).canCancel){<button class="danger-action" type="button" (click)="cancelFromDetails()" [disabled]="saving()">{{saving()?'正在取消…':'确认取消排课'}}</button>}</footer>
   </aside>
 }
 
@@ -195,7 +200,7 @@
   <div class="backdrop" (click)="closeFeedback()"></div>
   <aside class="drawer feedback-drawer" role="dialog" aria-modal="true" aria-label="完课反馈详情">
     <header><div><p>LESSON FEEDBACK</p><h2>完课反馈</h2><span>预约 {{report['appointmentGeneralId']}} · {{report['courseCategoryName']||'历史课程'}}</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
-    <section class="feedback-facts"><article><span>实际上课</span><strong>{{$any(report['actualStartAt'])|date:'yyyy-MM-dd HH:mm'}} – {{$any(report['actualEndAt'])|date:'HH:mm'}}</strong></article><article><span>课程标准</span><strong>{{report['courseCategoryName']||'未映射'}} · {{report['durationMinutes']||'—'}}分钟 · {{report['deliveryLabel']}}</strong></article><article><span>生词总量</span><strong>{{report['vocabularyCount']||0}}</strong></article></section>
-    <section class="feedback-sections"><article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
+    <section class="feedback-facts"><article><span>计划上课</span><strong>{{report['plannedAt']}}</strong></article><article><span>实际上课</span><strong>{{$any(report['actualStartAt'])|date:'yyyy-MM-dd HH:mm'}} – {{$any(report['actualEndAt'])|date:'HH:mm'}}</strong></article><article><span>课程标准</span><strong>{{report['courseCategoryName']||'未映射'}} · {{report['durationMinutes']||'—'}}分钟 · {{report['deliveryLabel']}}</strong></article><article><span>生词总量</span><strong>{{report['vocabularyCount']||0}}</strong></article></section>
+    <section class="feedback-sections"><article><span>预约编号</span><p>{{report['appointmentGeneralId']}}</p></article><article><span>课时处理</span><p>{{report['creditState']}} · 有效记录 {{report['lessonCount']}} 条</p></article><article><span>学生评价</span><p>{{$any(report['studentReview'])?.score ? $any(report['studentReview']).score+' 分' : '尚未评价'}} {{$any(report['studentReview'])?.comment}}</p></article>@if(report['issue']){<article role="alert"><span>待核对异常</span><p>{{report['issue']}}</p></article>}@if(!report['hasReport']){<article><p>尚未生成完课报告</p></article>}<article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>老师点评</span><p>{{report['coachComment']||'无'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
   </aside>
 }

+ 1 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -178,6 +178,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   openEdit(item:ScheduleAppointment):void{++this.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   closeDrawer():void{++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
   async viewFeedback(item:ScheduleAppointment):Promise<void>{if(item.readOnly){this.message.set('这条排课来自旧系统实时接口;完课反馈尚未同步到新库,请在原业务端查看。');return;}this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
+  async viewWorkflowIssue(targetId:string):Promise<void>{this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'异常详情加载失败');}}
   closeFeedback():void{this.feedback.set(null);}
   setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
   openSearch(field:SearchField):void{this.activeSearch.set(field);}

+ 9 - 0
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -349,4 +349,13 @@ describe('teaching request reliability',()=>{
  for(const action of ['e_order_complete_feedback','vote_add','unit_record_update','e_get_21list_tj'])it(action+' carries the current session',()=>{api.post(action,{uid:1761}).subscribe();const req=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(req.request.body.token).toBe('r:teacher');req.flush({retcode:0,result:{}});});
  it('does not automatically retry a write and retains its request key for manual retry',fakeAsync(()=>{const body={uid:1761,wordsId:'7',save:1};api.post('e_add_words',body).subscribe({error:()=>{}});const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;expect(key).toBeTruthy();first.flush('',{status:503,statusText:'Unavailable'});tick(2000);http.expectNone(API_CONFIG.cloudFunctionUrl);api.post('e_add_words',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{}});}));
  it('fetches a fresh learning summary after a successful teaching write',()=>{const results:Array<{records:Array<{id:number}>}>=[];api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[]}});api.post('e_order_complete_feedback',{orderId:7}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{}});api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[{id:1}]}});expect(results).toEqual([{records:[]},{records:[{id:1}]}]);});
+ it('times out completion without retrying and keeps the idempotency key for a manual retry',fakeAsync(()=>{
+  const body={orderId:901,expectedUpdatedAt:'2026-10-11T00:00:00Z',feedback:{contentSummary:'Unit 1'}};let message='';
+  api.post('app_lesson_complete',body).subscribe({error:e=>message=e.message});
+  const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;
+  tick(20_001);expect(message).toContain('请求暂未完成');expect(first.cancelled).toBeTrue();http.expectNone(API_CONFIG.cloudFunctionUrl);
+  api.post('app_lesson_complete',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);
+  expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{status:30}});
+ }));
+
 });

+ 6 - 4
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -1,7 +1,7 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
 import { Router } from '@angular/router';
-import { catchError, finalize, from, map, Observable, retry, shareReplay, Subject, switchMap, tap, throwError, timer } from 'rxjs';
+import { catchError, finalize, from, map, Observable, retry, shareReplay, Subject, switchMap, tap, throwError, timer, timeout } from 'rxjs';
 import { API_CONFIG } from './app.constants';
 import { requiresCloudSession } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
@@ -36,7 +36,7 @@ export class ApiService {
   }
 
   callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
-    const teachingWrite=['e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
+    const teachingWrite=['app_lesson_complete','app_lesson_review','e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
     const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
     if(teachingWrite && !params['requestKey']) {
       const key=this.pendingWrites.get(requestIdentity)||crypto.randomUUID();
@@ -46,12 +46,14 @@ export class ApiService {
       ...(sessionToken ? { token: sessionToken } : {}),
       params: { action, ...params },
     };
-    return this.resilient<T>(this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
+    const bounded = ['user_get','e_order_detail','e_order_update_v2','app_companion_overview','app_live_versions','app_lesson_complete','app_lesson_review'].includes(action);
+    const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
       headers: {
         'Content-Type': 'application/json',
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
-    }), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => {
+    });
+    return this.resilient<T>(bounded ? request.pipe(timeout({first:20_000,with:()=>throwError(()=>new ApiError('请求暂未完成,请刷新核对;已填写内容会保留,重试不会重复扣课时。'))})) : request, sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => {
       if (!this.isReadAction(action)) {
         this.invalidateOverviews();
         this.announceDataChange();

+ 8 - 3
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -53,6 +53,9 @@ class LearningModulesApiService {
         kcid: 801,
         yykcmc: '七年级上册',
         dszt: this.appointmentStatus,
+        updatedAt: "2026-10-11T00:00:00Z",
+        allowedActions: this.appointmentStatus===0?["start"]:this.appointmentStatus===10?["continue","complete"]:this.appointmentStatus===30?[]:["complete"],
+        reviewStatus: "pending",
         dslx: 2,
         bxrq: '2026-08-10',
         sdsd: '19:00',
@@ -139,7 +142,7 @@ class LearningModulesApiService {
       this.studyUpdates.push(params);
       return this.result<T>({ GeneralID: 9901 });
     }
-    if (action === 'e_order_complete_feedback') {
+    if (action === 'app_lesson_complete') {
       this.completionRequests.push(params);
       return this.completionResponse as Observable<ApiEnvelope<T>>;
     }
@@ -284,6 +287,8 @@ describe('home learning modules', () => {
   };
 
   beforeEach(() => {
+    // Route tests do not depend on remote audio availability or its error-toast timer.
+    spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
     localStorage.removeItem('xiaoshu.learning.300.100.901.skip_spelling_stage');
     user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
     session.groupId.set(1);
@@ -1473,13 +1478,13 @@ describe('home learning modules', () => {
     expect(submit.disabled).toBeTrue();
     dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
     expect(dialog.open).toBeTrue();
-    api.appointmentStatus = 11;
+    api.appointmentStatus = 30;
     api.completionResponse.next({ retcode: 1, result: {} });
     api.completionResponse.complete();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelector('dialog')).toBeNull();
-    expect(harness.routeNativeElement?.textContent).toContain('等待学员确认');
+    expect(harness.routeNativeElement?.textContent).toContain('已结课');
   });
 
   it('keeps feedback editable after a failed submission and allows closing with Escape', async () => {

+ 100 - 0
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -0,0 +1,100 @@
+import { Component, signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { provideRouter, Router } from '@angular/router';
+import { RouterTestingHarness } from '@angular/router/testing';
+import { By } from '@angular/platform-browser';
+import { of, throwError } from 'rxjs';
+import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
+import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
+import { ApiService, ApiError } from './api.service';
+import { SessionService } from './session.service';
+import { PlatformService } from './platform.service';
+
+@Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
+
+describe('appointment workflow screens',()=>{
+ let order:Record<string,unknown>,failDetail:boolean,calls:Array<{action:string;params:Record<string,unknown>}>;
+ const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
+ const response=(result:unknown)=>of({retcode:0,result});
+ beforeEach(()=>{
+  localStorage.clear();sessionStorage.clear();calls=[];failDetail=false;user.set({userId:30,groupId:3});groupId.set(3);
+  order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
+  const invoke=(action:string,params:Record<string,unknown>={})=>{
+   calls.push({action,params});
+   if(action==='e_order_detail')return failDetail?throwError(()=>new ApiError('详情不可用')):response([{...order}]);
+   if(action==='app_live_versions')return response({version:'appointments=1',scopeVersions:{appointments:'1'},syncStatus:{state:'current'}});
+   if(action==='app_companion_overview')return response({courses:[],schedules:[{...order}],dates:[{date:'2099-10-11',finished:0,planned:1}],studentOptions:[],page:{itemCount:1,pageCount:1},syncStatus:{state:'current'}});
+   if(action==='user_get')return response({userId:20,honeyName:'测试学员',groupId:1});
+   if(action==='node_list')return response([{NodeID:811,NodeName:'Unit 1'}]);
+   if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
+   if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:[],reviewStatus:'pending'};return response({status:30});}
+   if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
+   return response([]);
+  };
+  TestBed.configureTestingModule({providers:[
+   provideRouter([
+    {path:'pages/home/shop',component:CourseCenterPageComponent,data:{meta:{path:'pages/home/shop'}}},
+    {path:'pages/shop/get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/get_order'}}},
+    {path:'pages/shop/stu_get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/stu_get_order'}}},
+    {path:'pages/stu/stu_abc',component:ClassroomStub},
+   ]),
+   {provide:ApiService,useValue:{get:invoke,post:invoke}},
+   {provide:SessionService,useValue:{user,student,groupId,token:signal('r:test'),isLoggedIn:()=>true,setStudent:(s:unknown)=>student.set(s)}},
+   {provide:PlatformService,useValue:{loading:signal(false),toast:()=>{},confirm:async()=>true}},
+  ]});
+ });
+ const screen=async(path='/pages/shop/get_order?id=901')=>{
+  const harness=await RouterTestingHarness.create();await harness.navigateByUrl(path);await harness.fixture.whenStable();harness.fixture.detectChanges();
+  const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent))?.componentInstance as AppointmentPageComponent;
+  return{harness,component};
+ };
+ it('opens a future appointment and keeps the teacher-wide refresh scope independent of student selection',async()=>{
+  const {harness}=await screen('/pages/home/shop');
+  const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+  center.showAllSchedules();harness.fixture.detectChanges();
+  (harness.routeNativeElement?.querySelector('article[data-schedule-id]') as HTMLElement).click();
+  await harness.fixture.whenStable();expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
+  expect(calls.find(c=>c.action==='app_live_versions')?.params['studentId']).toBeUndefined();
+  expect(calls.find(c=>c.action==='app_companion_overview')?.params['source']).toBe('database');
+ });
+ it('starts a writing lesson without requiring vocabulary units',async()=>{
+  order['courseCategoryKey']='primary_writing';
+  const {harness,component}=await screen();expect(component.isWriting).toBeTrue();
+  (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
+  harness.fixture.detectChanges();expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);expect(component.status()).toBe(10);
+ });
+ it('shows a persistent reason when vocabulary has not been selected, then enters the bound classroom',async()=>{
+  const {harness,component}=await screen();component.startOrContinue();harness.fixture.detectChanges();
+  expect(harness.routeNativeElement?.querySelector('[role="alert"]')?.textContent).toContain('选择');
+  component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
+  expect(TestBed.inject(Router).url).toContain('orderId=901');expect(TestBed.inject(Router).url).toContain('bindingId=binding-1');
+ });
+ it('does not allow a cached appointment to write when the detail endpoint fails',async()=>{
+  failDetail=true;
+  const harness=await RouterTestingHarness.create();
+  await TestBed.inject(Router).navigate(['/pages/shop/get_order'],{queryParams:{id:901},state:{appointmentSnapshot:order}});
+  await harness.fixture.whenStable();harness.fixture.detectChanges();
+  const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
+  expect(component.detailVerified()).toBeFalse();component.startOrContinue();
+  expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(0);
+ });
+ it('completes with feedback and exposes the report without waiting for student confirmation',async()=>{
+  order={...order,dszt:10,allowedActions:['continue','complete']};
+  const {harness,component}=await screen();component.openCompletionFeedback();
+  component.contentSummary='Unit 1';component.reviewStatus='完成';component.trainingStatus='正常进行';component.trainingSummary='完成练习';component.noHomework=true;
+  component.finishCoachLesson();harness.fixture.detectChanges();
+  const command=calls.find(c=>c.action==='app_lesson_complete');expect(command?.params['expectedUpdatedAt']).toBe(order['updatedAt']);
+  expect(harness.routeNativeElement?.textContent).toContain('查看学习报告');expect(harness.routeNativeElement?.textContent).toContain('待学生评价');
+  expect(calls.some(c=>c.action==='content_update')).toBeFalse();
+ });
+ it('submits a student review on an already completed course without changing its status',async()=>{
+  user.set({userId:20,groupId:1});groupId.set(1);order={...order,dszt:30,allowedActions:['review'],reviewStatus:'pending'};
+  const {harness,component}=await screen('/pages/shop/stu_get_order?id=901');component.studentScore=4;component.studentComment='有帮助';component.finishStudentLesson();harness.fixture.detectChanges();
+  expect(calls.find(c=>c.action==='app_lesson_review')?.params).toEqual({orderId:'901',score:4,comment:'有帮助'});expect(component.status()).toBe(30);
+  expect(harness.routeNativeElement?.textContent).toContain('学生已评价');expect(harness.routeNativeElement?.textContent).toContain('有帮助');
+ });
+ it('keeps a non-current-day course readable and shows the server reason without a start button',async()=>{
+  order={...order,allowedActions:[],blockedReason:'课程仅在预约当天可开始'};const {harness}=await screen();
+  expect(harness.routeNativeElement?.textContent).toContain('预约当天');expect(harness.routeNativeElement?.querySelector('.appointment-actions .primary-button')).toBeNull();
+ });
+});

+ 19 - 0
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.spec.ts

@@ -67,6 +67,25 @@ describe('LiveDataRefreshService', () => {
     expect(post).toHaveBeenCalledTimes(1);
     stop();
   }));
+
+  it('polls appointments across devices, pauses when hidden, resumes online and releases listeners', fakeAsync(() => {
+    let version = 'v1';
+    const visible = spyOnProperty(document, 'visibilityState', 'get').and.returnValue('visible');
+    post.and.callFake(() => of(envelope(version)));
+    const refresh = jasmine.createSpy('refresh');
+    const stop = TestBed.inject(LiveDataRefreshService).watch({
+      scopes: ['appointments'], studentId: () => undefined, appointmentPolling: true, onRefresh: refresh,
+    });
+    expect(post.calls.mostRecent().args[1]).toEqual({studentId:undefined,scopes:['appointments'],source:'database',scope:'appointments'});
+    version = 'v2'; tick(15_000);
+    expect(refresh).toHaveBeenCalledOnceWith('version');
+    visible.and.returnValue('hidden'); tick(30_000);
+    expect(post).toHaveBeenCalledTimes(2);
+    visible.and.returnValue('visible'); window.dispatchEvent(new Event('online'));
+    expect(post).toHaveBeenCalledTimes(3);
+    stop(); tick(30_000); window.dispatchEvent(new Event('online'));
+    expect(post).toHaveBeenCalledTimes(3);
+  }));
 });
 
 function envelope(version: string): { retcode: number; result: LiveVersions } {

+ 19 - 2
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts

@@ -1,4 +1,4 @@
-import { inject, Injectable } from '@angular/core';
+import { inject, Injectable, NgZone } from '@angular/core';
 import { Subscription } from 'rxjs';
 import { ApiService } from './api.service';
 import { SessionService } from './session.service';
@@ -30,12 +30,15 @@ interface LiveRefreshOptions {
   studentId: () => number | undefined;
   onRefresh: (reason: 'version' | 'local-write' | 'broadcast') => void;
   onState?: (state: LiveRefreshState) => void;
+  /** Opt in only on appointment screens; learning screens keep their existing behaviour. */
+  appointmentPolling?: boolean;
 }
 
 @Injectable({ providedIn: 'root' })
 export class LiveDataRefreshService {
   private readonly api = inject(ApiService);
   private readonly session = inject(SessionService);
+  private readonly zone = inject(NgZone);
 
   watch(options: LiveRefreshOptions): () => void {
     let disposed = false;
@@ -52,7 +55,7 @@ export class LiveDataRefreshService {
       Object.assign(state, patch);
       options.onState?.({ ...state, syncStatus: { ...state.syncStatus } });
     };
-    const canCheck = () => this.session.isLoggedIn();
+    const canCheck = () => this.session.isLoggedIn() && (!options.appointmentPolling || document.visibilityState !== 'hidden');
     const check = () => {
       if (disposed || checking || !canCheck()) return;
       const studentId = options.studentId();
@@ -66,6 +69,7 @@ export class LiveDataRefreshService {
       const response = this.api.post<LiveVersions>('app_live_versions', {
         studentId,
         scopes: options.scopes,
+        ...(options.appointmentPolling ? { source: 'database', scope: 'appointments' } : {}),
       });
       if (!response || typeof response.subscribe !== 'function') {
         checking = false;
@@ -94,12 +98,25 @@ export class LiveDataRefreshService {
       check();
     };
     const dataChanges = this.api.dataChanges$?.subscribe((event) => refreshAfterChange(event.source === 'local' ? 'local-write' : 'broadcast')) ?? new Subscription();
+    const onResume = () => {
+      if (canCheck()) { options.onRefresh('version'); check(); }
+    };
+    const interval = options.appointmentPolling ? this.zone.runOutsideAngular(() => setInterval(() => this.zone.run(check), 15_000)) : undefined;
+    if (options.appointmentPolling) {
+      document.addEventListener('visibilitychange', onResume);
+      window.addEventListener('focus', onResume);
+      window.addEventListener('online', onResume);
+    }
     check();
 
     return () => {
       disposed = true;
       request?.unsubscribe();
       dataChanges.unsubscribe();
+      if (interval) clearInterval(interval);
+      document.removeEventListener('visibilitychange', onResume);
+      window.removeEventListener('focus', onResume);
+      window.removeEventListener('online', onResume);
     };
   }
 }

+ 18 - 19
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -8,7 +8,10 @@
       <div><span>{{ status() > 20 ? '课程陪练报告' : '预约课程' }}</span><strong>{{ statusText() }}</strong></div>
       <lucide-icon [img]="status() >= 30 ? icons.Flag : icons.Clock3" [size]="28" />
     </header>
-    @if (detailWarning()) { <p class="detail-warning">{{ detailWarning() }}</p> }
+    @if (detailWarning()) { <p class="detail-warning" role="alert">{{ detailWarning() }}</p> }
+    @if(actionError()){<p class="detail-warning" role="alert">{{actionError()}}</p>}
+    @if(order()['blockedReason']){<p class="detail-warning">{{order()['blockedReason']}}</p>}
+    <button type="button" class="secondary-button" (click)="load()" [disabled]="submitting()">刷新详情</button>
 
     <dl class="appointment-info">
       <div><dt>学生姓名</dt><dd>{{ field('student') || '-' }}</dd></div>
@@ -39,18 +42,14 @@
                   @if (completedUnits().has(unitId(unit))) { <span class="unit-completion-note">已标记学完</span> }
                 </span>
               </button>
-              <button type="button" class="mark-complete" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
+              <button type="button" class="mark-complete" [disabled]="!can('start') && !can('continue')" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
             </article>
           } @empty { <div class="empty-state"><p>请选择课程后加载单元</p></div> }
         </div>
       </section>
     }
 
-    @if (!isStudentView && (status() === 11 || status() === 20)) {
-      <section class="comment-panel"><label><span>陪练点评</span><textarea [(ngModel)]="coachComment" maxlength="200" placeholder="请输入本次陪练点评"></textarea></label></section>
-    }
-
-    @if (!isStudentView && status() === 10) {
+    @if (!isStudentView && can('complete')) {
       <dialog #completionDialog class="completion-dialog" aria-labelledby="completion-title" aria-describedby="completion-description" (cancel)="closeCompletionFeedback($event)">
         <section class="completion-feedback">
           <header>
@@ -72,6 +71,7 @@
               <label><span>作业安排 @if (!noHomework) { <b>*</b> }</span><textarea [(ngModel)]="homework" [disabled]="noHomework" [attr.aria-required]="!noHomework" maxlength="300" placeholder="填写作业内容和要求"></textarea></label>
               <label class="checkbox-row"><input type="checkbox" [(ngModel)]="noHomework" /><span>本次无作业</span></label>
             </div>
+            <label><span>陪练点评</span><textarea [(ngModel)]="coachComment" maxlength="1000" placeholder="本次学习表现与建议"></textarea></label>
             <label><span>备注 <small>选填</small></span><textarea [(ngModel)]="notes" maxlength="300" placeholder="记录需要运营或家长关注的情况"></textarea></label>
           </fieldset>
           @if (feedbackError()) { <p class="feedback-error" role="alert">{{ feedbackError() }}</p> }
@@ -83,30 +83,29 @@
       </dialog>
     }
 
-    @if (isStudentView && status() === 11) {
+    @if (isStudentView && can('review')) {
       <section class="comment-panel">
         <label><span>本次评分</span><select [(ngModel)]="studentScore">@for (score of [5,4,3,2,1]; track score) { <option [ngValue]="score">{{ score }} 分</option> }</select></label>
         <label><span>学习评价</span><textarea [(ngModel)]="studentComment" maxlength="200" placeholder="请输入评价内容"></textarea></label>
       </section>
     }
 
-    @if (status() > 20 || practiceRecord()['pldp'] || practiceRecord()['pjnr']) {
+    @if (status() >= 30 || practiceRecord()['pldp'] || practiceRecord()['pjnr']) {
       <section class="report-panel">
         <h2><lucide-icon [img]="icons.Star" [size]="20" />课程评价</h2>
-        <div><span>学员评分</span><strong>{{ practiceRecord()['pf'] || '-' }}@if (practiceRecord()['pf']) { 分 }</strong></div>
-        <p>{{ practiceRecord()['pjnr'] || '学员暂无文字评价' }}</p>
+        <div><span>学员评分</span><strong>{{ $any(order()['studentReview'])?.score || practiceRecord()['pf'] || '-' }}@if ($any(order()['studentReview'])?.score || practiceRecord()['pf']) { 分 }</strong></div>
+        <p>{{ $any(order()['studentReview'])?.comment || practiceRecord()['pjnr'] || '学员暂无文字评价' }}</p>
         <div><span>陪练点评</span><strong>{{ practiceRecord()['plpjsj'] || '' }}</strong></div>
         <p>{{ practiceRecord()['pldp'] || '陪练暂无文字点评' }}</p>
       </section>
     }
 
-    @if (!order()['cancelled']) {<div class="appointment-actions">
-      @if (!isStudentView && status() === 0) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="startOrContinue()"><lucide-icon [img]="icons.Play" [size]="18" />开始课程</button> }
-      @if (!isStudentView && status() === 10) { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="startOrContinue()">继续课程<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button><button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">结束课程</button> }
-      @if (!isStudentView && status() === 11) { <span class="workflow-hint">等待学员确认并评价</span> }
-      @if (!isStudentView && status() === 20) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="completeCoachOrder()"><lucide-icon [img]="icons.Save" [size]="18" />生成学习报告</button> }
-      @if (isStudentView && status() === 11) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()"><lucide-icon [img]="icons.GraduationCap" [size]="18" />确认学习结束</button> }
-      @if (isStudentView && status() === 20) { <button type="button" class="secondary-button" disabled>已确认结束</button> }
-    </div>}
+    <div class="appointment-actions">
+      @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
+      @if (can('continue')) { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="startOrContinue()">继续课程</button> }
+      @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">{{status()===10?'结束课程':'补齐反馈并结课'}}</button> }
+      @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
+      @if (status()>=30 && !order()['cancelled']) { <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
+    </div>
   }
 @if(feedback();as f){<section class="lesson-feedback card"><h3>完课反馈</h3><p>学习内容:{{f['contentSummary']}}</p><p>训练进度:{{f['trainingSummary']}}</p><p>作业:{{f['homework']||'无作业'}}</p></section>}</section>

+ 49 - 37
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -1,10 +1,11 @@
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnInit, OnDestroy, signal, viewChild } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, LucideAngularModule, Play, Save, Star, X } from 'lucide-angular';
-import { switchMap, finalize } from 'rxjs';
+import { finalize, Subscription } from 'rxjs';
+import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
 import { ApiError, ApiService } from '../../core/api.service';
 import { PageMeta } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
@@ -20,7 +21,7 @@ type SourceItem = Record<string, unknown>;
   styleUrl: './appointment-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AppointmentPageComponent implements OnInit {
+export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
   readonly order = signal<SourceItem>({});
   readonly practiceRecord = signal<SourceItem>({});
@@ -35,6 +36,11 @@ export class AppointmentPageComponent implements OnInit {
   readonly error = signal('');
   readonly detailWarning = signal('');
   readonly feedbackError = signal('');
+  readonly actionError = signal('');
+  readonly detailVerified = signal(false);
+  private loadRequest?: Subscription;
+  private stopLive?: () => void;
+  private readonly liveData = inject(LiveDataRefreshService);
   readonly completionDialog = viewChild<ElementRef<HTMLDialogElement>>('completionDialog');
   readonly icons = { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, Play, Save, Star, X };
   readonly status = computed(() => Number(this.order()['dszt'] ?? this.order()['status'] ?? 0));
@@ -62,34 +68,47 @@ export class AppointmentPageComponent implements OnInit {
   constructor() {
     effect(() => {
       const dialog = this.completionDialog()?.nativeElement;
-      if (dialog && !this.isStudentView && !this.order()['cancelled'] && this.status()===10 && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
+      if (dialog && !this.isStudentView && !this.order()['cancelled'] && this.can('complete') && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
     });
   }
 
   ngOnInit(): void {
     this.load();
+    this.stopLive = this.liveData.watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:()=>{if(!this.submitting())this.load(true);}});
   }
 
+  ngOnDestroy(): void { this.stopLive?.(); this.loadRequest?.unsubscribe(); }
+
+  can(action: string): boolean { return this.detailVerified() && !this.order()['cancelled'] && Array.isArray(this.order()['allowedActions']) && (this.order()['allowedActions'] as string[]).includes(action); }
+
+  openReport(): void { void this.router.navigate(['/pages/shop/baobiao'], {queryParams:{id:this.orderId}}); }
+
   get isStudentView(): boolean { return this.meta.path === 'pages/shop/stu_get_order'; }
   get orderId(): string { return this.route.snapshot.queryParamMap.get('id') ?? this.route.snapshot.queryParamMap.get('cart') ?? ''; }
-  get isWriting(): boolean { return String(this.order()['if_writing'] ?? '') === '1'; }
+  get isWriting(): boolean { return ['primary_writing','middle_writing','high_writing'].includes(String(this.order()['courseCategoryKey'] || '')) || String(this.order()['if_writing'] ?? '') === '1'; }
 
-  load(): void {
+  load(background = false): void {
+    this.loadRequest?.unsubscribe();
     if (!this.orderId) {
       this.loading.set(false);
       this.error.set('缺少预约编号');
       return;
     }
-    this.loading.set(true);
+    if (!background) { this.loading.set(true); this.detailVerified.set(false); }
     this.error.set('');
     this.detailWarning.set('');
     const fallback = this.appointmentSnapshot();
-    this.api.get<unknown>('e_order_detail', { id: this.orderId }).pipe(finalize(() => this.loading.set(false))).subscribe({
+    this.loadRequest = this.api.get<unknown>('e_order_detail', { id: this.orderId }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
         const detail = this.findList(result)[0] ?? this.asRecord(result);
+        this.detailVerified.set(Array.isArray(detail['allowedActions']));
+        if (!this.detailVerified()) this.detailWarning.set('课程服务尚未更新,请联系管理员完成发布后刷新。当前课程仅供查看。');
+        if (background) { this.order.set({...this.order(),...detail}); return; }
         this.applyOrder({ ...fallback, ...detail });
       },
       error: (error: ApiError) => {
+        this.detailVerified.set(false);
+        if (background) { this.detailWarning.set('详情更新失败,已暂停操作,请刷新后重试:'+error.message); return; }
         if (Object.keys(fallback).length) {
           this.applyOrder(fallback);
           this.detailWarning.set('详情接口暂时返回异常,当前显示排课列表里的课程信息。你可以返回列表后重试。');
@@ -125,7 +144,7 @@ export class AppointmentPageComponent implements OnInit {
     this.order.set(order);
     this.syncAppointmentStudent(order);
     this.selectedCourse = String(order['kcid'] ?? '');
-    this.coachComment = String(order['pldp'] ?? '');
+    if (!this.coachComment) this.coachComment = String(order['pldp'] ?? '');
     this.loadPracticeRecord();
     if (!this.isStudentView) {
       this.loadCourses();
@@ -146,6 +165,7 @@ export class AppointmentPageComponent implements OnInit {
   }
 
   async markUnitComplete(unit: SourceItem): Promise<void> {
+    if (!this.detailVerified() || (!this.can('start') && !this.can('continue'))) return;
     if (!(await this.platform.confirm(`确认将“${this.unitTitle(unit)}”标记为已学完?`))) return;
     const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
     const binding = this.bindingRecord();
@@ -187,9 +207,11 @@ export class AppointmentPageComponent implements OnInit {
   }
 
   startOrContinue(): void {
+    this.actionError.set('');
+    if (!this.can('start') && !this.can('continue')) { this.actionError.set(String(this.order()['blockedReason'] || '详情未核实,请刷新后重试')); return; }
     if(this.order()['cancelled']===true){this.platform.toast('此预约已取消,不能开始课程','error');return;}
     if (!this.isWriting && !this.selectedUnits().size) {
-      this.platform.toast('请选择单元', 'error');
+      this.actionError.set(this.units().length ? '请选择本次学习单元' : '暂无可用学习单元,请刷新重试或联系管理员核对课程内容');
       return;
     }
     this.updateOrderStatus(10, () => {
@@ -206,7 +228,7 @@ export class AppointmentPageComponent implements OnInit {
 
   openCompletionFeedback(): void {
     if(this.order()['cancelled']===true)return;
-    if (this.isStudentView || this.status() !== 10 || this.submitting()) return;
+    if (this.isStudentView || !this.can('complete') || this.submitting()) return;
     this.feedbackError.set('');
     this.completionDialog()?.nativeElement.showModal();
   }
@@ -217,16 +239,16 @@ export class AppointmentPageComponent implements OnInit {
   }
 
   finishCoachLesson(): void {
-    if (this.submitting() || !this.completionDialog()?.nativeElement.open) return;
+    if (this.submitting() || !this.can('complete') || !this.completionDialog()?.nativeElement.open) return;
     this.feedbackError.set('');
     if (!this.contentSummary.trim()) { this.feedbackError.set('请填写本次学习内容'); return; }
     if (!this.reviewStatus) { this.feedbackError.set('请选择复习或生词本情况'); return; }
     if (!this.trainingStatus || !this.trainingSummary.trim()) { this.feedbackError.set('请选择训练状态并填写进度说明'); return; }
     if (!this.noHomework && !this.homework.trim()) { this.feedbackError.set('请填写作业安排,或选择“无作业”'); return; }
     this.submitting.set(true);
-    this.api.post('e_order_complete_feedback', {
+    this.api.post('app_lesson_complete', {
       orderId: this.orderId,
-      content: JSON.stringify({ GeneralID: this.orderId, UpDateTime: this.dateTime() }),
+      expectedUpdatedAt: this.order()['updatedAt'],
       feedback: JSON.stringify({
         contentSummary: this.contentSummary.trim(),
         reviewStatus: this.reviewStatus,
@@ -236,35 +258,26 @@ export class AppointmentPageComponent implements OnInit {
         homework: this.noHomework ? '无作业' : this.homework.trim(),
         noHomework: this.noHomework,
         notes: this.notes.trim(),
+        coachComment: this.coachComment.trim(),
       }),
     }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => { this.completionDialog()?.nativeElement.close(); this.platform.toast('完课反馈已提交'); this.load(); },
+      next: () => { this.completionDialog()?.nativeElement.close(); this.platform.toast('课程已结课,学习报告已生成'); this.load(); },
       error: (error: ApiError) => this.feedbackError.set(error.message),
     });
   }
 
-  completeCoachOrder(): void {
-    if(!this.coachComment.trim()){this.platform.toast('请填写陪练点评','error');return;}
-    this.saveLessonComment({pldp:this.coachComment,plpjsj:this.dateTime()},30);
-  }
-  async finishStudentLesson():Promise<void>{
-    if(this.submitting())return;
-    if(!(await this.platform.confirm('即将确认本次学习结束,确认继续?')))return;
-    if(this.studentScore<1||this.studentScore>5){this.platform.toast('评分应为 1 到 5 分','error');return;}
-    this.saveLessonComment({pf:this.studentScore,pjnr:this.studentComment},20);
-  }
-  private saveLessonComment(patch:SourceItem,status:number):void{
-    if(this.submitting())return;const id=this.practiceRecord()['GeneralID'];
-    if(!id){this.platform.toast('未找到本次完课记录,请刷新后重试','error');return;}
+  finishStudentLesson(): void {
+    if(this.submitting() || !this.can('review')) return;
+    this.actionError.set('');
+    if (!Number.isInteger(this.studentScore) || this.studentScore < 1 || this.studentScore > 5) { this.actionError.set('评分应为1至5分'); return; }
     this.submitting.set(true);
-    this.api.post('content_update',{content:JSON.stringify({GeneralID:id}),addon:JSON.stringify(patch)}).pipe(
-      switchMap(()=>this.api.post('e_order_update_v2',{status,content:JSON.stringify({GeneralID:this.orderId})})),
-      finalize(()=>this.submitting.set(false)),
-    ).subscribe({next:()=>this.load(),error:(error:ApiError)=>this.platform.toast(error.message,'error')});
+    this.api.post('app_lesson_review',{orderId:this.orderId,score:this.studentScore,comment:this.studentComment.trim()}).pipe(finalize(()=>this.submitting.set(false))).subscribe({
+      next:()=>{this.platform.toast('评价已提交');this.load();}, error:(error:ApiError)=>this.actionError.set(error.message),
+    });
   }
   private updateOrderStatus(status:number,next:()=>void):void{
     if(this.submitting())return;this.submitting.set(true);
-    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.platform.toast(error.message,'error')});
+    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.actionError.set(error.message)});
   }
 
   private loadPracticeRecord(): void {
@@ -275,9 +288,8 @@ export class AppointmentPageComponent implements OnInit {
         const records=this.findList(result),bindingId=String(this.order()['bindingId']||'');
         const record = records.find(row=>String(row['yyds']||'')===this.orderId)|| (records.length===1?records[0]:undefined) || {};
         this.practiceRecord.set(record);
-        this.coachComment = String(record['pldp'] ?? this.coachComment);
-        this.studentScore = Number(record['pf'] ?? this.studentScore);
-        this.studentComment = String(record['pjnr'] ?? '');
+        if (!this.coachComment) this.coachComment = String(record['pldp'] ?? '');
+        if (Number(record['pf']) > 0) { this.studentScore = Number(record['pf']); this.studentComment = String(record['pjnr'] ?? ''); }
       },
       error: () => undefined,
     });
@@ -317,7 +329,7 @@ export class AppointmentPageComponent implements OnInit {
         this.units.set(this.findList(result));
         this.loadUnitProgress();
       },
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+      error: (error: ApiError) => this.actionError.set('学习单元加载失败:'+error.message),
     });
   }
 

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -4,7 +4,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
 import { finalize } from 'rxjs';
 import { ApiService } from '../../core/api.service';
 interface Report {order:Record<string,unknown>;feedback:Record<string,unknown>|null;records:Array<Record<string,unknown>>;antiForget:{total:number;completed:number};refreshedAt:string}
-@Component({standalone:true,imports:[CommonModule,RouterLink],template:`<section class="lesson-report"><header><h2>学习报表</h2><p>课程学习、完课反馈与抗遗忘进度</p></header>@if(error()){<p class="error" role="alert">{{error()}}</p><button (click)="load()">重新加载</button>}@if(report();as r){<article><h3>{{r.order['kcmc']}}</h3><p>{{r.order['HoneyName']}} · {{r.order['yysj']}}</p>@if(r.order['cancelled']){<p>此预约已取消</p>}</article><article><h3>完课反馈</h3>@if(r.feedback;as f){<p><b>学习内容:</b>{{f['contentSummary']}}</p><p><b>复习情况:</b>{{f['reviewSummary']||'未补充'}}</p><p><b>训练进度:</b>{{f['trainingSummary']}}</p><p><b>作业:</b>{{f['homework']||'无作业'}}</p>}@else{<p>尚未提交完课反馈</p>}</article><article><h3>学习记录</h3>@for(item of r.records;track item['generalId']){<p>{{item['dqrq']}} · 已学 {{item['learned']}} 个单词 · 已掌握 {{item['ygg']}}</p>}@empty{<p>暂无本次课程的单词学习记录</p>}</article><article><h3>抗遗忘计划</h3><p>已完成 {{r.antiForget.completed}} / {{r.antiForget.total}} 项</p></article><small>更新时间 {{r.refreshedAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</small>}@else if(loading()){<p>正在加载…</p>}<footer><a routerLink="/pages/his/his">查看学习记录</a></footer></section>`,styles:[`.lesson-report{max-width:1000px;margin:24px auto;padding:0 20px;line-height:1.8}.lesson-report article{padding:24px;margin:16px 0;border:1px solid var(--line);border-radius:18px;background:white}.error{color:#af3328}`]})
+@Component({standalone:true,imports:[CommonModule,RouterLink],template:`<section class="lesson-report"><header><h2>学习报表</h2><p>课程学习、完课反馈与抗遗忘进度</p></header>@if(error()){<p class="error" role="alert">{{error()}}</p><button (click)="load()">重新加载</button>}@if(report();as r){<article><h3>{{r.order['kcmc']}}</h3><p>{{r.order['HoneyName']}} · {{r.order['yysj']}}</p>@if(r.order['cancelled']){<p>此预约已取消</p>}</article><article><h3>完课反馈</h3>@if(r.feedback;as f){<p><b>学习内容:</b>{{f['contentSummary']}}</p><p><b>复习情况:</b>{{f['reviewSummary']||'未补充'}}</p><p><b>训练进度:</b>{{f['trainingSummary']}}</p><p><b>老师点评:</b>{{f['coachComment']||'未补充'}}</p><p><b>作业:</b>{{f['homework']||'无作业'}}</p>}@else{<p>尚未提交完课反馈</p>}</article><article><h3>学习记录</h3>@for(item of r.records;track item['generalId']){<p>{{item['dqrq']}} · 已学 {{item['learned']}} 个单词 · 已掌握 {{item['ygg']}}</p>}@empty{<p>暂无本次课程的单词学习记录</p>}</article><article><h3>学生评价</h3>@if(r.order['studentReview'];as review){<p>{{$any(review).score}} 分 · {{$any(review).comment||'无文字评价'}}</p>}@else{<p>尚未评价</p>}</article><article><h3>抗遗忘计划</h3><p>已完成 {{r.antiForget.completed}} / {{r.antiForget.total}} 项</p></article><small>更新时间 {{r.refreshedAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</small>}@else if(loading()){<p>正在加载…</p>}<footer><a routerLink="/pages/his/his">查看学习记录</a></footer></section>`,styles:[`.lesson-report{max-width:1000px;margin:24px auto;padding:0 20px;line-height:1.8}.lesson-report article{padding:24px;margin:16px 0;border:1px solid var(--line);border-radius:18px;background:white}.error{color:#af3328}`]})
 export class LessonReportComponent implements OnInit{
  readonly report=signal<Report|null>(null);readonly loading=signal(false);readonly error=signal('');private readonly api=inject(ApiService);private readonly route=inject(ActivatedRoute);
  ngOnInit():void{this.load();}load():void{const id=this.route.snapshot.queryParamMap.get('sid')||this.route.snapshot.queryParamMap.get('id');if(!id){this.error.set('缺少预约编号,请从课程详情进入');return;}this.loading.set(true);this.error.set('');this.api.get<Report>('app_lesson_report',{id}).pipe(finalize(()=>this.loading.set(false))).subscribe({next:r=>this.report.set(r.result),error:e=>this.error.set(e.message)});}

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -13,6 +13,7 @@
 
     <section class="schedule-band">
       <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
+      <nav class="range-tabs" aria-label="课表操作"><button type="button" (click)="load(true)">刷新课表</button><button type="button" (click)="clearScheduleFilters()">清除筛选</button>@if (nextScheduleDate() && !isHistory) { <button type="button" (click)="showNextSchedule()">最近一节 · {{nextScheduleDate()}}</button> }</nav>
       @if (isHistory) {
         <section class="history-filter-panel" aria-label="销课记录筛选">
           <div class="history-filter-grid">

+ 34 - 24
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -1,6 +1,6 @@
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, NgZone, OnDestroy, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
@@ -101,11 +101,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, Search, X };
 
   private readonly api = inject(ApiService);
+  private readonly zone = inject(NgZone);
   private readonly liveData = inject(LiveDataRefreshService);
   private readonly router = inject(Router);
   private readonly session = inject(SessionService);
   private readonly platform = inject(PlatformService);
   private nowTimer?: ReturnType<typeof setInterval>;
+  private readonly scrollTimers = new Set<ReturnType<typeof setTimeout>>();
+  private scrollFrame?: number;
   private restoredScheduleState = false;
   private syncingQuery = false;
   private loadedRange: { start: string; end: string } | null = null;
@@ -121,15 +124,18 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     this.load();
     this.stopLiveRefresh = this.liveData.watch({
       scopes: ['courses', 'appointments'],
-      studentId: () => Number(this.selectedStudent() === 'all' ? this.session.user()?.userId : this.selectedStudent()) || undefined,
+      studentId: () => this.isCoach && this.selectedStudent() === 'all' ? undefined : Number(this.selectedStudent() === 'all' ? this.session.user()?.userId : this.selectedStudent()) || undefined,
+      appointmentPolling: true,
       onRefresh: () => this.load(true, true, true),
       onState: (state) => { this.liveChecking.set(state.checking); this.updateSyncStatus(state.syncStatus); },
     });
-    if (this.hasScheduleCalendar) this.nowTimer = setInterval(() => this.now.set(Date.now()), 60_000);
+    if (this.hasScheduleCalendar) this.nowTimer = this.zone.runOutsideAngular(() => setInterval(() => this.zone.run(() => this.now.set(Date.now())), 60_000));
   }
 
   ngOnDestroy(): void {
     this.stopLiveRefresh?.();
+    for (const timer of this.scrollTimers) clearTimeout(timer);
+    if (this.scrollFrame !== undefined) cancelAnimationFrame(this.scrollFrame);
     if (this.syncNoticeTimer) clearTimeout(this.syncNoticeTimer);
     this.loadId++;this.loadRequest?.unsubscribe();
     if (this.nowTimer) clearInterval(this.nowTimer);
@@ -157,6 +163,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       rangeStart: range?.start,
       rangeEnd: range?.end,
       refresh,
+      source: 'database',
     }).pipe(finalize(() => {
       if (!background && requestId === this.loadId) this.loading.set(false);
     })).subscribe({
@@ -173,7 +180,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
         this.loadedAll = !range;
         if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectedDate.set(today);
         this.initialScheduleLoaded = true;
-        if (this.restoredScheduleState) this.restoreScheduleScroll();
+        if (this.restoredScheduleState) { this.restoreScheduleScroll(); this.restoredScheduleState = false; }
       },
       error: (error: ApiError) => {
         if (requestId !== this.loadId) return;
@@ -341,27 +348,35 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   scheduleEmptyText(): string {
     const target = this.isCoach ? '排课' : '伴学记录';
-    if (!this.schedules().length) return this.isCoach ? '暂无排课' : '暂无伴学记录';
     if (this.scheduleRange() === 'all') return `当前筛选下暂无${target}`;
     if (this.scheduleRange() === 'week') return `本周暂无${target}`;
     return `${this.selectedDate()} 暂无${target}`;
   }
 
   open(item: Item, event?: Event): void {
-    if(item['cancelled']===true){this.platform.toast('该预约已取消','info');return;}
     this.saveScheduleScroll(item, event);
     if (!this.isCoach) {
       this.storeAppointmentSnapshot(item);
       void this.router.navigate(['/pages/shop/stu_get_order'], { queryParams: { id: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
       return;
     }
-    if (this.isFutureUnfinished(item)) {
-      this.platform.toast('课程未开始', 'error');
-      return;
-    }
     this.loadStudentAndNavigate(item, '/pages/shop/get_order', 'id');
   }
 
+  clearScheduleFilters(): void {
+    this.selectedStudent.set('all'); this.scheduleFilter.set('all');
+    this.showAllSchedules();
+  }
+
+  nextScheduleDate(): string {
+    return this.serverDates().filter(day => day.date >= this.today() && day.planned > 0).map(day => day.date).sort()[0] || '';
+  }
+
+  showNextSchedule(): void {
+    const date = this.nextScheduleDate();
+    if (date) this.selectDate(date);
+  }
+
   report(item: Item, event: Event): void {
     event.stopPropagation();
     this.saveScheduleScroll(item, event);
@@ -516,12 +531,6 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   private isPendingRaw(item: Item): boolean { return this.rawStatus(item) <= 10; }
 
-  private isFutureUnfinished(item: Item): boolean {
-    if (!this.isPendingRaw(item)) return false;
-    const start = this.lessonStart(item);
-    return Boolean(start && start.getTime() > this.now());
-  }
-
   private isOverdueUnfinished(item: Item): boolean {
     if (!this.isPendingRaw(item)) return false;
     const end = this.lessonEnd(item);
@@ -551,13 +560,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   private lessonMinutes(item: Item): number {
+    if (Number(item['durationMinutes'])>0) return Number(item['durationMinutes']);
     const text = `${item['Title'] ?? ''} ${item['Title2'] ?? ''} ${item['Subtitle'] ?? ''}`;
     const matched = text.match(/(\d{1,3})\s*分钟/);
     if (matched) return Number(matched[1]);
     const type = Number(item['dslx'] ?? 0);
     if (type === 1) return 30;
     if (type === 2) return 60;
-    if (type === 3) return 30;
+    if (type === 3) return 60;
     return 0;
   }
 
@@ -744,14 +754,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       window.scrollTo({ top: Math.min(state.top, max), behavior: 'auto' });
       this.restoreScheduleAnchor(state);
     };
-    setTimeout(() => {
-      requestAnimationFrame(() => {
+    // Scroll recovery has no Angular state and must not delay navigation stability.
+    this.zone.runOutsideAngular(() => {
+      this.scrollFrame = requestAnimationFrame(() => {
         apply();
-        setTimeout(apply, 80);
-        setTimeout(apply, 220);
-        setTimeout(apply, 600);
-        setTimeout(apply, 1200);
-        setTimeout(apply, 1800);
+        for (const delay of [80, 220, 600, 1200, 1800]) {
+          const timer = setTimeout(() => { this.scrollTimers.delete(timer); apply(); }, delay);
+          this.scrollTimers.add(timer);
+        }
       });
     });
   }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -105,7 +105,7 @@ export class AccountPageComponent implements OnInit, OnDestroy {
       this.antiForgetError.set('');
       this.overviewError.set('');
     }
-    this.api.post<AccountOverview>('app_account_overview', { uid, refresh }).pipe(
+    this.api.post<AccountOverview>('app_account_overview', { uid, refresh, source:'database' }).pipe(
       finalize(() => {
         if (background || requestId !== this.overviewRequestId) return;
         this.coachPanelLoading.set(false);

+ 39 - 0
scripts/audit-lesson-chain.mjs

@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+// Read-only: never creates a user/session/helper and never changes a lesson.
+const base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),companyId=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
+const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',master=process.env.XIAOSHU_MASTER_KEY;
+const date=process.env.XIAOSHU_AUDIT_DATE||'2026-10-11',uid=Number(process.env.XIAOSHU_AUDIT_TEACHER_ID||0);
+const company={__type:'Pointer',className:'Company',objectId:companyId};
+if(!master)throw Error('请在运行环境配置 XIAOSHU_MASTER_KEY;本脚本只读');
+if(!/^\d{4}-\d{2}-\d{2}$/.test(date))throw Error('日期格式应为 YYYY-MM-DD');
+async function get(path){
+ const response=await fetch(base+'/parse'+path,{headers:{'X-Parse-Application-Id':appId,'X-Parse-Master-Key':master},signal:AbortSignal.timeout(20000)});
+ const result=await response.json();if(!response.ok||result.error)throw Error(result.error||'HTTP '+response.status);return result;
+}
+async function rows(table,where,keys){
+ const result=[];let skip=0;
+ while(true){const query=new URLSearchParams({where:JSON.stringify(where),keys,order:'objectId',limit:'500',skip:String(skip)});const page=(await get('/classes/'+table+'?'+query)).results||[];result.push(...page);if(page.length<500)break;skip+=500;}
+ return result;
+}
+const teachers=await rows('_User',{company,legacyGroupId:3,...(uid?{legacyUserId:uid}:{$or:[{realName:{$regex:'钱'}},{nickname:{$regex:'钱'}}]})},'objectId,legacyUserId,legacyGroupId,realName,nickname,isDisabled');
+if(!uid){console.log(JSON.stringify({requiresTeacherId:true,message:'候选仅用于人工识别,请指定 XIAOSHU_AUDIT_TEACHER_ID 后重跑;归属使用编号校验',teachers},null,2));process.exit(0);}
+if(teachers.length!==1)throw Error('教师编号在目标帐套未唯一匹配');
+const all=await rows('CourseAppointment',{company,pl:String(uid)},'id,pl,szyh,kcid,bindingId,bxrq,yysj,sdsd,dszt,sourceKey,teachingManaged,createdAt,updatedAt');
+const day=value=>String(value?.iso||value||'').replace(/-/g,'').slice(0,8);
+const selected=all.filter(a=>day(a.bxrq||a.yysj)===date.replace(/-/g,'')||new Date(a.createdAt).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'})===date);
+const findings=[];
+for(const appointment of selected){
+ const common=await rows('CommonModel',{company,modelId:54,itemId:appointment.id},'objectId,generalId,itemId,status,updatedAt');
+ const binding=appointment.bindingId?await rows('CourseBinding',{company,objectId:appointment.bindingId},'objectId,yhid,kcid,categoryKey,enrollmentStatus'):[];
+ findings.push({appointment,common,binding,identityMatches:binding.length===1&&String(binding[0].yhid)===String(appointment.szyh)&&String(binding[0].kcid)===String(appointment.kcid)});
+}
+let gateway=null;
+// An explicitly supplied teacher session permits exact list/detail verification; no impersonation.
+if(process.env.XIAOSHU_AUDIT_TEACHER_SESSION){
+ async function call(action,params={}){const response=await fetch(base+'/api/functions/xiaoshu/app/gateway',{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token:process.env.XIAOSHU_AUDIT_TEACHER_SESSION,params:{action,...params}}),signal:AbortSignal.timeout(20000)});const data=await response.json();return{httpStatus:response.status,retcode:data.retcode,retmsg:data.retmsg,result:data.result};}
+ const pages=[];let index=1;
+ do{const page=await call('app_companion_overview',{uid,source:'database',mode:'coach',dateStart:date,dateEnd:date,page:index,pageSize:100});pages.push(page);if(page.httpStatus!==200||index>=Number(page.result?.page?.pageCount||1))break;index++;}while(true);
+ const details=[];for(const item of findings)for(const common of item.common){const r=await call('e_order_detail',{id:common.generalId});details.push({generalId:common.generalId,httpStatus:r.httpStatus,retcode:r.retcode,retmsg:r.retmsg,rows:Array.isArray(r.result)?r.result.map(a=>({GeneralID:a.GeneralID,pl:a.pl,szyh:a.szyh,bindingId:a.bindingId,dszt:a.dszt,allowedActions:a.allowedActions,blockedReason:a.blockedReason})):[]});}
+ gateway={pages:pages.map(p=>({httpStatus:p.httpStatus,retcode:p.retcode,page:p.result?.page,scheduleIds:p.result?.schedules?.map(a=>a.GeneralID)})),details};
+}
+console.log(JSON.stringify({readOnly:true,companyId,date,teacher:teachers[0],findings,gateway,limitations:gateway?[]:['未提供老师会话,尚未验证老师账号实际列表与详情响应']},null,2));

+ 23 - 0
scripts/cloud/lesson-workflow-ops.js

@@ -0,0 +1,23 @@
+// Trace a lesson by either the admin CommonModel ID or the mobile GeneralID.
+async function lessonFeedbackDetail(context,input) {
+  requireRole(context,['ops-manager','ops-staff','ops-auditor']);
+  const companyId=companyIdOf(context,input),target=text(input.targetId||input.appointmentId||input.lessonId);
+  if(!companyId||!target)fail(400,'缺少帐套或预约编号');
+  const appointment=await Psql.oneOrNone('SELECT a.*,c."objectId" AS "commonId",c."generalId",c.title,c.subtitle,c.status AS "commonStatus" FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=54 AND (c."objectId"=$2 OR c."generalId"::text=$2 OR a."objectId"=$2) LIMIT 1',[companyId,target]);
+  if(!appointment)fail(404,'预约不存在');
+  const gid=String(appointment.generalId);
+  const [report,lessons,reservations,review,issue]=await Promise.all([
+    Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE company=$1 AND "appointmentGeneralId"::text=$2 ORDER BY "submittedAt" DESC LIMIT 1',[companyId,gid]),
+    Psql.query('SELECT "objectId",pf,pjnr,pldp FROM "LessonRecord" WHERE company=$1 AND yyds::text=$2',[companyId,gid]),
+    Psql.query('SELECT state,"creditCosts","consumedAt" FROM "CourseCreditReservation" WHERE company=$1 AND "appointmentId"=$2',[companyId,appointment.commonId]),
+    Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"=$2',[companyId,gid]),
+    Psql.oneOrNone('SELECT message FROM "LessonWorkflowIssue" WHERE company=$1 AND "appointmentGeneralId"=$2',[companyId,gid]),
+  ]);
+  const costs=report?.creditCosts||appointment.creditCostsSnapshot||[];
+  return {...(report?safe(report):{}),appointmentGeneralId:gid,hasReport:Boolean(report),status:number(appointment.dszt),plannedAt:appointment.yysj,actualStartAt:report?.actualStartAt||appointment.kssj,actualEndAt:report?.actualEndAt||appointment.jssj,studentName:appointment.title,courseName:appointment.subtitle,courseCategoryName:appointment.courseCategoryName,durationMinutes:appointment.durationMinutes,deliveryLabel:deliveryLabel(appointment.deliveryMode),creditCosts:costs.map(cost=>({...cost,label:TEACHING_CREDIT_LABELS[cost.account]||cost.account})),creditState:reservations.map(r=>r.state).join('、')||'缺少凭据',lessonCount:lessons.length,studentReview:review||(number(lessons[0]?.pf)>0?{score:number(lessons[0].pf),comment:lessons[0].pjnr}:null),issue:issue?.message||(lessons.length>1?'重复上课记录,需核对':'')};
+}
+
+async function lessonWorkflowSchedule(context,input,data) {
+  const issues=await Psql.query('SELECT i."appointmentGeneralId",i.message,i."updatedAt",c."objectId" AS "targetId" FROM "LessonWorkflowIssue" i LEFT JOIN "CommonModel" c ON c.company=i.company AND c."modelId"=54 AND c."generalId"::text=i."appointmentGeneralId" WHERE i.company=$1 ORDER BY i."updatedAt" DESC LIMIT 200',[companyIdOf(context,input)]);
+  return {...data,workflowIssues:issues};
+}

+ 70 - 0
scripts/cloud/lesson-workflow.js

@@ -0,0 +1,70 @@
+// Appointment reads and commands share the same new-database identity.
+function assertLessonTenant(current) {
+  const company=current.get('company');
+  if ((company && (company.id || company.objectId)) !== DEFAULT_COMPANY_ID) fail(403, '无权访问该帐套');
+}
+function lessonFeedbackInput(input) {
+  const raw = parseObject(input.feedback, '完课反馈'), feedback = {};
+  for (const key of ['contentSummary','reviewSummary','trainingSummary','homework','notes','coachComment']) {
+    const value = String(raw[key] || '').trim();
+    if (value.length > 1000) fail(400, '课堂反馈每项最多1000字');
+    feedback[key] = value;
+  }
+  feedback.reviewStatus = String(raw.reviewStatus || '');
+  feedback.trainingStatus = String(raw.trainingStatus || '');
+  feedback.noHomework = raw.noHomework === true;
+  if (!feedback.contentSummary || !['完成','部分完成','未复习','无复习任务'].includes(feedback.reviewStatus)
+    || !['正常进行','需要巩固','已完成','暂停'].includes(feedback.trainingStatus) || !feedback.trainingSummary
+    || !feedback.noHomework && !feedback.homework) fail(400, '请完整填写学习内容、复习、训练进度和作业');
+  return feedback;
+}
+async function appLessonComplete(input, current) {
+  assertLessonTenant(current);
+  const id = String(input.orderId || '').trim(), key = String(input.requestKey || '');
+  if (!id || key.length < 10 || key.length > 160 || !input.expectedUpdatedAt || Number.isNaN(Date.parse(input.expectedUpdatedAt))) fail(400, '缺少预约编号、请求编号或详情版本,请刷新后重试');
+  const feedback = lessonFeedbackInput(input);
+  try {
+    return (await Psql.one('SELECT xs_lesson_complete($1,$2,$3,$4,$5::timestamptz,$6,$7::jsonb) AS result',
+      [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),input.expectedUpdatedAt,key,JSON.stringify(feedback)])).result;
+  } catch (error) {
+    const message = String(error.message || error);
+    if (message.includes('历史凭据')) await Psql.none('INSERT INTO "LessonWorkflowIssue" (company,"appointmentGeneralId",message) VALUES($1,$2,$3) ON CONFLICT(company,"appointmentGeneralId") DO UPDATE SET message=EXCLUDED.message,"updatedAt"=now()', [DEFAULT_COMPANY_ID,id,message]);
+    fail(409, message);
+  }
+}
+async function appLessonReview(input, current) {
+  assertLessonTenant(current);
+  const score = Number(input.score), comment = String(input.comment || '').trim(), id = String(input.orderId || '');
+  if (!id || !Number.isInteger(score) || score < 1 || score > 5 || comment.length > 200) fail(400, '评分应为1至5分,评价最多200字');
+  try { return (await Psql.one('SELECT xs_lesson_review($1,$2,$3,$4,$5,$6) AS result', [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),score,comment])).result; }
+  catch (error) { fail(409, String(error.message || error)); }
+}
+async function lessonOrderCapabilities(order, current) {
+  assertLessonTenant(current);
+  const id = String(order.GeneralID), uid = ownLegacyId(current), status = number(order.dszt);
+  const state = await Psql.oneOrNone('SELECT a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."generalId"::text=$2', [DEFAULT_COMPANY_ID,id]);
+  if (!state) fail(404, '预约不存在');
+  const lesson = await Psql.oneOrNone('SELECT pf,pjnr,pldp FROM "LessonRecord" WHERE company=$1 AND yyds::text=$2 ORDER BY "createdAt" LIMIT 1',[DEFAULT_COMPANY_ID,id]);
+  const review = await Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"=$2',[DEFAULT_COMPANY_ID,id]);
+  const teacher = uid === number(order.pl), student = uid === number(order.szyh), actions = [];
+  let blockedReason = '';
+  if (order.cancelled) blockedReason = '预约已取消';
+  else if (teacher) {
+    if (status === 0 && !state.validToday) blockedReason = '课程仅在预约当天可开始,其他日期请联系管理员改期';
+    else if (status === 0 && (!state.bindingId || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = '开课记录或课时凭据尚未核实,请联系管理员';
+    else if (status === 0) actions.push('start');
+    if (status === 10) actions.push('continue','complete');
+    if ([11,20].includes(status)) actions.push('complete');
+  }
+  const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
+  if (!order.cancelled && student && [30,40].includes(status) && !reviewed) actions.push('review');
+  return {...order,...state,serverNow:new Date().toISOString(),allowedActions:actions,blockedReason,reviewStatus:reviewed?'submitted':'pending',studentReview:review || (reviewed ? {score:number(lesson.pf),comment:lesson.pjnr,submittedAt:null} : null)};
+}
+async function databaseAppointmentVersions(input,current) {
+  assertLessonTenant(current);
+  const coach = number(current.get('legacyGroupId')) === 3;
+  const uid = ownLegacyId(current), selected = coach && number(input.studentId) ? await appLearningTarget(current,input.studentId) : 0;
+  const row=await Psql.one('SELECT MAX(GREATEST(a."updatedAt",c."updatedAt"))::text AS changed_at,COUNT(*)::int AS total FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE a.company=$1 AND a."'+(coach?'pl':'szyh')+'"::text=$2 AND ($3::numeric=0 OR a.szyh::text=$3::text)',[DEFAULT_COMPANY_ID,String(uid),selected]);
+  const value=String(row.changed_at||'')+':'+row.total;
+  return {version:'appointments='+value,scopeVersions:{appointments:value},refreshedAt:new Date().toISOString(),syncStatus:{state:'current'},source:'database'};
+}

+ 2 - 2
scripts/cloud/member-enrollment-app.js

@@ -1,6 +1,6 @@
 async function enrollmentOrderTransition(input,current,order,orderId,status){
  const row=await Psql.oneOrNone('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND "modelId"=54 AND "generalId"::text=$2',[DEFAULT_COMPANY_ID,String(orderId)]);if(!row)fail(404,'预约不存在');
- if(status===11){const reviews=await memoryReviewsForOrder(orderId,order,current);try{const done=await Psql.one('SELECT xs_complete_appointment($1,$2,$3) AS result',[DEFAULT_COMPANY_ID,row.objectId,current.id]);return{GeneralID:orderId,status,previousStatus:number(order.dszt),...done.result,reviewCount:reviews.count};}catch(error){for(const pair of reviews.created.reverse()){if(pair.common)await pair.common.destroy({useMasterKey:true});if(pair.record)await pair.record.destroy({useMasterKey:true});}fail(409,String(error.message));}}
- try{await Psql.one('SELECT xs_start_appointment($1,$2) AS result',[DEFAULT_COMPANY_ID,row.objectId]);}catch(error){fail(409,String(error.message));}
+ if(status!==10)fail(409,'请使用老师一次结课入口');
+ try{await Psql.one('SELECT xs_start_appointment_for_actor($1,$2,$3,$4) AS result',[DEFAULT_COMPANY_ID,row.objectId,current.id,ownLegacyId(current)]);}catch(error){fail(409,String(error.message));}
  return{GeneralID:orderId,status,previousStatus:number(order.dszt)};
 }

+ 1 - 1
scripts/cloud/mobile-teaching.js

@@ -18,7 +18,7 @@ async function appLessonReport(input,current) {
     Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."dsid"::text=$2 AND d."userId"=$3 ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]),
     Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE "fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "orderId"::text=$2 AND "yhid"=$3',[DEFAULT_COMPANY_ID,id,number(order.szyh)])
   ]);
-  return {order,feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()};
+  return {order:await lessonOrderCapabilities(order,current),feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()};
 }
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);

Fișier diff suprimat deoarece este prea mare
+ 13 - 4
scripts/deploy-admin-functions.mjs


+ 51 - 0
scripts/migrate-lesson-workflow.mjs

@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+import { request as httpsRequest } from 'node:https';
+const enrollment=readFileSync(new URL('./sql/member-enrollment.sql',import.meta.url),'utf8');
+const startFunction=enrollment.match(/CREATE OR REPLACE FUNCTION xs_start_appointment[\s\S]*?END \$\$;/)?.[0];
+if(!startFunction)throw Error('未找到开课事务');
+const sql=readFileSync(new URL('./sql/lesson-workflow.sql',import.meta.url),'utf8')+'\n'+startFunction;
+if(!process.argv.includes('--apply')) {
+  console.log('只读检查:结课与评价迁移脚本已就绪。使用 --apply 执行;不修改历史教学或工资数据。');
+  process.exit(0);
+}
+const master=process.env.XIAOSHU_MASTER_KEY;
+if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY');
+const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+async function request(path,options={}) {
+  const res=await fetch(parseUrl+path,{...options,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});
+  const data=await res.json();if(!res.ok||data.error)throw new Error(data.error||data.message||data.retmsg||`HTTP ${res.status}`);return data;
+}
+function invokeMigration(url,token) {
+  return new Promise((resolve,reject)=>{const body=JSON.stringify({token,params:{}}),req=httpsRequest(url,{method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},(res)=>{let raw='';res.setEncoding('utf8');res.on('data',(chunk)=>raw+=chunk);res.on('end',()=>{try{resolve({ok:Number(res.statusCode)>=200&&Number(res.statusCode)<300,data:JSON.parse(raw)})}catch(error){reject(error)}});});req.on('error',reject);req.write(body);req.end();});
+}
+// Register additive Parse fields before SQL, so schema metadata and physical columns agree.
+for(const [name,fields] of Object.entries({
+ CommonModel:{teachingManaged:{type:'Boolean'}},CourseAppointment:{teachingManaged:{type:'Boolean'}},
+ LessonRecord:{teachingManaged:{type:'Boolean'}},LessonCompletionReport:{coachComment:{type:'String'}},
+})){
+ const schema=await request('/schemas/'+name),missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields?.[key]));
+ if(Object.keys(missing).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields:missing})});
+}
+const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if(!company)throw new Error('未找到帐套');
+const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/lesson-workflow-'+suffix;
+let userId='',functionId='';
+try {
+  const sessions=await request('/classes/_Session?include=user&limit=200&order=-updatedAt');
+  const selected=sessions.results.find(s=>s.sessionToken&&s.user?.isAdmin&&!s.user?.isDisabled&&new Date(s.expiresAt.iso)>new Date());
+  if(!selected)throw new Error('没有可用管理员会话');
+  const account={objectId:selected.user.objectId,sessionToken:selected.sessionToken};const authorizedId=account.objectId;
+  const code=`async function handler(request,response){try{const current=request.user;if(!current)return response.status(401).json({success:false});await current.fetch({useMasterKey:true});if(current.id!==${JSON.stringify(authorizedId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(sql)});const counts=await Psql.query('SELECT COUNT(*)::int AS commands FROM "LessonWorkflowCommand"');return response.json({success:true,counts});}catch(error){return response.status(500).json({success:false,message:String(error.message||error)});}}`;
+  const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'结课与评价一次性数据库迁移',enabled:true,params:[],paramList:[],respType:'json'})});functionId=fn.objectId;
+  const res=await invokeMigration(parseUrl.replace(/\/parse$/,'/api/functions/')+path,account.sessionToken);
+  if(!res.ok||!res.data.success)throw new Error(res.data.message||'迁移失败');console.log(JSON.stringify(res.data,null,2));
+} finally {
+  // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind.
+  const failures=[];
+  if(functionId)try{await request('/classes/Function/'+functionId,{method:'DELETE'});}catch{failures.push('临时函数 '+functionId);}
+  if(userId)try{await request('/users/'+userId,{method:'DELETE'});}catch{failures.push('临时管理员 '+userId);}
+  if(failures.length)throw new Error('需要清理:'+failures.join('、'));
+}

+ 36 - 16
scripts/smoke-mobile-teaching-flow.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 // Creates disposable users in the target company, verifies real transactions, then removes all fixture business rows.
 import {randomBytes,randomUUID} from 'node:crypto';
-import {writeFileSync,mkdirSync} from 'node:fs';
+import {writeFileSync,mkdirSync,rmSync} from 'node:fs';
 import {resolve,join} from 'node:path';
 const co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',suffix=Date.now(),state={users:[],co,admin:null,helper:null};
 if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
@@ -38,31 +38,45 @@ try{
  const studentToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
  const learning=(await call('xiaoshu/app/gateway',studentToken,{action:'app_learning_overview'})).data;expect(learning.courses.length===2&&new Set(learning.courses.map(c=>c.categoryKey)).size===2,'用户端分别展示两个开课分类');
  const units=await Promise.all([word,self].map(b=>call('xiaoshu/app/gateway',studentToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:b.bindingIds[0]})));expect(JSON.stringify(units[0].data)===JSON.stringify(units[1].data),'同词表跨分类沿用共享词汇进度');
- const date=new Date(Date.now()+10*86400000).toISOString().slice(0,10),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
+ const date=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
  const ambiguous=await ops('ops/appointments/preflight',{payload:draft},true);expect(ambiguous.status===409,'旧排课请求遇分类歧义明确拒绝');
  const appointment=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0]})).data.targetIds[0];
  const appData=await parse('/classes/CommonModel/'+appointment);const generalId=appData.generalId;
- const teacherView=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_companion_overview',mode:'coach',rangeStart:date,rangeEnd:date})).data;expect(teacherView.schedules.some(a=>a.bindingId===word.bindingIds[0]&&a.courseCategoryKey==='word'),'教师端预约保留开课分类');
+ const teacherView=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_companion_overview',mode:'coach',source:'database',rangeStart:date,rangeEnd:date})).data;expect(teacherView.schedules.some(a=>a.bindingId===word.bindingIds[0]&&a.courseCategoryKey==='word'),'教师端预约保留开课分类');
  const assisted=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/create',studentId:student.legacyUserId,requestKey:randomUUID(),className:'SurveyItem',value:{type:'reading',title:'隔离老师带读',content:'A test article.',createOptions:{tpl:'test',params:{wordList:['test']}}}})).data;
  expect(assisted.learnerLegacyId===student.legacyUserId&&assisted.operatorId===coach.objectId,'老师带读归属所选学生并记录老师');
  const studentArticle=(await call('xiaoshu/app/gateway',studentToken,{action:'reading/get',className:'SurveyItem',objectId:assisted.objectId})).data;expect(studentArticle.objectId===assisted.objectId,'学生读取老师带读文章');
- await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:generalId})});const feedback={action:'e_order_complete_feedback',orderId:generalId,content:JSON.stringify({GeneralID:generalId}),feedback:JSON.stringify({contentSummary:'隔离教学验收',reviewStatus:'完成',reviewSummary:'复习已完成',trainingStatus:'正常进行',trainingSummary:'已完成本单元',homework:'无作业',noHomework:true,notes:'隔离账号验收'})};
- await call('xiaoshu/app/gateway',coach.sessionToken,feedback);await call('xiaoshu/app/gateway',coach.sessionToken,feedback);
+ const beforeVersion=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_live_versions',source:'database'})).data;
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:generalId})});
+ const classroom=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:generalId})).data[0];
+ expect(classroom.allowedActions.includes('complete'),'当天可提前开始并允许结课');
+ const unit=(Array.isArray(units[0].data)?units[0].data:[])[0];expect(!!unit?.NodeID,'测试课程有可学习单元');
+ const vocabulary=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,nodeid:unit.NodeID,psize:1})).data;
+ const learnedWord=vocabulary[0];expect(Number(learnedWord?.GeneralID)>0,'读取真实词库内容用于隔离课堂');
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'stu_record_update_v2',orderId:generalId,uid:student.legacyUserId,requestKey:randomUUID(),addon:JSON.stringify({learned:1,ygg:1,djq:0,xxqs:JSON.stringify([{GeneralID:learnedWord.GeneralID,Title:learnedWord.Title,check:1}])})});
+ const feedback={action:'app_lesson_complete',orderId:generalId,expectedUpdatedAt:classroom.updatedAt,requestKey:randomUUID(),feedback:{contentSummary:'隔离教学验收',reviewStatus:'完成',reviewSummary:'复习已完成',trainingStatus:'正常进行',trainingSummary:'已完成本单元',homework:'无作业',noHomework:true,notes:'隔离账号验收',coachComment:'隔离老师点评'}};
+ const completed=await Promise.all([call('xiaoshu/app/gateway',coach.sessionToken,feedback),call('xiaoshu/app/gateway',coach.sessionToken,feedback)]);
+ expect(completed.every(r=>r.data.status===30),'并发结课均返回已结课');
  const detail=(await call('xiaoshu/app/gateway',studentToken,{action:'e_order_detail',id:generalId})).data;
- expect(detail[0]?.feedback?.contentSummary==='隔离教学验收','老师提交完课后学生读取反馈');
- const lessonReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;expect(lessonReport.feedback?.contentSummary==='隔离教学验收','新库学习报表读取同一反馈');
- const feedbackForeign=await call('xiaoshu/app/gateway',empty.sessionToken,feedback,true);
- expect(feedbackForeign.status===403,'已有完课反馈的重试仍校验学员权限');
- const lessons=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:59,myfield2:'yyds='+generalId+'|xymz='+student.legacyUserId,psize:2})).data;const lessonGeneralId=lessons[0].GeneralID;
- await call('xiaoshu/app/gateway',studentToken,{action:'content_update',requestKey:randomUUID(),content:JSON.stringify({GeneralID:lessonGeneralId}),addon:JSON.stringify({pf:5,pjnr:'隔离学生评价'})});
- await call('xiaoshu/app/gateway',studentToken,{action:'e_order_update_v2',status:20,content:JSON.stringify({GeneralID:generalId})});
- await call('xiaoshu/app/gateway',coach.sessionToken,{action:'content_update',requestKey:randomUUID(),content:JSON.stringify({GeneralID:lessonGeneralId}),addon:JSON.stringify({pldp:'隔离老师点评',plpjsj:'2026-09-10 10:00:00'})});
- await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:30,content:JSON.stringify({GeneralID:generalId})});expect(true,'学生评价、老师点评与最终完成均可保存');
+ expect(Number(detail[0]?.dszt)===30&&detail[0]?.feedback?.contentSummary==='隔离教学验收'&&detail[0]?.allowedActions.includes('review'),'学生评价前已结课且可读报告');
+ const lessonReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;
+ expect(lessonReport.feedback?.contentSummary==='隔离教学验收'&&Number(lessonReport.feedback?.vocabularyCount)===1,'新库报告使用已保存课堂成果');
+ const feedbackForeign=await call('xiaoshu/app/gateway',empty.sessionToken,feedback,true);expect(feedbackForeign.status===403||feedbackForeign.status===409,'其他用户无法结课');
+ const review={action:'app_lesson_review',orderId:generalId,score:5,comment:'隔离学生评价',requestKey:randomUUID()};
+ const firstReview=(await call('xiaoshu/app/gateway',studentToken,review)).data;
+ const repeatedReview=(await call('xiaoshu/app/gateway',studentToken,{...review,score:1,comment:'不能覆盖首次评价'})).data;
+ expect(Number(firstReview.score)===5&&Number(repeatedReview.score)===5,'评价仅首次生效');
+ const deniedReview=await call('xiaoshu/app/gateway',empty.sessionToken,review,true);expect(deniedReview.status===403||deniedReview.status===409,'其他学生不能评价');
+ const oldWrite=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:11,content:JSON.stringify({GeneralID:generalId})},true);expect(oldWrite.status===409,'旧客户端不能回退结课状态');
+ const trace=(await ops('ops/lesson-feedback/detail',{targetId:appointment})).data;
+ expect(trace.status===30&&trace.lessonCount===1&&trace.hasReport&&trace.studentReview.score===5&&trace.creditState==='consumed','后台对账显示唯一记录、报告、评价与消费预留');
+ const afterVersion=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_live_versions',source:'database'})).data;expect(beforeVersion.version!==afterVersion.version,'跨设备更新版本随课堂推进变化');
  await mutate('ops/appointments/complete',appointment,{});
  const after=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(after.accounts.find(a=>a.account==='SilverCoin').balance===4&&after.accounts.find(a=>a.account==='UserPoint').balance===4,'教师完课与管理员重试只扣一次');
  const a2=(await mutate('ops/appointments/create','',{...draft,bindingId:self.bindingIds[0],startTime:'12:00'})).data.targetIds[0];await mutate('ops/appointments/cancel',a2,{});await mutate('ops/appointments/restore',a2,{});await mutate('ops/appointments/cancel',a2,{});const cancelledCommon=await parse('/classes/CommonModel/'+a2);const cancelledStart=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:cancelledCommon.generalId})},true);expect(cancelledStart.status===409,'取消预约禁止开课');
  const a3=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0],startTime:'13:00'})).data.targetIds[0],a3Common=await parse('/classes/CommonModel/'+a3);await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:a3Common.generalId})});
- const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),mutate('ops/appointments/complete',a3,{},randomUUID(),true)]);expect(rechargeRace.every(r=>r.status===200),'充值与完课并发都成功');
+ const nextDetail=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:a3Common.generalId})).data[0];
+ const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),call('xiaoshu/app/gateway',coach.sessionToken,{...feedback,orderId:a3Common.generalId,expectedUpdatedAt:nextDetail.updatedAt,requestKey:randomUUID()},true)]);expect(rechargeRace.every(r=>r.status===200),'充值与完课并发都成功');
  const concurrentBalance=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(concurrentBalance.accounts.find(a=>a.account==='SilverCoin').balance===4,'充值与完课并发余额无覆盖');
  const transfer=accounts.transactions.find(t=>t.kind==='transfer'&&t.account==='Purse');await mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:4,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId});
  const drafts=['14:00','15:00'].map(startTime=>({...draft,bindingId:word.bindingIds[0],durationMinutes:30,startTime}));const race=await Promise.all(drafts.map(p=>mutate('ops/appointments/create','',p,randomUUID(),true)));expect(race.filter(r=>r.status===200).length===1&&race.filter(r=>r.status===409).length===1,'服务器多连接争抢最后一节课仅一单成功');
@@ -72,7 +86,7 @@ try{
  const audit=(await call(state.helper.path,state.admin.sessionToken,{mode:'audit'})).data;expect(audit[0].missing===0,'全部课时业务单均有事务审计');
  const snapshot=(await call(state.helper.path,state.admin.sessionToken,{mode:'counts'})).data;console.log('ENROLLMENT_COUNTS',snapshot);console.log('ONLINE_SMOKE_PASSED');
 }catch(e){console.error('ONLINE_SMOKE_FAILED',e.message);process.exitCode=1;}
-finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
+finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');rmSync(statePath,{force:true});console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
 
 function helperCode(state){
 const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
@@ -81,6 +95,12 @@ const statements=[
 ['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
 ['DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($2::text[])',[co,texts]],
 ['DELETE FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId" IN (SELECT c."generalId" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"=c."itemId" WHERE c."company"=$1 AND c."modelId"=54 AND a."szyh"::text=ANY($2::text[]))',[co,texts]],
+['DELETE FROM "LessonWorkflowCommand" WHERE company=$1 AND actor=ANY($2::text[])',[co,objects]],
+...['LessonReview','LessonWorkflowIssue'].map(t=>['DELETE FROM "'+t+'" WHERE company=$1 AND "appointmentGeneralId" IN (SELECT c."generalId"::text FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=54 AND a.szyh::text=ANY($2::text[]))',[co,texts]]),
+['DELETE FROM "CommonModel" c USING "DailyStudyRecord" d WHERE c.company=$1 AND d.company=$1 AND c."modelId"=56 AND c."itemId"=d.id AND d."userId"=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM "DailyStudyRecord" WHERE company=$1 AND "userId"=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM "CommonModel" c USING "MemoryPracticeRecord" m WHERE c.company=$1 AND m.company=$1 AND c."modelId"=60 AND c."itemId"=m.id AND m.yhid=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM "MemoryPracticeRecord" WHERE company=$1 AND yhid=ANY($2::numeric[])',[co,ids]],
 ['DELETE FROM "CourseCreditReservation" WHERE "company"=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],
 ['DELETE FROM "CommonModel" c USING "LessonRecord" l WHERE c."company"=$1 AND l."company"=$1 AND c."modelId"=59 AND c."itemId"=l."id" AND l."xymz"::text=ANY($2::text[])',[co,texts]],
 ['DELETE FROM "LessonRecord" WHERE "company"=$1 AND "xymz"::text=ANY($2::text[])',[co,texts]],

+ 181 - 0
scripts/sql/lesson-workflow.sql

@@ -0,0 +1,181 @@
+-- Install after member-enrollment.sql and mobile-teaching.sql. No historical backfill.
+CREATE TABLE IF NOT EXISTS "LessonWorkflowCommand" (
+ company text NOT NULL, actor text NOT NULL, "requestKey" text NOT NULL,
+ fingerprint jsonb NOT NULL, result jsonb NOT NULL, "createdAt" timestamptz DEFAULT now(),
+ PRIMARY KEY(company,actor,"requestKey")
+);
+CREATE TABLE IF NOT EXISTS "LessonReview" (
+ company text NOT NULL, "appointmentGeneralId" text NOT NULL, "studentId" numeric NOT NULL,
+ score integer NOT NULL CHECK(score BETWEEN 1 AND 5), comment text NOT NULL CHECK(length(comment)<=200),
+ "submittedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"appointmentGeneralId")
+);
+CREATE TABLE IF NOT EXISTS "LessonWorkflowIssue" (
+ company text NOT NULL, "appointmentGeneralId" text NOT NULL, message text NOT NULL,
+ "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"appointmentGeneralId")
+);
+ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
+ALTER TABLE "CommonModel" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
+ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
+ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "coachComment" text;
+
+CREATE OR REPLACE FUNCTION xs_lesson_complete(co text,gid text,actor text,actor_uid numeric,expected timestamptz,request_key text,feedback jsonb)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "LessonCompletionReport";
+ previous "LessonWorkflowCommand"; result jsonb; lesson_count int; cost jsonb; ledger_table text; evidence int;
+ study record; words jsonb='[]'; review_date text; review_key text; review_id numeric; review_object text;
+ total_reviews int; completed_reviews int; report_id text; was_charged boolean=false;
+BEGIN
+ IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
+ IF NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR length(feedback->>'contentSummary')>1000
+ OR COALESCE(feedback->>'reviewStatus','') NOT IN ('完成','部分完成','未复习','无复习任务')
+ OR COALESCE(feedback->>'trainingStatus','') NOT IN ('正常进行','需要巩固','已完成','暂停')
+ OR NULLIF(trim(feedback->>'trainingSummary'),'') IS NULL OR length(feedback->>'trainingSummary')>1000
+ OR (NOT COALESCE((feedback->>'noHomework')::boolean,false) AND NULLIF(trim(feedback->>'homework'),'') IS NULL)
+ THEN RAISE EXCEPTION '请完整填写学习内容、复习、训练进度和作业';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "generalId"::text=gid;
+ SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId";
+ IF a."objectId" IS NULL OR c.status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
+ IF a.pl::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约老师可以结课';END IF;
+ -- Same lock order as credit transfers and xs_complete_appointment.
+ PERFORM 1 FROM "_User" WHERE company=co AND "legacyUserId"::text=a.szyh::text FOR UPDATE;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
+ SELECT * INTO previous FROM "LessonWorkflowCommand" WHERE company=co AND "LessonWorkflowCommand".actor=xs_lesson_complete.actor AND "requestKey"=request_key;
+ IF FOUND THEN
+  IF previous.fingerprint<>jsonb_build_object('id',gid,'feedback',feedback) THEN RAISE EXCEPTION '请求编号已用于其他内容';END IF;
+  RETURN previous.result;
+ END IF;
+ IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)>1 THEN RAISE EXCEPTION '历史凭据存在重复报告,请由后台核对';END IF;
+ SELECT * INTO report FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid ORDER BY "submittedAt" DESC LIMIT 1;
+ IF a.dszt='30' AND report."objectId" IS NOT NULL THEN RETURN jsonb_build_object('GeneralID',gid,'status',30,'unchanged',true,'feedbackId',report."objectId");END IF;
+ IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
+ IF a.dszt NOT IN ('10','11','20') THEN RAISE EXCEPTION '请先开始课程';END IF;
+ SELECT count(*) INTO lesson_count FROM "LessonRecord" WHERE company=co AND yyds::text=gid;
+ IF lesson_count>1 THEN RAISE EXCEPTION '历史凭据存在重复上课记录,请由后台核对';END IF;
+ SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid LIMIT 1;
+ IF l."objectId" IS NOT NULL THEN words=COALESCE(NULLIF(l.kzsj,'')::jsonb,'[]');END IF;
+ IF l."objectId" IS NOT NULL OR a.dszt IN ('11','20') THEN
+  IF l."objectId" IS NULL OR l.xymz::text IS DISTINCT FROM a.szyh::text OR l.jsmz::text IS DISTINCT FROM a.pl::text
+   OR NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE company=co AND "modelId"=59 AND "itemId"=l.id AND status=99)
+   OR jsonb_array_length(COALESCE(a."creditCostsSnapshot"::jsonb,'[]'))=0
+   OR (SELECT count(*) FROM "CourseCreditReservation" WHERE company=co AND "appointmentId"=c."objectId" AND state='consumed' AND "creditCosts"::jsonb=a."creditCostsSnapshot"::jsonb)<>1
+  THEN RAISE EXCEPTION '历史凭据不完整,请由后台核对课时,不能自动补扣';END IF;
+  FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
+   ledger_table=CASE cost->>'account' WHEN 'Purse' THEN 'UserExpDomP' WHEN 'SilverCoin' THEN 'UserSIcon' WHEN 'UserExp' THEN 'UserExpHis' WHEN 'UserPoint' THEN 'UserUserPoint' END;
+   IF ledger_table IS NULL THEN RAISE EXCEPTION '历史凭据的账户无效';END IF;
+   EXECUTE format('SELECT count(*) FROM %I WHERE company=$1 AND "userId"::text=$2 AND "sourceKey"=$3 AND score=$4',ledger_table)
+    INTO evidence USING co,a.szyh::text,'credit:complete:'||c."objectId"||':'||a.szyh||':'||(cost->>'account'),-(cost->>'amount')::numeric;
+   IF evidence<>1 THEN RAISE EXCEPTION '历史凭据与课时扣减不一致,请由后台核对';END IF;
+  END LOOP;
+ ELSE
+  -- A fresh completion must consume exactly the reservation created by scheduling.
+  IF (SELECT count(*) FROM "CourseCreditReservation" WHERE company=co AND "appointmentId"=c."objectId" AND state='reserved' AND "creditCosts"::jsonb=a."creditCostsSnapshot"::jsonb)<>1
+  THEN RAISE EXCEPTION '历史凭据缺少匹配的课时预留,请由后台核对';END IF;
+  result=xs_complete_appointment(co,c."objectId",actor);
+  was_charged=COALESCE((result->>'periodDeducted')::boolean,false);
+  SELECT * INTO l FROM "LessonRecord" WHERE company=co AND "objectId"=result->>'lessonId';
+ END IF;
+ SELECT d.*,sc."generalId" AS study_general_id INTO study FROM "DailyStudyRecord" d
+ JOIN "CommonModel" sc ON sc.company=d.company AND sc."modelId"=56 AND sc."itemId"=d.id
+ WHERE d.company=co AND d."userId"::text=a.szyh::text AND d.dsid::text=gid ORDER BY d."updatedAt" DESC LIMIT 1;
+ IF FOUND THEN
+  words=COALESCE(NULLIF(study.xxqs,'')::jsonb,'[]');
+  IF jsonb_typeof(words)<>'array' THEN RAISE EXCEPTION '课堂学习记录格式异常,请核对后重试';END IF;
+  IF jsonb_array_length(words)>0 THEN
+   FOR review_date IN SELECT DISTINCT value FROM unnest(string_to_array(COALESCE(study.fxrl,''),',')) value WHERE value ~ '^\d{8}$' LIMIT 15 LOOP
+    review_key='cloud:e_order_update:'||gid||':review:'||review_date;
+    SELECT id,"objectId" INTO review_id,review_object FROM "MemoryPracticeRecord" WHERE company=co AND "sourceKey"=review_key LIMIT 1;
+    IF review_object IS NULL THEN
+     review_id=nextval('xiaoshu_business_id');review_object=xs_object_id();
+     INSERT INTO "MemoryPracticeRecord" SELECT * FROM jsonb_populate_record(NULL::"MemoryPracticeRecord",jsonb_build_object(
+      'objectId',review_object,'company',co,'id',review_id,'createdAt',now(),'updatedAt',now(),'sourceKey',review_key,
+      'yhid',a.szyh,'plid',COALESCE(NULLIF(a.fxpl,''),a.pl),'orderId',gid,'xxjlid',study.study_general_id,'kcid',a.kcid,
+      'fxzt','0','kywrq',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD'),'kywsj',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD')||' '||COALESCE(a.sdsd,'00:00')));
+    END IF;
+    IF NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE company=co AND "sourceKey"=review_key) THEN
+     PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey',review_key,'generalId',nextval('xiaoshu_business_id'),'itemId',review_id,'modelId',60,'nodeId',388,'tableName','ZL_C_gywjl','title',c.title||'@抗遗忘@'||review_date,'inputer',actor,'status',99));
+    END IF;
+   END LOOP;
+  END IF;
+ END IF;
+ UPDATE "LessonRecord" SET kzsj=words::text,pldp=COALESCE(NULLIF(feedback->>'coachComment',''),NULLIF(feedback->>'notes',''),pldp),plpjsj=now()::text,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
+ UPDATE "CourseAppointment" SET dszt='30',jssj=COALESCE(NULLIF(jssj,''),now()::text),"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
+ UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND ("objectId"=c."objectId" OR ("modelId"=59 AND "itemId"=l.id));
+ SELECT count(*),count(*) FILTER(WHERE fxzt='1') INTO total_reviews,completed_reviews FROM "MemoryPracticeRecord" WHERE company=co AND "orderId"::text=gid;
+ report_id=COALESCE(report."objectId",xs_object_id());
+ -- Existing partial reports are replaced in the same transaction, preserving their ID.
+ DELETE FROM "LessonCompletionReport" WHERE "objectId"=report_id AND company=co;
+ INSERT INTO "LessonCompletionReport" SELECT * FROM jsonb_populate_record(NULL::"LessonCompletionReport",feedback||jsonb_build_object(
+  'objectId',report_id,'company',co,'createdAt',COALESCE(report."createdAt",now()),'updatedAt',now(),'sourceKey','cloud:lesson-feedback:'||co||':'||gid,
+  'appointmentId',a."objectId",'appointmentGeneralId',gid,'lessonId',l."objectId",'studentId',a.szyh,'coachId',a.pl,
+  'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
+  'actualStartAt',NULLIF(a.kssj,'')::timestamptz,'actualEndAt',COALESCE(NULLIF(a.jssj,'')::timestamptz,now()),'creditCosts',a."creditCostsSnapshot",
+  'vocabularyCount',jsonb_array_length(words),'antiForgetSummary',jsonb_build_object('total',total_reviews,'completed',completed_reviews,'pending',total_reviews-completed_reviews),
+  'homework',CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,'submittedBy',actor,'submittedAt',now()));
+ DELETE FROM "LessonWorkflowIssue" WHERE company=co AND "appointmentGeneralId"=gid;
+ result=jsonb_build_object('GeneralID',gid,'status',30,'lessonId',l."objectId",'feedbackId',report_id,'periodDeducted',was_charged);
+ INSERT INTO "LessonWorkflowCommand" VALUES(co,actor,request_key,jsonb_build_object('id',gid,'feedback',feedback),result,now());
+ RETURN result;
+END $$;
+REVOKE ALL ON FUNCTION xs_lesson_complete(text,text,text,numeric,timestamptz,text,jsonb) FROM PUBLIC;
+
+CREATE OR REPLACE FUNCTION xs_lesson_review(co text,gid text,actor text,actor_uid numeric,rating integer,review_comment text)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a record; l record; existing "LessonReview";
+BEGIN
+ IF rating IS NULL OR rating NOT BETWEEN 1 AND 5 OR review_comment IS NULL OR length(review_comment)>200 THEN RAISE EXCEPTION '评分应为1至5分,评价最多200字';END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
+ SELECT ca.*,c.status AS common_status INTO a FROM "CourseAppointment" ca JOIN "CommonModel" c ON c.company=ca.company AND c."modelId"=54 AND c."itemId"=ca.id WHERE c.company=co AND c."generalId"::text=gid FOR UPDATE OF ca;
+ IF NOT FOUND OR a.common_status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
+ IF a.szyh::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约学生可以评价';END IF;
+ IF a.dszt NOT IN ('30','40') THEN RAISE EXCEPTION '课程尚未结课';END IF;
+ SELECT * INTO existing FROM "LessonReview" WHERE company=co AND "appointmentGeneralId"=gid;
+ IF FOUND THEN RETURN to_jsonb(existing)||jsonb_build_object('unchanged',true,'status',a.dszt::int);END IF;
+ IF (SELECT count(*) FROM "LessonRecord" WHERE company=co AND yyds::text=gid)<>1 THEN RAISE EXCEPTION '上课记录缺失或重复,请由后台核对';END IF;
+ SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
+ IF COALESCE(l.pf,'') ~ '^[1-5](\.0+)?$' THEN RETURN jsonb_build_object('score',l.pf,'comment',l.pjnr,'unchanged',true,'status',a.dszt::int);END IF;
+ INSERT INTO "LessonReview" VALUES(co,gid,actor_uid,rating,review_comment,now()) RETURNING * INTO existing;
+ UPDATE "LessonRecord" SET pf=rating::text,pjnr=review_comment,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
+ UPDATE "CourseAppointment" SET "teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
+ UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND (("modelId"=54 AND "itemId"=a.id) OR ("modelId"=59 AND "itemId"=l.id));
+ RETURN to_jsonb(existing)||jsonb_build_object('status',a.dszt::int);
+END $$;
+REVOKE ALL ON FUNCTION xs_lesson_review(text,text,text,numeric,integer,text) FROM PUBLIC;
+
+-- Recheck assignment under the same lock as scheduling; the preceding HTTP read is not authority.
+CREATE OR REPLACE FUNCTION xs_start_appointment_for_actor(co text,aid text,actor text,actor_uid numeric)
+RETURNS void LANGUAGE plpgsql AS $$
+DECLARE assigned text;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT a.pl INTO assigned FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=co AND c."objectId"=aid;
+ IF assigned IS DISTINCT FROM actor_uid::text OR NOT EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=actor AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '仅预约老师可以开始课程';END IF;
+ PERFORM xs_start_appointment(co,aid);
+END $$;
+REVOKE ALL ON FUNCTION xs_start_appointment_for_actor(text,text,text,numeric) FROM PUBLIC;
+
+-- Protect completed lessons even if a legacy sync read races with the teacher command.
+CREATE OR REPLACE FUNCTION xs_lesson_no_regression() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF OLD."teachingManaged" IS TRUE AND (
+   (OLD.dszt IN ('30','40') AND NEW.dszt IS DISTINCT FROM OLD.dszt AND NOT (OLD.dszt='30' AND NEW.dszt='40'))
+   OR (OLD.dszt='10' AND NEW.dszt NOT IN ('10','11','20','30','40'))
+   OR NEW."teachingManaged" IS DISTINCT FROM true)
+ THEN RAISE EXCEPTION '新系统课次不可由旧流程回退';END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS lesson_no_regression ON "CourseAppointment";
+CREATE TRIGGER lesson_no_regression BEFORE UPDATE ON "CourseAppointment" FOR EACH ROW EXECUTE FUNCTION xs_lesson_no_regression();
+
+-- Administrators retain the existing correction operation with a required, transactional audit reason.
+CREATE OR REPLACE FUNCTION xs_admin_lesson_correction(co text,aid text,actor text,reason text)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE result jsonb;
+BEGIN
+ IF NULLIF(trim(reason),'') IS NULL THEN RAISE EXCEPTION '请填写纠错原因';END IF;
+ result=xs_complete_appointment(co,aid,actor);
+ PERFORM xs_insert('EnrollmentCommand',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',xs_object_id(),'request',jsonb_build_object('actor',actor,'reason',reason,'appointmentId',aid,'operation','admin.lesson-correction'),'result',result));
+ RETURN result;
+END $$;
+REVOKE ALL ON FUNCTION xs_admin_lesson_correction(text,text,text,text) FROM PUBLIC;

+ 4 - 1
scripts/sql/member-enrollment.sql

@@ -20,6 +20,8 @@ ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "categoryKey" text;
 ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "categoryName" text;
 ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "enrollmentStatus" text;
 ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "bindingId" text;
+ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
+ALTER TABLE "CommonModel" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
 ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "bindingId" text;
 ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "courseCategoryKey" text;
 ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "courseCategoryName" text;
@@ -350,7 +352,8 @@ BEGIN
  IF a."dszt"::text NOT IN ('0','10') THEN RAISE EXCEPTION '预约状态已变更';END IF;
  IF a."dszt"::text='0' AND NOT xs_appointment_valid_at(a."bxrq",a."yysj") THEN RAISE EXCEPTION '课程仅在预约当天 00:00 至 24:00 有效,请调整排课日期';END IF;
  IF COALESCE(NULLIF(a."pl"::text,''),'0')='0' THEN RAISE EXCEPTION '请先分配教师';END IF;
- UPDATE "CourseAppointment" SET "dszt"='10',"kssj"=COALESCE(NULLIF("kssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
+ UPDATE "CourseAppointment" SET "dszt"='10',"teachingManaged"=true,"kssj"=COALESCE(NULLIF("kssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
+ UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE "company"=co AND "objectId"=aid;
 END $$;
 -- Reject stale profile writes that would silently overwrite balances changed by a transaction.
 CREATE OR REPLACE FUNCTION xs_user_credit_guard() RETURNS trigger LANGUAGE plpgsql AS $$

+ 156 - 0
scripts/tests/lesson-workflow.test.mjs

@@ -0,0 +1,156 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite();
+const enrollmentSql=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');
+const workflowSql=readFileSync(new URL('../sql/lesson-workflow.sql',import.meta.url),'utf8');
+const one=async(sql,args=[])=>(await db.query(sql,args)).rows[0];
+const feedback={contentSummary:'学习 Unit 1',reviewStatus:'完成',reviewSummary:'复习两个单词',trainingStatus:'正常进行',trainingSummary:'完成课堂练习',noHomework:true,homework:'',notes:'',coachComment:'继续保持'};
+const complete=async(gid,version,key='request-complete-001',actor='teacher',uid=30,f=feedback)=>(await one('SELECT xs_lesson_complete($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',gid,actor,uid,version,key,JSON.stringify(f)])).result;
+const review=async(gid,rating=5,comment='很好',actor='student',uid=20)=>(await one('SELECT xs_lesson_review($1,$2,$3,$4,$5,$6) result',['co',gid,actor,uid,rating,comment])).result;
+let binding;
+before(async()=>{
+
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" double precision,"cksl" double precision,"syjd" text);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" double precision,"itemId" double precision,"modelId" double precision,"nodeId" double precision,"tableName" text,"title" text,"subtitle" text,"status" double precision,"inputer" text);
+ CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"szyh" text,"kcid" text,"pl" text,"fxpl" text,"dszt" text,"dslx" text,"jffs" text,"jssj" text,"updatedAt" timestamptz,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"courseCategoryKey" text,"courseCategoryName" text,"teacherPaySnapshot" double precision);
+ CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"xymz" text,"jsmz" text,"kcid" text,"kclx" text,"kcmc" text,"yyds" text,"jffs" text,"kzsj" text,"lessonAt" timestamptz);
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
+ CREATE TABLE "CourseTeachingProfile" ("company" text,"courseId" double precision,"categoryKey" text,"confirmed" boolean);
+ CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
+ await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
+ ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
+ await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);
+ INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
+ INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
+ await db.exec(enrollmentSql);
+ await db.exec(`ALTER TABLE "LessonRecord" ADD pf text,ADD pjnr text,ADD pldp text,ADD plpjsj text;
+ ALTER TABLE "DailyStudyRecord" ADD company text,ADD id numeric,ADD "userId" numeric,ADD dsid text,ADD xxqs text,ADD fxrl text,ADD "updatedAt" timestamptz;
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text primary key,company text,id numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,yhid numeric,plid numeric,"orderId" numeric,xxjlid numeric,kcid numeric,fxzt text,kywrq text,kywsj text);
+ CREATE TABLE "LessonCompletionReport" ("objectId" text primary key,company text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"appointmentId" text,"appointmentGeneralId" numeric,"lessonId" text,"studentId" numeric,"coachId" numeric,"courseCategoryKey" text,"courseCategoryName" text,"deliveryMode" text,"durationMinutes" numeric,"actualStartAt" timestamptz,"actualEndAt" timestamptz,"creditCosts" jsonb,"contentSummary" text,"vocabularyCount" numeric,"reviewStatus" text,"reviewSummary" text,"trainingStatus" text,"trainingSummary" text,"antiForgetSummary" jsonb,homework text,"noHomework" boolean,notes text,"submittedBy" text,"submittedAt" timestamptz);
+ INSERT INTO "_User" VALUES('teacher','co',30,3,'coach',false,'{}',now()),('other-teacher','co',31,3,'coach',false,'{}',now());`);
+ await db.exec(workflowSql);
+ await db.query("SELECT xs_credit_post('co','admin','fund','store','Purse',30,'','初始课时','转账','fund-workflow')");
+ await db.query("SELECT xs_credit_post('co','admin','transfer','student','Purse',30,'','学员课时','转账','transfer-workflow')");
+ binding=(await one(`SELECT xs_enroll('co','admin',20,'word','[{"courseId":100,"courseName":"词表","wordCount":200,"categoryName":"单词课"}]','[[{"account":"Purse","amount":1}]]','测试开课','enroll-workflow') result`)).result.bindingIds[0];
+});
+after(()=>db.close());
+let serial=0;
+async function appointment(start=true){
+ const date=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;
+ const time=String(5+(serial++%16)).padStart(2,'0')+':00';
+ const entries=[{common:{title:'学员',subtitle:'词表'},addon:{szyh:'20',pl:'30',fxpl:'30',plxm:'老师',kcid:'100',yykcid:'100',dslx:'1',jffs:'线上',bindingId:binding,courseCategoryKey:'word',durationMinutes:30,deliveryMode:'online',creditCostsSnapshot:[{account:'Purse',amount:1}],teacherPaySnapshot:20,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}}];
+ const result=(await one("SELECT xs_schedule_save('co','admin','{}',$1::jsonb,$2) result",[JSON.stringify(entries),'schedule-'+serial])).result;
+ const common=result.targetIds[0];
+ if(start) await db.query("SELECT xs_start_appointment('co',$1)",[common]);
+ const row=await one('SELECT c."generalId"::text gid,a."objectId",a."updatedAt" FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c."objectId"=$1',[common]);
+ return {...row,common};
+}
+const balance=async()=>Number((await one(`SELECT "legacyUserData"->>'Purse' balance FROM "_User" WHERE "objectId"='student'`)).balance);
+test('老师一次结课生成报告和学习快照,重复并发只扣一次;学生随后评价不改结课状态',async()=>{
+ const a=await appointment(), before=await balance();
+ await db.query('INSERT INTO "DailyStudyRecord" ("objectId",company,id,"userId",dsid,xxqs,fxrl,"updatedAt") VALUES($1,$2,$3,$4,$5,$6,$7,now())',['study','co',999,20,a.gid,'[{"GeneralID":1,"Title":"apple","check":1}]','20300101,20300102']);
+ await db.exec(`INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES('study-common','co',56,999,1000,99)`);
+ const results=await Promise.all(Array.from({length:4},()=>complete(a.gid,a.updatedAt)));
+ assert.ok(results.every(r=>r.status===30));assert.equal(await balance(),before-1);
+ assert.equal(Number((await one('SELECT count(*) n FROM "LessonRecord" WHERE yyds=$1',[a.gid])).n),1);
+ assert.equal(Number((await one('SELECT count(*) n FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid])).n),1);
+ const report=await one('SELECT * FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);assert.equal(report.vocabularyCount,'1');assert.equal(report.antiForgetSummary.total,2);
+ const lesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);assert.equal(JSON.parse(lesson.kzsj)[0].Title,'apple');
+ assert.equal((await review(a.gid)).score,5);assert.equal((await review(a.gid,1,'changed')).score,5);
+ assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).dszt,'30');assert.equal(await balance(),before-1);
+ await assert.rejects(db.query(`UPDATE "CourseAppointment" SET dszt='11' WHERE "objectId"=$1`,[a.objectId]),/不可由旧流程回退/);
+ await assert.rejects(complete(a.gid,a.updatedAt,'request-complete-001','teacher',30,{...feedback,notes:'changed'}),/请求编号/);
+});
+test('报告写入失败回滚结课、课时和学习记录,可原请求重试',async()=>{
+ const a=await appointment(),before=await balance();
+ await db.exec(`ALTER TABLE "LessonCompletionReport" ADD CONSTRAINT fail_report CHECK("contentSummary"<>'失败注入')`);
+ await assert.rejects(complete(a.gid,a.updatedAt,'rollback-request-001','teacher',30,{...feedback,contentSummary:'失败注入'}),/fail_report/);
+ assert.equal(await balance(),before);assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).dszt,'10');
+ assert.equal(Number((await one('SELECT count(*) n FROM "LessonRecord" WHERE yyds=$1',[a.gid])).n),0);
+ await db.exec('ALTER TABLE "LessonCompletionReport" DROP CONSTRAINT fail_report');
+ assert.equal((await complete(a.gid,a.updatedAt,'rollback-request-001','teacher',30,{...feedback,contentSummary:'失败注入'})).status,30);
+});
+test('越权、未结课评价、过期版本、取消均拒绝',async()=>{
+ const a=await appointment();
+ await assert.rejects(complete(a.gid,a.updatedAt,'denied-request-001','other-teacher',31),/仅预约老师/);
+ await assert.rejects(complete(a.gid,'2000-01-01','denied-request-002'),/已更新/);
+ await assert.rejects(review(a.gid),/尚未结课/);
+ await assert.rejects(review(a.gid,5,'','other-teacher',31),/仅预约学生/);
+ await db.query('UPDATE "CommonModel" SET status=-2 WHERE "objectId"=$1',[a.common]);
+ await assert.rejects(complete(a.gid,a.updatedAt,'denied-request-003'),/已取消/);
+});
+test('旧11/20课次复用已扣凭据与评价,缺凭据不补扣',async()=>{
+ const a=await appointment();await db.query("SELECT xs_complete_appointment('co',$1,'teacher')",[a.common]);
+ await db.query(`UPDATE "LessonRecord" SET pf='4',pjnr='原评价' WHERE yyds=$1`,[a.gid]);
+ await db.query(`UPDATE "CourseAppointment" SET dszt='20' WHERE "objectId"=$1`,[a.objectId]);
+ const v=(await one('SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).updatedAt,before=await balance();
+ assert.equal((await complete(a.gid,v,'legacy-request-001')).status,30);assert.equal(await balance(),before);assert.equal((await review(a.gid)).score,'4');
+ const broken=await appointment();await db.query(`UPDATE "CourseAppointment" SET dszt='11' WHERE "objectId"=$1`,[broken.objectId]);
+ await assert.rejects(complete(broken.gid,broken.updatedAt,'legacy-request-002'),/历史凭据/);assert.equal(await balance(),before);
+});
+test('评价原子拒绝无效评分与超长文字',async()=>{
+ const a=await appointment();await complete(a.gid,a.updatedAt,'review-request-001');
+ for (const score of [0,6]) await assert.rejects(review(a.gid,score),/评分/);
+ await assert.rejects(review(a.gid,5,'a'.repeat(201)),/200字/);
+ const all=await Promise.all([review(a.gid,3,'first'),review(a.gid,5,'second')]);assert.equal(all[0].score,all[1].score);
+ assert.equal(Number((await one('SELECT count(*) n FROM "LessonReview" WHERE "appointmentGeneralId"=$1',[a.gid])).n),1);
+});
+
+test('同日提前开课、午夜后继续、非预约当天与改派老师在数据库校验',async()=>{
+ const a=await appointment(false);
+ await db.query(`UPDATE "CourseAppointment" SET sdsd='23:59',yysj=bxrq||' 23:59' WHERE "objectId"=$1`,[a.objectId]);
+ await db.query("SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)",[a.common]);
+ await db.query(`UPDATE "CourseAppointment" SET bxrq='2000-01-01',yysj='2000-01-01 00:00' WHERE "objectId"=$1`,[a.objectId]);
+ await db.query("SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)",[a.common]);
+ const other=await appointment(false);
+ await db.query(`UPDATE "CourseAppointment" SET bxrq='2000-01-01',yysj='2000-01-01 00:00' WHERE "objectId"=$1`,[other.objectId]);
+ await assert.rejects(db.query("SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)",[other.common]),/预约当天/);
+ await db.query(`UPDATE "CourseAppointment" SET pl='31' WHERE "objectId"=$1`,[a.objectId]);
+ await assert.rejects(db.query("SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)",[a.common]),/仅预约老师/);
+});
+
+test('同一真实排课从教师、学生分页查询与详情读取,不访问旧接口;版本感知新增排课',async()=>{
+ const {default:vm}=await import('node:vm');
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'", "const DEFAULT_COMPANY_ID = 'co'");
+ const context={Psql:{query:async(s,p)=>(await db.query(s,p)).rows,one:async(s,p)=>(await db.query(s,p)).rows[0],oneOrNone:async(s,p)=>(await db.query(s,p)).rows[0]||null}};
+ vm.createContext(context);vm.runInContext(app+'\n'+readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8')+'\n'+readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
+ const teacher={id:'teacher',get:k=>({company:{id:'co'},legacyUserId:30,legacyGroupId:3}[k])},student={id:'student',get:k=>({company:{id:'co'},legacyUserId:20,legacyGroupId:1}[k])};
+ context.syncLegacyAppointments=async()=>{throw Error('不应读取旧端');};context.appLegacyConfig=async()=>{throw Error('不应查询旧配置');};
+ await db.exec('ALTER TABLE "_User" ADD COLUMN "realName" text,ADD COLUMN nickname text,ADD COLUMN username text');
+ const before=await context.databaseAppointmentVersions({},teacher),a=await appointment();
+ const after=await context.databaseAppointmentVersions({},teacher);assert.notEqual(before.version,after.version);
+ const t=await context.appCompanionOverview({source:'database',pageSize:100},teacher);
+ const s=await context.appCompanionOverview({source:'database',pageSize:100},student);
+ assert.ok(t.schedules.some(row=>String(row.GeneralID)===a.gid));assert.ok(s.schedules.some(row=>String(row.GeneralID)===a.gid));
+ assert.equal(t.syncStatus.state,'current');assert.equal(s.syncStatus.state,'current');
+ const found=await context.orderDetailRow(a.gid,teacher);
+ const detail=await context.lessonOrderCapabilities(found,teacher);assert.ok(detail.allowedActions.includes('complete'));assert.equal(detail.bindingId,binding);
+ await db.query(`INSERT INTO "CourseAppointment" SELECT (jsonb_populate_record(NULL::"CourseAppointment",to_jsonb(a)||jsonb_build_object('objectId','page-'||i,'id',900000+i))).* FROM "CourseAppointment" a CROSS JOIN generate_series(1,25) i WHERE a."objectId"=$1`,[a.objectId]);
+ await db.query(`INSERT INTO "CommonModel" SELECT (jsonb_populate_record(NULL::"CommonModel",to_jsonb(c)||jsonb_build_object('objectId','page-common-'||i,'itemId',900000+i,'generalId',910000+i))).* FROM "CommonModel" c CROSS JOIN generate_series(1,25) i WHERE c."objectId"=$1`,[a.common]);
+ const first=await context.appCompanionOverview({source:'database',page:1,pageSize:20},teacher),second=await context.appCompanionOverview({source:'database',page:2,pageSize:20},teacher);
+ assert.equal(first.schedules.length,20);assert.ok(second.schedules.length>0);
+ assert.equal(new Set([...first.schedules,...second.schedules].map(r=>r.GeneralID)).size,first.schedules.length+second.schedules.length);
+ assert.ok([...first.schedules,...second.schedules].every(r=>!r.cancelled&&Number(r.pl)===30));
+ const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],admin={Psql:context.Psql};vm.createContext(admin);vm.runInContext(ops+'\n'+readFileSync(new URL('../cloud/lesson-workflow-ops.js',import.meta.url),'utf8'),admin);
+ admin.requireRole=()=>{};admin.companyIdOf=()=> 'co';
+ const trace=await admin.lessonFeedbackDetail({}, {targetId:a.common});assert.equal(trace.appointmentGeneralId,a.gid);assert.equal(trace.lessonCount,0);assert.equal(trace.creditState,'reserved');
+ admin.professionalScheduleData=async()=>({appointments:[{generalId:a.gid}]});admin.cachedLiveAppointmentRows=async()=>{throw Error('后台不应读取旧端');};
+ assert.equal((await admin.professionalScheduleDataLocal({}, {source:'database'})).appointments[0].generalId,a.gid);
+ const foreign={...teacher,get:k=>k==='company'?{id:'other'}:teacher.get(k)};
+ await assert.rejects(context.databaseAppointmentVersions({},foreign),/帐套/);
+});
+
+test('旧客户端状态写入和旧分步完课明确拒绝,不触发数据库扣费',async()=>{
+ const {default:vm}=await import('node:vm');
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const c={};vm.createContext(c);vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c);c.assertLessonTenant=()=>{};
+ for(const status of [11,20,30]) await assert.rejects(c.updateOrderStatus({status,orderId:1,content:'{}'},{}),/刷新课程详情/);
+ await assert.rejects(c.completeOrderWithFeedback({},{}),/一次结课/);
+});

+ 8 - 4
scripts/tests/member-enrollment.test.mjs

@@ -26,6 +26,8 @@ before(async()=>{
  INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
  INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
  await db.exec(sql);
+ const workflow=readFileSync(new URL('../sql/lesson-workflow.sql',import.meta.url),'utf8');
+ for(const name of ['xs_start_appointment_for_actor','xs_admin_lesson_correction'])await db.exec(workflow.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'[\\s\\S]*?END \\$\\$;'))[0]);
 });
 after(()=>db.close());
 test('总部入账、双方划拨、幂等与原请求校验',async()=>{
@@ -112,7 +114,7 @@ test('最后一节课的竞争提交仅一张预约成功,完课后重试原
  await query('SELECT xs_complete_appointment(\'co\',$1,\'teacher\')',[aid]);assert.equal(await balance('student'),0);
  const retry=await enroll('word','enroll-word-01');assert.equal(retry.count,1);
 });
-test('运营网关开课排课与教师网关完课联调,共用同一事务与分类快照',async()=>{
+test('运营网关开课排课、开课授权与管理员审计纠错共用课时快照',async()=>{
  const {default:vm}=await import('node:vm');const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  const c={Date,Psql:{query,one,oneOrNone:one,none:query}};vm.createContext(c);vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8'),c);
  c.companyIdOf=()=> 'co';c.requireRole=()=>{};c.teachingCategoryRows=async()=>vm.runInContext('TEACHING_CATEGORY_DEFAULTS',c);c.teachingRuleRows=async()=>c.defaultTeachingRules();c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
@@ -122,8 +124,8 @@ test('运营网关开课排课与教师网关完课联调,共用同一事务
  assert.equal(result.count,1);const bid=result.bindingIds[0];
  const saved=await c.saveEnrollmentSchedule(context,{reason:'联调排课',idempotencyKey:'integration-schedule'},{studentId:20,coachId:30,bindingId:bid,courseId:100,durationMinutes:60,deliveryMode:'online',date:new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),startTime:'12:00',recurrence:'once',occurrences:1});
  const aid=saved.targetIds[0],row=await one('SELECT a.*,c."generalId" FROM "CourseAppointment" a JOIN "CommonModel" c ON c."itemId"=a."id" AND c."modelId"=54 WHERE c."objectId"=$1',[aid]);assert.equal(row.courseCategoryKey,'trial');assert.equal(Number(row.teacherPaySnapshot),30);
- const app={Date,Psql:{query,one,oneOrNone:one,none:query},DEFAULT_COMPANY_ID:'co',number:Number,fail:(_,msg)=>{throw Error(msg);},memoryReviewsForOrder:async()=>({count:0,created:[]})};vm.createContext(app);vm.runInContext(readFileSync(new URL('../cloud/member-enrollment-app.js',import.meta.url),'utf8'),app);
- await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,10);await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,11);
+ const app={Date,Psql:{query,one,oneOrNone:one,none:query},DEFAULT_COMPANY_ID:'co',number:Number,ownLegacyId:()=>30,fail:(_,msg)=>{throw Error(msg);},memoryReviewsForOrder:async()=>({count:0,created:[]})};vm.createContext(app);vm.runInContext(readFileSync(new URL('../cloud/member-enrollment-app.js',import.meta.url),'utf8'),app);
+ await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,10);await assert.rejects(app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,11),/一次结课/);await query("SELECT xs_complete_appointment('co',$1,'teacher')",[aid]);
  assert.equal(await balance('student','UserExp'),0);const lesson=await one('SELECT * FROM "LessonRecord" WHERE "bindingId"=$1',[bid]);assert.equal(lesson.courseCategoryKey,'trial');
  c.appointmentPair=async()=>({common:{id:aid,get:key=>key==='company'?{objectId:'co'}:null}});
  const repeat=await c.completeAppointment(context,{targetId:aid,reason:'联调重试'});assert.equal(repeat.idempotent,true);assert.equal((await one('SELECT count(*)::int AS n FROM "UserExpHis" WHERE "transactionId"=$1',['complete:'+aid])).n,1);
@@ -142,7 +144,9 @@ test('已有唯一约束下重复迁移,冲突历史绑定保留进度并标
  await db.exec(`INSERT INTO "CourseTeachingProfile" VALUES ('co',100,'word',true);
  INSERT INTO "CourseBinding" ("objectId","company","id","yhid","kcid","yxx") VALUES ('history1','co',901,'20','100',5),('history2','co',902,'20','100',7);
  INSERT INTO "CommonModel" ("objectId","company","generalId","itemId","modelId","nodeId","tableName","status") VALUES ('hc1','co',901,901,58,100,'ZL_C_yhkcbd',99),('hc2','co',902,902,58,100,'ZL_C_yhkcbd',99);`);
- await db.exec(sql);await db.exec(sql);
+ await db.exec(sql);
+ const workflow=readFileSync(new URL('../sql/lesson-workflow.sql',import.meta.url),'utf8');
+ for(const name of ['xs_start_appointment_for_actor','xs_admin_lesson_correction'])await db.exec(workflow.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'[\\s\\S]*?END \\$\\$;'))[0]);await db.exec(sql);
  const history=await query('SELECT * FROM "CourseBinding" WHERE "objectId" IN (\'history1\',\'history2\') ORDER BY "objectId"');assert.deepEqual(history.map(r=>r.enrollmentStatus),['pending','pending']);assert.deepEqual(history.map(r=>Number(r.yxx)),[5,7]);
  assert.equal(Number((await one('SELECT "nodeId" FROM "CommonModel" WHERE "objectId"=\'hc1\'')).nodeId),28);
 });

+ 1 - 1
scripts/tests/payroll-integrity.test.mjs

@@ -10,7 +10,7 @@ async function command(op,target,req,id){return(await one('SELECT xs_payroll_com
 async function version(){return(await one('SELECT xs_payroll_source_version(\'co\') AS value')).value;}
 before(async()=>{
  const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n  });/)[1]+')');
- for(const [name,fields] of Object.entries(schemas)){const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);}
+ for(const [name,fields] of Object.entries(schemas)){if(name==='CommonModel')continue;const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);}
  await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz);
  CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
  CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff