Просмотр исходного кода

feat(members): add store credits and category-based enrollment

彭峰 1 месяц назад
Родитель
Сommit
d06de08eb1
31 измененных файлов с 890 добавлено и 72 удалено
  1. 44 0
      docs/migration/member-enrollment.md
  2. 3 1
      package.json
  3. 2 1
      projects/xiaoshu-admin/src/app/admin.models.ts
  4. 35 0
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts
  5. 37 0
      projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts
  6. 24 0
      projects/xiaoshu-admin/src/app/components/member-enrollment.component.ts
  7. 1 0
      projects/xiaoshu-admin/src/app/components/member-enrollment.scss
  8. 6 0
      projects/xiaoshu-admin/src/app/member-enrollment.models.ts
  9. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  10. 14 13
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  11. 2 0
      projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.html
  12. 3 2
      projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.ts
  13. 3 2
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html
  14. 8 11
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts
  15. 4 3
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  16. 2 2
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  17. 1 1
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  18. 6 13
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  19. 1 1
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  20. 2 1
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts
  21. 1 0
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  22. 1 1
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html
  23. 2 1
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts
  24. 6 0
      scripts/cloud/member-enrollment-app.js
  25. 91 0
      scripts/cloud/member-enrollment.js
  26. 22 17
      scripts/deploy-admin-functions.mjs
  27. 8 0
      scripts/member-enrollment-schema.mjs
  28. 43 0
      scripts/migrate-member-enrollment.mjs
  29. 351 0
      scripts/sql/member-enrollment.sql
  30. 18 0
      scripts/tests/member-enrollment-gateway.test.mjs
  31. 147 0
      scripts/tests/member-enrollment.test.mjs

+ 44 - 0
docs/migration/member-enrollment.md

@@ -0,0 +1,44 @@
+# 用户管理、门店课时与按分类开课
+
+本次将账号、课时、开课和预约写入统一的新数据库。旧同步网关不再接受这些业务的回写。完整路径为:总部给门店入账 → 创建学员 → 门店划拨 → 分类和内容开课 → 排课预留 → 完课扣除。管理员记账不代表在线收款或现金退款。
+
+## 使用入口
+
+- 会员管理新增成功后进入学员详情的“课时与流水”。未关联门店可以建档,划拨前必须关联有效门店。
+- 门店详情的“门店课时账户”供超级管理员入账;会员详情显示各账户余额、预留、可用量和业务流水,运营经理划拨或关联原业务单退回。
+- 会员详情“已开课程”先选择六个教学分类之一,再选完整课程内容。语法写作分类限制对应学阶。同一内容可以分别用于不同分类,内容数量从目录自动统计。
+- 排课选具体开课记录,显示分类及内容。课时和工资规则按每节上课日期、时长、线上线下保存快照;恢复预约保留原快照。
+- 用户端课程入口与教师预约按 bindingId 区分分类。词汇掌握数据继续沿用已有学员词汇进度;预约、学习记录与扣课可追溯至开课记录。
+
+## 数据与事务
+
+`scripts/sql/member-enrollment.sql` 安装数据库事务函数。`CreditTransaction` 保存总部入账、双边划拨和原单退回,双方流水及 SysLog 审计同事务提交。`EnrollmentCommand` 保存开课、排课请求和操作记录,按帐套及幂等键防重。相同键但不同业务内容拒绝。
+
+`CourseBinding` 使用 categoryKey、categoryName、enrollmentStatus;有效记录按帐套、学员、分类、内容唯一。CommonModel 使用模型 58、栏目 28、ZL_C_kcbd,内容节点写入 kcid。开课不扣减、不预留;至少一个有效授课规则的全部账户可用量必须足够。
+
+排课、改期、取消恢复、开始上课和完课使用数据库事务。帐套事务锁统一写入顺序,账户行锁保护余额和预留。旧资料保存若会覆盖已改变的余额,触发器拒绝并提示刷新。课时退回只能返还原划拨门店,受原单未退数及当前可用量约束。
+
+完课将预约、预留、四类课时流水及 LessonRecord 一次性提交;重试不重复扣费。App 复习计划仍沿用现有生成流程,事务失败会清理本次新建计划。教师通用内容编辑不可改写预约的课程或分类。
+
+## 迁移与发布顺序
+
+1. 本地执行 `npm run members:migrate -- --validate` 检查迁移文件;不连接服务器。
+2. 在已配置 `XIAOSHU_MASTER_KEY` 的发布环境执行 `npm run members:migrate`。默认只读,导出受限权限的 `tmp/member-enrollment-*/before.json` 和 `report.json`,记录 SQL 摘要。自定义环境可设置 XIAOSHU_PARSE_URL、XIAOSHU_PARSE_APP_ID。
+3. 核对完整数据库备份和迁移报告。报告包含分类缺失、孤立绑定、错误内容栏目、重复绑定、账户异常与重复预留。负余额、超额或重复预留会阻止 apply;历史流水不推断退款来源。
+4. 安排停止业务写入的发布窗口,执行 `npm run members:migrate -- --apply`。它先备份,再补 Parse schema,执行确定性修复和约束安装,最后清理临时迁移函数和临时账号。
+5. 部署运营与 App 云函数,再发布管理端和移动端构建。SQL schema 必须先于新网关。禁止重新启用旧账号、开课和预约同步。
+6. 用隔离测试门店和学员完成线上受控验收:总部入账、划拨、跨分类开同词表、各规则排课、取消恢复、开始完课、原单退回;核对账户、业务单、流水和工资快照。
+
+历史分类仅采用唯一、已确认的新分类映射;其他记录标记 pending,历史进度保留。错误模型栏目修为 28 / ZL_C_kcbd;缺少关联、分类冲突和重复绑定保留待核对,不删除或合并学习历史。历史预约快照不重算,无法唯一定位绑定的旧接口要求明确 bindingId。pending 记录需依据报告人工核对分类和预约后恢复有效状态。
+
+发布前应在服务器 PostgreSQL 的独立连接下验证最后一节课竞争、充值与完课竞争、退款与预留竞争。当前本地 PGlite 测试执行真实 SQL 事务并包含竞争请求,但 PGlite 单实例会串行调度,不能替代服务器多连接锁竞争验收。
+
+## 本地验证与线上边界
+
+- `npm run test:member-enrollment`:数据库事务、网关分类资格、旧接口歧义、按日期快照、账号精确查询、运营开课排课与教师完课的本地联调。
+- `npm run test:admin:ci`:管理端交互,含分类切换清空选择、余额不足禁选、充值重试幂等。
+- `npm run test:mobile:ci`:用户和教师课程入口、预约固定内容、学习记录及原有无预约学习流程。
+- `npm run test:review-rewards`:受影响的完课与工资奖励回归。
+- `npm run cloud:validate`、`npm run build`:云函数语法及管理端、移动端生产构建。
+
+本次工作环境没有 XIAOSHU_MASTER_KEY,未执行生产预检、数据修改、部署或真实账号联调。生产多连接事务验收与受控业务验收须在发布环境完成;本地验证结果不代表已经上线。

+ 3 - 1
package.json

@@ -43,7 +43,9 @@
     "test:mobile:ci": "ng test xiaoshu-mobile --no-watch --no-progress --browsers=ChromeHeadless",
     "test:admin:ci": "ng test xiaoshu-admin --no-watch --no-progress --browsers=ChromeHeadless",
     "test:review-rewards": "node --test scripts/tests/review-rewards.test.mjs",
-    "review-rewards:migrate": "node scripts/migrate-review-rewards.mjs"
+    "review-rewards:migrate": "node scripts/migrate-review-rewards.mjs",
+    "test:member-enrollment": "node --test scripts/tests/member-enrollment.test.mjs scripts/tests/member-enrollment-gateway.test.mjs",
+    "members:migrate": "node scripts/migrate-member-enrollment.mjs"
   },
   "private": true,
   "dependencies": {

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

@@ -560,6 +560,7 @@ export interface CoachPage extends OperationsPage<CoachSummary> {
 }
 
 export interface ScheduleAppointment extends Record<string, unknown> {
+  bindingId?: string;
   targetId: string;
   generalId: number;
   studentObjectId: string;
@@ -610,7 +611,7 @@ export interface ScheduleCalendarData {
   appointments: ScheduleAppointment[];
   unassigned: ScheduleAppointment[];
   members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string; liveOnly?: boolean; writeReady?: boolean; readOnly?: boolean; readOnlyReason?: string }>;
-  courses: Array<{ courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean; stageKey?: TeachingStageKey | ''; stageName?: string; categoryKey?: string; categoryName?: string; mappingConfirmed?: boolean; suggestedCategoryName?: string; supportedDurations?: number[] }>;
+  courses: Array<{ bindingId?: string; courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean; stageKey?: TeachingStageKey | ''; stageName?: string; categoryKey?: string; categoryName?: string; mappingConfirmed?: boolean; suggestedCategoryName?: string; supportedDurations?: number[] }>;
   sync?: OperationsSyncIndicator;
 }
 

+ 35 - 0
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -0,0 +1,35 @@
+import { CommonModule } from '@angular/common';
+import { Component, Input, Output, EventEmitter, OnChanges, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OperationsService } from '../operations.service';
+import { CreditAccounts, CreditTransaction } from '../member-enrollment.models';
+import { TeachingCreditAccount } from '../admin.models';
+@Component({selector:'app-member-credit-account',standalone:true,imports:[CommonModule,FormsModule],template:`
+<section class="panel"><h2>{{store?'门店课时账户':'课时充值与划拨'}}</h2>
+@if(error()){<p role="alert" class="error">{{error()}}</p>}@if(message()){<p role="status">{{message()}}</p>}
+@if(data();as value){
+<p>{{store?'总部入账后,可向所属学员划拨课时。':'充值来源:'+(value.storeName||'尚未关联门店')}}</p>
+<div class="accounts">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span></article>}</div>
+@if(value.canPost){<form (ngSubmit)="submit()">
+@if(!store){<label>业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='' "><option value="add">门店划拨充值</option><option value="deduct">退回原门店</option></select></label>}
+@if(direction==='deduct'&&!store){<label>原划拨单<select name="source" [(ngModel)]="sourceId" (ngModelChange)="selectSource($event)"><option value="">请选择原划拨记录</option>@for(t of refundable();track t.objectId){<option [value]="t.objectId">{{t.createdAt|date:'MM-dd HH:mm'}} · {{label(t.account)}} · 尚未退回 {{t.unreturned}}</option>}</select></label>}
+<label>课时类型<select name="account" [(ngModel)]="account" [disabled]="direction==='deduct'&&!store">@for(a of value.accounts;track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label>
+<label>数量<input name="amount" type="number" min="0.01" [step]="account==='UserPoint'?0.01:1" [(ngModel)]="amount" required /></label>
+<label>付款或记账方式<select name="method" [(ngModel)]="method" required><option value="">请选择</option><option>微信</option><option>支付宝</option><option>转账</option><option>线下收款</option><option>运营记账</option></select></label>
+<label class="wide">操作原因<input name="reason" [(ngModel)]="reason" minlength="2" maxlength="500" required /></label>
+<button type="submit" [disabled]="saving()||(!store&&!value.storeId&&direction==='add')">{{saving()?'处理中…':store?'确认总部入账':direction==='add'?'确认门店划拨':'确认退回原门店'}}</button></form>}
+<div class="scroll"><table><thead><tr><th>时间</th><th>业务</th><th>项目</th><th>数量</th><th>门店</th><th>学员</th><th>原因</th></tr></thead><tbody>@for(t of value.transactions;track t.objectId){<tr><td>{{t.createdAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{t.kind==='fund'?'总部入账':t.kind==='transfer'?'门店划拨':'退回门店'}}</td><td>{{label(t.account)}}</td><td>{{t.amount}}</td><td>{{t.storeId}}</td><td>{{t.studentId||'—'}}</td><td>{{t.reason}}</td></tr>}@empty{<tr><td colspan="7">暂无新业务流水;历史记录保留在课时流水中。</td></tr>}</tbody></table></div>
+}<button type="button" (click)="load()" [disabled]="saving()">刷新账户</button></section>`,styleUrls:['./member-enrollment.scss']})
+export class MemberCreditAccountComponent implements OnChanges {
+ @Input({required:true}) objectId='';@Input() store=false;@Output() changed=new EventEmitter<void>();
+ private operations=inject(OperationsService);data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);
+ account:TeachingCreditAccount='Purse';amount=0;direction='add';sourceId='';method='';reason='';private fingerprint='';private key='';
+ ngOnChanges(){void this.load();}
+ async load(){if(!this.objectId)return;try{this.data.set(await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId}));}catch(e){this.error.set(e instanceof Error?e.message:'账户加载失败');}}
+ label(account:string){return this.data()?.accounts.find(a=>a.account===account)?.label||account;}
+ refundable():CreditTransaction[]{return(this.data()?.transactions||[]).filter(t=>t.kind==='transfer'&&Number(t.unreturned)>0);}
+ selectSource(id:string){const t=this.refundable().find(t=>t.objectId===id);if(t)this.account=t.account;}
+ async submit(){if(this.saving())return;this.error.set('');this.message.set('');if(this.amount<=0||!this.method||this.reason.trim().length<2||(!this.store&&this.direction==='deduct'&&!this.sourceId)){this.error.set('请填写有效数量、方式、操作原因;退回必须选择原划拨单');return;}
+ const payload={account:this.account,amount:this.amount,direction:this.direction,sourceTransactionId:this.sourceId,payMethod:this.method},fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
+ this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,'',this.key);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.message.set('课时业务已完成,余额和流水已更新');await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'课时业务失败');}finally{this.saving.set(false);}}
+}

+ 37 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts

@@ -0,0 +1,37 @@
+import { TestBed } from '@angular/core/testing';
+import { MemberEnrollmentComponent } from './member-enrollment.component';
+import { MemberCreditAccountComponent } from './member-credit-account.component';
+import { OperationsService } from '../operations.service';
+import { EnrollmentOptions } from '../member-enrollment.models';
+
+describe('会员开课与课时操作', () => {
+ let operations: jasmine.SpyObj<OperationsService>;
+ const options = (categoryKey = 'word'): EnrollmentOptions => ({categories:[{key:'word',name:'单词课'},{key:'word_self_study',name:'单词自学课'}] as EnrollmentOptions['categories'],categoryKey,eligible:true,reason:'',rules:[],items:[{courseId:100,courseName:'七年级词表',stageKey:'middle',stageName:'初中',wordCount:100,active:true,alreadyOpened:false,eligible:true,reason:''}]});
+ beforeEach(() => {
+  operations = jasmine.createSpyObj('OperationsService', ['request','mutate']);
+  operations.request.and.resolveTo(options());operations.mutate.and.resolveTo({});
+  TestBed.configureTestingModule({imports:[MemberEnrollmentComponent,MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations}]});
+ });
+ it('切换分类清空待开内容,提交明确分类与内容 ID', async () => {
+  const fixture=TestBed.createComponent(MemberEnrollmentComponent),c=fixture.componentInstance;c.studentId=20;await c.load();fixture.detectChanges();
+  c.toggle(100);c.changeCategory('word_self_study');expect(c.selected()).toEqual([]);await fixture.whenStable();
+  operations.request.and.resolveTo(options('word_self_study'));await c.load();fixture.detectChanges();
+  const checkbox=fixture.nativeElement.querySelector('input[type=checkbox]') as HTMLInputElement;checkbox.click();fixture.detectChanges();await c.submit();
+  expect(operations.mutate.calls.mostRecent().args[3]).toEqual({studentId:20,categoryKey:'word_self_study',courseIds:[100]});
+  expect(c.message()).toContain('成功');
+ });
+ it('零余额和已开内容在界面禁选', async () => {
+  const value=options();value.eligible=false;value.reason='对应课时不足';value.items[0].eligible=false;operations.request.and.resolveTo(value);
+  const fixture=TestBed.createComponent(MemberEnrollmentComponent);fixture.componentInstance.studentId=20;await fixture.componentInstance.load();fixture.detectChanges();
+  expect((fixture.nativeElement.querySelector('input[type=checkbox]') as HTMLInputElement).disabled).toBeTrue();expect(fixture.nativeElement.textContent).toContain('对应课时不足');
+ });
+ it('网络失败后充值重试沿用幂等键,修改数量才更换', async () => {
+  const fixture=TestBed.createComponent(MemberCreditAccountComponent),c=fixture.componentInstance;c.objectId='student';c.amount=5;c.method='运营记账';c.reason='购买课时';operations.mutate.and.rejectWith(new Error('网络失败'));
+  await c.submit();const first=operations.mutate.calls.mostRecent().args[5];await c.submit();expect(operations.mutate.calls.mostRecent().args[5]).toBe(first);
+  c.amount=6;await c.submit();expect(operations.mutate.calls.mostRecent().args[5]).not.toBe(first);
+ });
+ it('退回必须关联原划拨单,自动使用原课时账户', async () => {
+  const fixture=TestBed.createComponent(MemberCreditAccountComponent),c=fixture.componentInstance;c.objectId='student';c.direction='deduct';c.amount=1;c.method='运营记账';c.reason='退回余量';
+  await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('原划拨单');
+ });
+});

+ 24 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.component.ts

@@ -0,0 +1,24 @@
+import { CommonModule } from '@angular/common';
+import { Component, Input, Output, EventEmitter, OnChanges, inject, signal, computed } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OperationsService } from '../operations.service';
+import { EnrollmentOptions } from '../member-enrollment.models';
+@Component({selector:'app-member-enrollment',standalone:true,imports:[CommonModule,FormsModule],template:`
+<section class="panel"><h2>按分类开课</h2><p>先选择课程分类,再选择内容。同一内容可分别用于不同分类;开课不扣课时。</p>
+@if(error()){<p class="error" role="alert">{{error()}}</p>}@if(message()){<p role="status">{{message()}}</p>}
+@if(loading()){<p role="status">正在加载分类内容…</p>}@if(error()){<button type="button" (click)="load()">重试加载</button>}
+@if(options();as value){<div class="form-grid"><label>课程分类<select [ngModel]="category" (ngModelChange)="changeCategory($event)" [disabled]="saving()">@for(c of value.categories;track c.key){<option [value]="c.key">{{c.name}}</option>}</select></label><label>搜索课程<input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="课程名称或 ID" /></label><label>学阶<select [ngModel]="stage()" (ngModelChange)="stage.set($event)"><option value="">全部</option><option value="primary">小学</option><option value="middle">初中</option><option value="high">高中</option><option value="other">其他</option></select></label></div>
+@if(value.reason){<p class="error">{{value.reason}}</p>}
+<div class="rules">@for(r of value.rules;track r.objectId){<span>{{r.durationMinutes}}分钟 · {{r.deliveryLabel}}:@for(c of r.creditCosts;track c.account){ {{c.label}} {{c.amount}} }({{r.affordable?'课时足够':'课时不足'}})</span>}</div>
+<div class="scroll"><table><thead><tr><th>选择</th><th>课程内容</th><th>学阶</th><th>单词数量</th><th>状态</th></tr></thead><tbody>@for(c of filtered();track c.courseId){<tr><td><input type="checkbox" [attr.aria-label]="'选择'+c.courseName" [checked]="selected().includes(c.courseId)" (change)="toggle(c.courseId)" [disabled]="saving()||loading()||!c.eligible||c.alreadyOpened" /></td><td>{{c.courseName}}<small>ID {{c.courseId}}</small></td><td>{{c.stageName}}</td><td>{{c.wordCount}}</td><td>{{c.alreadyOpened?'该分类已开课':c.reason||'可开课'}}</td></tr>}@empty{<tr><td colspan="5">该分类下暂无匹配内容,请检查课程目录和学阶。</td></tr>}</tbody></table></div>
+<label>操作原因<input [(ngModel)]="reason" maxlength="500" /></label><button type="button" (click)="submit()" [disabled]="saving()||loading()||!value.eligible||!selected().length">{{saving()?'正在开课…':'确认开课('+selected().length+'项)'}}</button>
+}</section>`,styleUrls:['./member-enrollment.scss']})
+export class MemberEnrollmentComponent implements OnChanges{
+ @Input({required:true}) studentId=0;@Output() changed=new EventEmitter<void>();private operations=inject(OperationsService);
+ options=signal<EnrollmentOptions|null>(null);selected=signal<number[]>([]);search=signal('');stage=signal('');error=signal('');message=signal('');saving=signal(false);loading=signal(false);category='word';reason='为会员开课';private serial=0;private key='';private fingerprint='';
+ filtered=computed(()=>(this.options()?.items||[]).filter(c=>(!this.stage()||c.stageKey===this.stage())&&`${c.courseName} ${c.courseId}`.toLowerCase().includes(this.search().trim().toLowerCase())));
+ ngOnChanges(){void this.load();}changeCategory(value:string){this.category=value;this.selected.set([]);this.stage.set('');this.message.set('');void this.load();}
+ toggle(id:number){this.selected.update(ids=>ids.includes(id)?ids.filter(x=>x!==id):[...ids,id]);}
+ async load(){if(!this.studentId)return;const serial=++this.serial;this.loading.set(true);try{const result=await this.operations.request<EnrollmentOptions>('ops/course-bindings/options',{studentId:this.studentId,categoryKey:this.category});if(serial===this.serial){this.options.set(result);this.error.set('');}}catch(e){if(serial===this.serial)this.error.set(e instanceof Error?e.message:'开课内容加载失败');}finally{if(serial===this.serial)this.loading.set(false);}}
+ async submit(){if(this.saving()||this.loading()||!this.selected().length)return;if(this.reason.trim().length<2){this.error.set('请填写操作原因');return;}const payload={studentId:this.studentId,categoryKey:this.category,courseIds:[...this.selected()].sort((a,b)=>a-b)},f=JSON.stringify([payload,this.reason]);if(f!==this.fingerprint){this.fingerprint=f;this.key=crypto.randomUUID();}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/course-bindings/save','',this.reason,payload,'',this.key);this.selected.set([]);this.fingerprint='';this.message.set('开课成功,可前往排课');await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'开课失败');}finally{this.saving.set(false);}}
+}

+ 1 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.scss

@@ -0,0 +1 @@
+:host{display:block;margin:16px 0;color:#213547}.panel{background:white;border:1px solid #dfe7ee;border-radius:14px;padding:20px}h2{margin:0 0 12px;font-size:20px}p{line-height:1.7;color:#586b7c}.error{color:#b3261e}.accounts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:18px 0}article{background:#f3f7fa;border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:8px}article b{font-size:26px;color:#186895}article span,small{font-size:12px;color:#647587}form,.form-grid{display:flex;flex-wrap:wrap;gap:14px;align-items:end}label{display:flex;flex-direction:column;gap:7px;margin:8px 0;font-size:14px}input,select,button{font:inherit;padding:10px;border:1px solid #cad6e0;border-radius:7px;max-width:100%;box-sizing:border-box}button{cursor:pointer;background:#166a96;color:white;margin:10px 0}button:disabled{opacity:.5;cursor:default}.wide{flex:1;min-width:200px}.scroll{overflow:auto;max-height:420px;margin:15px 0}table{border-collapse:collapse;width:100%;font-size:14px}td,th{padding:12px;text-align:left;border-bottom:1px solid #e1e8ee;white-space:nowrap}th{background:#f3f7fa;position:sticky;top:0}small{display:block}.rules{display:flex;flex-wrap:wrap;gap:8px;color:#526879;font-size:12px;margin:14px 0}.rules span{padding:6px;background:#f1f5f8;border-radius:6px}@media(max-width:700px){.accounts{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:14px}form label,.form-grid label{width:100%}}

+ 6 - 0
projects/xiaoshu-admin/src/app/member-enrollment.models.ts

@@ -0,0 +1,6 @@
+import { TeachingCourseCategory, TeachingRule, TeachingCreditAccount, TeachingStageKey } from './admin.models';
+export interface CreditAccount { account: TeachingCreditAccount; label: string; balance: number; reserved: number; available: number; }
+export interface CreditTransaction { objectId: string; kind: 'fund'|'transfer'|'refund'; account: TeachingCreditAccount; amount: number; storeId: number; studentId: number; reason: string; payMethod: string; createdAt: string; unreturned: number; balances: {storeBefore:number;storeAfter:number;studentBefore:number;studentAfter:number}; }
+export interface CreditAccounts { accounts: CreditAccount[]; transactions: CreditTransaction[]; storeId: number; storeName: string; canPost: boolean; }
+export interface EnrollmentContent { courseId:number;courseName:string;stageKey:TeachingStageKey|'';stageName:string;wordCount:number;active:boolean;alreadyOpened:boolean;eligible:boolean;reason:string; }
+export interface EnrollmentOptions { categories: TeachingCourseCategory[]; categoryKey:string;eligible:boolean;reason:string;rules:Array<TeachingRule & {affordable:boolean}>;items:EnrollmentContent[]; }

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

@@ -113,8 +113,8 @@
         </span>
         @if(activeSearch()==='course'){
           <span class="search-results" id="course-search-results" role="listbox">
-            @for(course of filteredCourses();track course.courseId){
-              <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId" (click)="chooseCourse(course)"><span><strong>{{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?(course.stageName+' · '+course.categoryName):'待处理课程分类'}}</small></span></button>
+            @for(course of filteredCourses();track course.bindingId||course.courseId){
+              <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId && bindingId()===(course.bindingId||'')" (click)="chooseCourse(course)"><span><strong>{{course.categoryName}} · {{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?(course.stageName+' · '+course.categoryName):'待处理课程分类'}}</small></span></button>
             } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
           </span>
         }

+ 14 - 13
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -1,3 +1,4 @@
+import { ActivatedRoute } from '@angular/router';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -117,7 +118,7 @@ export function scheduleCoachBusy(
 @Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit {
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
-  readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);
+  readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService);
   readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0);
@@ -126,11 +127,11 @@ export class OperationsScheduleComponent implements OnInit {
   readonly unassignedCount=computed(()=>this.visibleAppointments().filter(item=>!item.coachId||item.coachAccountMissing).length);
   readonly hasScheduleFilters=computed(()=>Boolean(this.scheduleSearch().trim())||this.statusFilter()!=='all');
   readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
-  readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>scheduleCourseAvailable(course,this.studentId())&&matchesScheduleSearch(this.courseSearch(),[course.courseName,course.courseId])).slice(0,12));
-  readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId())||null);
+  readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>scheduleCourseAvailable(course,this.studentId())&&matchesScheduleSearch(this.courseSearch(),[course.courseName,course.categoryName,course.courseId])).slice(0,12));
+  readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
   readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).sort((a,b)=>this.mode()==='time'?(Number(this.coachBusyAtSelectedTime(a.userId))-Number(this.coachBusyAtSelectedTime(b.userId)))||(Number(b.availabilityConfigured)-Number(a.availabilityConfigured))||a.displayName.localeCompare(b.displayName,'zh-CN'):a.displayName.localeCompare(b.displayName,'zh-CN')).slice(0,12));
   readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
-  private initialRange=true;
+  private initialRange=true; private saveKey=''; private saveFingerprint='';
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
   previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
   canSavePreview():boolean{return Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
@@ -138,7 +139,7 @@ export class OperationsScheduleComponent implements OnInit {
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
   rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{return scheduleRowActions(item);}
-  ngOnInit():void{void this.load();}
+  ngOnInit():void{void this.load().then(()=>{const id=Number(this.route.snapshot.queryParamMap.get('studentId'));const member=this.data()?.members.find(m=>m.userId===id);if(member){this.openCreate();this.chooseMember(member);}});}
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}
   async load():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range();this.data.set(await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter()));this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}
   move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
@@ -146,18 +147,18 @@ export class OperationsScheduleComponent implements OnInit {
   async applyScheduleFilters():Promise<void>{if(this.searching())return;this.searching.set(true);this.scheduleSearch.set(this.scheduleSearchInput().trim());this.statusFilter.set(this.statusFilterInput());await new Promise(resolve=>setTimeout(resolve,220));this.searching.set(false);}
   clearScheduleFilters():void{this.scheduleSearchInput.set('');this.statusFilterInput.set('all');this.scheduleSearch.set('');this.statusFilter.set('all');this.searching.set(false);}
   visibleCoachCount(coachId:number):number{return this.visibleAppointments().filter(item=>item.coachId===coachId).length;}
-  openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
-  openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);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));this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
+  openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
+  openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);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));this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
   async viewFeedback(item:ScheduleAppointment):Promise<void>{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);}}
   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);}
-  updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.courseSearch.set('');this.preview.set(null);this.openSearch('member');}
-  updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.openSearch('course');}
+  updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.preview.set(null);this.openSearch('member');}
+  updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.bindingId.set('');this.openSearch('course');}
   updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
-  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId());this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);if(selectedCourse&&!scheduleCourseAvailable(selectedCourse,member.userId)){this.courseId.set(0);this.courseSearch.set('');}this.activeSearch.set(null);this.preview.set(null);}
-  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该课程尚未完成标准分类映射,或数据库课程大类缺失,需要先在工资结算的“排课与工资标准”中处理。');}
+  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId()&&course.bindingId===this.bindingId());this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);if(selectedCourse&&!scheduleCourseAvailable(selectedCourse,member.userId)){this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');}this.activeSearch.set(null);this.preview.set(null);}
+  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.bindingId.set(course.bindingId||'');this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该开课分类缺少有效课时规则,请在排课与工资标准中配置。');}
   chooseCoach(coach:ScheduleCalendarData['coaches'][number]):void{this.coachId.set(coach.userId);this.coachSearch.set(`${coach.displayName}${coach.mobileMasked?` · ${coach.mobileMasked}`:''}`);this.activeSearch.set(null);this.preview.set(null);}
   clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
   updateDraftDate(value:string):void{if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return;this.date.set(value);this.preview.set(null);void this.load();}
@@ -167,8 +168,8 @@ export class OperationsScheduleComponent implements OnInit {
   coachBusyAtSelectedTime(coachId:number):boolean{return scheduleCoachBusy(this.data()?.appointments||[],coachId,`${this.date()}T${this.startTime()}:00+08:00`,this.durationMinutes(),this.editing()?.targetId||'');}
   coachCandidateHint(coach:ScheduleCalendarData['coaches'][number]):string{return this.coachBusyAtSelectedTime(coach.userId)?'该时段已有排课':coach.availabilityConfigured?'该时段可继续预检':'未设置可用时间';}
   memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
-  payload():Record<string,unknown>{return{studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),durationMinutes:this.durationMinutes(),deliveryMethod:this.deliveryMethod(),deliveryMode:this.deliveryMethod()==='线下'?'offline':'online',date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
+  payload():Record<string,unknown>{return{bindingId:this.bindingId(),studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),durationMinutes:this.durationMinutes(),deliveryMethod:this.deliveryMethod(),deliveryMode:this.deliveryMethod()==='线下'?'offline':'online',date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
   async preflight():Promise<void>{this.error.set('');if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能排课;请先补齐该会员的迁移标识');return;}try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
-  async save():Promise<void>{if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'');this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
+  async save():Promise<void>{if(this.saving())return;if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const fingerprint=JSON.stringify([editing?.targetId||'',this.payload(),skipConflicts,this.reason()]);if(this.saveFingerprint!==fingerprint){this.saveFingerprint=fingerprint;this.saveKey=crypto.randomUUID();}const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'',this.saveKey);this.saveFingerprint='';this.saveKey='';this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
   async action(item:ScheduleAppointment,operation:string):Promise<void>{if(item.readOnly){this.message.set('实时业务数据暂不支持从新后台修改,请在原业务端完成操作。');return;}const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
 }

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.html

@@ -71,3 +71,5 @@
     }
   </section>
 }
+
+<app-member-credit-account [objectId]="objectId" [store]="true" />

+ 3 - 2
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.ts

@@ -1,3 +1,4 @@
+import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -14,7 +15,7 @@ const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/S
 @Component({
   selector: 'app-operations-store-detail',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [MemberCreditAccountComponent,CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-store-detail.component.html',
   styleUrls: ['./operations-professional.scss', './operations-store-detail.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -49,7 +50,7 @@ export class OperationsStoreDetailComponent implements OnInit {
   });
 
   private readonly operations = inject(OperationsService);
-  private readonly objectId = inject(ActivatedRoute).snapshot.paramMap.get('objectId') || '';
+  readonly objectId = inject(ActivatedRoute).snapshot.paramMap.get('objectId') || '';
 
   ngOnInit(): void { void this.load(); }
 

Разница между файлами не показана из-за своего большого размера
+ 3 - 2
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html


+ 8 - 11
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -1,16 +1,18 @@
+import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
+import { MemberEnrollmentComponent } from '../components/member-enrollment.component';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, LucideAngularModule, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards } from 'lucide-angular';
-import { MemberAgentOption, OperationsUserDetail, ScheduleCalendarData } from '../admin.models';
+import { MemberAgentOption, OperationsUserDetail } from '../admin.models';
 import { OperationsListKind, OperationsService } from '../operations.service';
 
 type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'reports' | 'balances' | 'audit';
 
 @Component({
   selector: 'app-operations-user-detail', standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-user-detail.component.html', styleUrl: './operations-user-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
@@ -18,14 +20,13 @@ export class OperationsUserDetailComponent implements OnInit {
   readonly user = signal<OperationsUserDetail | null>(null); readonly tab = signal<MemberTab>('overview'); readonly related = signal<Record<MemberTab, Record<string, unknown>[]>>({ overview: [], courses: [], appointments: [], lessons: [], reports: [], balances: [], audit: [] });
   readonly notes = signal<Record<string, unknown>[]>([]); readonly loading = signal(true); readonly sectionLoading = signal(false); readonly saving = signal(false); readonly error = signal(''); readonly message = signal(''); readonly reason = signal('');
   readonly editName = signal(''); readonly editNickname = signal(''); readonly editMobile = signal(''); readonly editEmail = signal(''); readonly editGender = signal(''); readonly editGrade = signal(''); readonly parentUserId = signal(0); readonly agentSearch = signal('');
-  readonly balanceType = signal(1); readonly balanceDirection = signal<'add' | 'deduct'>('add'); readonly balanceAmount = signal(0); readonly payMethod = signal(''); readonly resetPassword = signal(''); readonly noteContent = signal(''); readonly revealMobile = signal(false);
-  readonly courseId = signal(0); readonly courseOpenedCount = signal(1); readonly courseSearch = signal(''); readonly courseOptions = signal<ScheduleCalendarData['courses']>([]);
+  readonly resetPassword = signal(''); readonly noteContent = signal(''); readonly revealMobile = signal(false);
   readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
   private readonly route = inject(ActivatedRoute); private readonly operations = inject(OperationsService); private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
   readonly filteredAgents = computed(() => { const keyword = this.agentSearch().trim().toLowerCase(); return (this.user()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
-  readonly filteredCourses = computed(() => { const keyword = this.courseSearch().trim().toLowerCase(); return this.courseOptions().filter(course => !keyword || `${course.courseName} ${course.courseId}`.toLowerCase().includes(keyword)).slice(0, 30); });
 
-  ngOnInit(): void { void this.load(); }
+  ngOnInit(): void { void this.load().then(() => { const tab=this.route.snapshot.queryParamMap.get('tab') as MemberTab; if(['courses','balances'].includes(tab))void this.selectTab(tab); }); }
+  async refreshBusiness(): Promise<void>{await this.load();await this.loadSection();}
   async load(): Promise<void> {
     this.loading.set(true); this.error.set('');
     try { const user = await this.operations.userDetail(this.objectId); this.user.set(user); this.editName.set(user.displayName); this.editNickname.set(user.nickname || ''); this.editMobile.set(user.mobile || user.mobileMasked); this.editEmail.set(user.email || ''); this.editGender.set(user.gender || ''); this.editGrade.set(user.grade || ''); this.parentUserId.set(user.parentUserId); this.agentSearch.set(user.parentDisplayName || ''); this.revealMobile.set(Boolean(user.mobile)); }
@@ -39,8 +40,7 @@ export class OperationsUserDetailComponent implements OnInit {
     try {
       if (tab === 'audit') { const [notes, audit] = await Promise.all([this.operations.memberRows('ops/users/notes', user.objectId), this.operations.memberRows('ops/users/audit', user.objectId)]); this.notes.set(notes.items); this.related.update(value => ({ ...value, audit: audit.items })); }
       else if (tab === 'reports') { const page = await this.operations.learningReports(1, 100, '', { studentObjectId: user.objectId }, false); this.related.update(value => ({ ...value, reports: page.items })); }
-      else { const kind = this.kindFor(tab); if (kind) { const page = await this.operations.list(kind, 1, 100, String(user.userId)); this.related.update(value => ({ ...value, [tab]: page.items })); } }
-      if (tab === 'courses' && user.permissions.canEdit && !this.courseOptions().length) { const today = new Date().toISOString().slice(0, 10); const schedule = await this.operations.schedule(today, today); this.courseOptions.set(schedule.courses); }
+      else { const kind = this.kindFor(tab); if (kind) { const page = await this.operations.request<{items:Record<string,unknown>[]}>(`ops/${kind}/list`, {page:1,pageSize:100,studentId:user.userId}); this.related.update(value => ({ ...value, [tab]: page.items })); } }
     } catch (error) { this.error.set(error instanceof Error ? error.message : '业务记录加载失败'); }
     finally { this.sectionLoading.set(false); }
   }
@@ -52,13 +52,10 @@ export class OperationsUserDetailComponent implements OnInit {
   }
   selectAgent(agent: MemberAgentOption): void { this.parentUserId.set(agent.userId); this.agentSearch.set(agent.displayName); }
   clearAgent(): void { this.parentUserId.set(0); this.agentSearch.set(''); }
-  selectCourse(course: ScheduleCalendarData['courses'][number]): void { this.courseId.set(course.courseId); this.courseSearch.set(course.courseName); }
   async saveProfile(): Promise<void> { if (await this.run('ops/users/update-profile', { displayName: this.editName(), nickname: this.editNickname(), mobile: this.revealMobile() ? this.editMobile() : '', email: this.editEmail(), gender: this.editGender(), grade: this.editGrade(), parentUserId: this.parentUserId() }, '会员资料已更新')) await this.load(); }
   async changeStatus(): Promise<void> { const user = this.user(); if (user && await this.run('ops/users/change-status', { disabled: user.status !== 'disabled' }, user.status === 'disabled' ? '账号已恢复' : '账号已停用')) await this.load(); }
-  async adjustBalance(): Promise<void> { if (!this.payMethod()) { this.error.set('请选择付款或调整方式'); return; } if (await this.run('ops/users/adjust-balance', { type: this.balanceType(), direction: this.balanceDirection(), amount: this.balanceAmount(), payMethod: this.payMethod() }, '课时已调整并写入流水')) { this.balanceAmount.set(0); await this.load(); await this.loadSection('balances'); } }
   async resetMemberPassword(): Promise<void> { if (this.resetPassword().length < 8) { this.error.set('临时密码至少 8 位'); return; } if (await this.run('ops/users/reset-password', { password: this.resetPassword() }, '临时密码已更新,旧会话已退出')) { this.resetPassword.set(''); await this.load(); } }
   async saveNote(): Promise<void> { if (!this.noteContent().trim()) { this.error.set('请输入跟进备注'); return; } if (await this.run('ops/users/save-note', { content: this.noteContent() }, '跟进备注已保存')) { this.noteContent.set(''); await this.loadSection('audit'); } }
-  async bindCourse(): Promise<void> { const user = this.user(); if (!user || !this.courseId()) { this.error.set('请选择课程'); return; } if (await this.run('ops/course-bindings/save', { studentId: user.userId, courseId: this.courseId(), openedCount: this.courseOpenedCount() }, '课程已绑定')) { this.courseId.set(0); this.courseSearch.set(''); await this.load(); await this.loadSection('courses'); } }
   async toggleCourse(row: Record<string, unknown>): Promise<void> { const recycled = Number(row['status']) === -2; if (await this.run(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['targetId'] || ''), String(row['updatedAt'] || ''))) await this.loadSection('courses'); }
   async reveal(): Promise<void> { const user = this.user(); if (!user) return; this.error.set(''); try { const result = await this.operations.mutate<{ mobile: string }>('ops/users/reveal-mobile', user.objectId, '运营核对会员联系方式'); this.user.set({ ...user, mobile: result.mobile }); this.editMobile.set(result.mobile); this.revealMobile.set(true); } catch (error) { this.error.set(error instanceof Error ? error.message : '手机号查看失败'); } }
 }

+ 4 - 3
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -1,7 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { RouterLink } from '@angular/router';
+import { Router, RouterLink } from '@angular/router';
 import { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, LucideAngularModule, Plus, RefreshCw, Search, Upload, UserPlus, Users, X } from 'lucide-angular';
 import { read, utils, writeFile } from 'xlsx';
 import { MemberAgentOption, MemberImportPreview, MemberListSort, MemberPage } from '../admin.models';
@@ -23,7 +23,7 @@ export class OperationsUsersComponent implements OnInit {
   readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createPassword = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
   readonly importOpen = signal(false); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]); readonly importPreview = signal<MemberImportPreview | null>(null); readonly importReason = signal('批量导入会员'); readonly importing = signal(false); readonly importIdempotencyKey = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, Plus, RefreshCw, Search, Upload, UserPlus, Users, X };
-  private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService);
+  private readonly router = inject(Router); private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService);
 
   readonly canCreate = computed(() => this.sessions.session()?.operationsRole !== 'ops-auditor');
   readonly canBulkImport = computed(() => this.sessions.session()?.operationsRole === 'ops-manager');
@@ -111,9 +111,10 @@ export class OperationsUsersComponent implements OnInit {
   }
 
   async createMember(): Promise<void> {
+    if (this.saving()) return;
     if (!this.createName().trim() || !/^1\d{10}$/.test(this.createMobile()) || this.createPassword().length < 8 || this.createReason().trim().length < 2) { this.error.set('请填写姓名、有效手机号、至少 8 位临时密码和操作原因'); return; }
     this.saving.set(true); this.error.set('');
-    try { await this.operations.mutate('ops/users/create', '', this.createReason(), { displayName: this.createName(), nickname: this.createNickname() || this.createName(), mobile: this.createMobile(), username: this.createMobile(), gender: this.createGender(), grade: this.createGrade(), parentUserId: this.createParentUserId(), password: this.createPassword() }); this.createOpen.set(false); this.message.set('会员已新增,首次登录需要修改临时密码'); await this.load(1, true); }
+    try { const created=await this.operations.mutate<{objectId:string}>('ops/users/create', '', this.createReason(), { displayName: this.createName(), nickname: this.createNickname() || this.createName(), mobile: this.createMobile(), username: this.createMobile(), gender: this.createGender(), grade: this.createGrade(), parentUserId: this.createParentUserId(), password: this.createPassword() }); this.createOpen.set(false); this.message.set('会员已新增,请继续充值课时'); await this.router.navigate(['/admin/members',created.objectId],{queryParams:{tab:'balances'}}); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '新增会员失败'); }
     finally { this.saving.set(false); }
   }

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

@@ -722,8 +722,8 @@ describe('home learning modules', () => {
     expect(api.lastAppointmentCourseParams['myfield']).toBe('yhid=100');
     expect(api.lastNodeParams['userId']).toBe(100);
     const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement;
-    expect(courseSelect.value).toBe('801');
-    expect(courseSelect.selectedOptions[0]?.textContent).toContain('七年级上册');
+    expect(courseSelect).toBeNull();
+    expect(harness.routeNativeElement?.querySelector('.course-section')?.textContent).toContain('七年级上册');
     expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
     const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
     expect(unitButtons?.[0].textContent).toContain('已学 5%');

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

@@ -21,7 +21,7 @@
 
     @if (!isStudentView && status() <= 10 && !isWriting) {
       <section class="course-section">
-        <label><span>本次课程</span><select [(ngModel)]="selectedCourse" (change)="chooseCourse()"><option value="">请选择课程</option>@for (course of courses(); track $index) { <option [value]="course['kcid']">{{ course['Title'] }}</option> }</select></label>
+        <label><span>本次课程</span><strong>{{order()['courseCategoryName'] || ''}} · {{field('course')}}</strong><small>如需改课,请由管理员调整排课</small></label>
         <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,长按标记功能在 Web 中改为右侧按钮</span></div>
         <div class="unit-grid">
           @for (unit of units(); track $index) {

+ 6 - 13
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -131,16 +131,7 @@ export class AppointmentPageComponent implements OnInit {
     }
   }
 
-  chooseCourse(): void {
-    const selected = this.courses().find((course) => String(course['kcid'] ?? course['kcId']) === this.selectedCourse);
-    if (!selected) return;
-    this.order.update((order) => ({ ...order, kcid: this.selectedCourse, kcmc: selected['Title'] }));
-    const content = { GeneralID: this.orderId, UpDateTime: this.dateTime() };
-    this.api.post('content_update', { content: JSON.stringify(content), addon: JSON.stringify({ kcid: this.selectedCourse }) }).subscribe({
-      next: () => this.loadUnits(),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
+  chooseCourse(): void { this.platform.toast('如需改课,请由管理员调整排课', 'error'); }
 
   toggleUnit(unit: SourceItem): void {
     const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
@@ -381,7 +372,8 @@ export class AppointmentPageComponent implements OnInit {
     const targetField = this.isStudentView ? `yyds=${this.orderId}|xymz=${user?.userId}` : `yyds=${this.orderId}|jsmz=${user?.userId}`;
     this.api.get<unknown>('content_list', { ModelID: 59, psize: 2, myfield2: targetField }).subscribe({
       next: ({ result }) => {
-        const record = this.findList(result)[0] ?? {};
+        const records=this.findList(result),bindingId=String(this.order()['bindingId']||'');
+        const record = (bindingId?records.find(row=>String(row['bindingId']||row['id'])===bindingId):records.length===1?records[0]:undefined) ?? {};
         this.practiceRecord.set(record);
         this.coachComment = String(record['pldp'] ?? this.coachComment);
         this.studentScore = Number(record['pf'] ?? this.studentScore);
@@ -420,7 +412,7 @@ export class AppointmentPageComponent implements OnInit {
       this.platform.toast('预约记录缺少学员 ID,暂时无法加载学习单元', 'error');
       return;
     }
-    this.api.get<unknown>('node_list', { pid: this.selectedCourse, ifunit: 1, userId: studentId }).subscribe({
+    this.api.get<unknown>('node_list', { pid: this.selectedCourse, bindingId: this.order()['bindingId'] || '', ifunit: 1, userId: studentId }).subscribe({
       next: ({ result }) => {
         this.units.set(this.findList(result));
         this.loadUnitProgress();
@@ -434,7 +426,8 @@ export class AppointmentPageComponent implements OnInit {
     if (!studentId) return;
     this.api.post<unknown>('content_list', { ModelID: 58, myfield2: `yhid=${studentId}|kcid=${this.selectedCourse}` }).subscribe({
       next: ({ result }) => {
-        const record = this.findList(result)[0] ?? {};
+        const records=this.findList(result),bindingId=String(this.order()['bindingId']||'');
+        const record = (bindingId?records.find(row=>String(row['bindingId']||row['id'])===bindingId):records.length===1?records[0]:undefined) ?? {};
         this.bindingRecord.set(record);
         this.completedUnits.set(new Set(Object.keys(this.parseRecord(record['syjd']))));
       },

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

@@ -6,7 +6,7 @@
       <section class="course-band">
         <header><lucide-icon [img]="icons.BookOpen" [size]="21" /><h2>我的学习内容</h2><span>{{ courses().length }} 项</span></header>
         @if (courses().length) {
-          <ul class="course-list">@for (item of courses(); track courseBindingId(item) || courseId(item) || $index) { <li><button type="button" [class.readonly]="!canStartPointReading" [disabled]="!canOpenCourse(item)" (click)="openCourse(item)"><span><strong>{{ courseTitle(item) }}</strong><small>{{ courseHint(item) }}</small></span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></li> }</ul>
+          <ul class="course-list">@for (item of courses(); track courseBindingId(item) || courseId(item) || $index) { <li><button type="button" [class.readonly]="!canStartPointReading" [disabled]="!canOpenCourse(item)" (click)="openCourse(item)"><span><strong>{{ courseCategory(item) ? courseCategory(item)+' · ' : '' }}{{ courseTitle(item) }}</strong><small>{{ courseHint(item) }}</small></span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></li> }</ul>
         } @else { <p class="band-empty">{{ loading() ? '正在读取学习内容…' : '暂未分配学习内容,请联系老师' }}</p> }
       </section>
     }

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -151,6 +151,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return Array.from(grouped, ([date, items]) => ({ date, items }));
   }
 
+  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作',middle_writing:'初中语法写作',high_writing:'高中语法写作',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const sourceTitle = String(item['Title'] ?? '').trim();
     if (sourceTitle.includes('@')) {
@@ -171,7 +172,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   courseBindingId(item: Item): string {
-    return String(item['id'] ?? item['bindingId'] ?? item['bindingObjectId'] ?? '');
+    return String(item['bindingId'] ?? item['id'] ?? item['bindingObjectId'] ?? '');
   }
 
   canOpenCourse(item: Item): boolean {

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1513,6 +1513,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       xxqs: JSON.stringify(xxqs),
       fxrl: '',
       createReviewPlan: false,
+      bindingId: this.query('bindingId') || undefined,
     };
     this.submitting.set(true);
     this.persistWordsAsLearned(allWords, owner).pipe(

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -29,7 +29,7 @@
         <header><div><span id="current-learning-title">正在学习</span><strong>{{ canStartPointReading ? '继续选词学习' : '我的学习内容' }}</strong></div><span>{{ courses().length }} 项内容</span></header>
         <div class="current-learning-list">
           @for (course of visibleCourses(); track $index) {
-            <button type="button" [class.readonly]="!canStartPointReading" [disabled]="!canOpenCourse(course)" (click)="openCourse(course)"><i><lucide-icon [img]="icons.BookOpen" [size]="19" /></i><span><strong>{{ courseTitle(course) }}</strong><small>{{ courseHint(course) }}</small></span><lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></button>
+            <button type="button" [class.readonly]="!canStartPointReading" [disabled]="!canOpenCourse(course)" (click)="openCourse(course)"><i><lucide-icon [img]="icons.BookOpen" [size]="19" /></i><span><strong>{{ courseCategory(course) }} · {{ courseTitle(course) }}</strong><small>{{ courseHint(course) }}</small></span><lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></button>
           }
         </div>
         @if (courses().length > 3) { <button type="button" class="current-learning-toggle" [class.expanded]="coursesExpanded()" [attr.aria-expanded]="coursesExpanded()" (click)="toggleCoursesExpanded()"><span>{{ coursesExpanded() ? '收起学习内容' : '展开剩余 ' + hiddenCourseCount() + ' 项' }}</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> }

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -237,13 +237,14 @@ export class StudyRecordsPageComponent implements OnInit {
   }
 
   courseBindingId(item: Item): string {
-    return String(item['id'] ?? item['bindingId'] ?? item['bindingObjectId'] ?? '');
+    return String(item['bindingId'] ?? item['id'] ?? item['bindingObjectId'] ?? '');
   }
 
   canOpenCourse(item: Item): boolean {
     return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
   }
 
+  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作',middle_writing:'初中语法写作',high_writing:'高中语法写作',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const title = String(item['Title2'] ?? item['Title'] ?? '课程内容');
     if (item['Title2'] || !title.includes('@')) return title;

+ 6 - 0
scripts/cloud/member-enrollment-app.js

@@ -0,0 +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));}
+ return{GeneralID:orderId,status,previousStatus:number(order.dszt)};
+}

+ 91 - 0
scripts/cloud/member-enrollment.js

@@ -0,0 +1,91 @@
+// Appended to the operations gateway; all writes remain behind its authentication.
+async function enrollmentSql(sql, args) {
+  try { return await Psql.one(sql,args); }
+  catch(error) { if(/does not exist/i.test(text(error.message))) fail(503,'用户开课数据迁移尚未完成'); fail(409,text(error.message).replace(/^.*ERROR:\s*/,'')); }
+}
+async function creditAccounts(context,input,store=false) {
+  const target=store?await scopedStoreObject(context,{...input,targetId:input.objectId||input.targetId}):await memberTarget(context,input,text(input.objectId||input.targetId));
+  const uid=number(target.get('legacyUserId')),co=pointerId(target.get('company')),legacy=target.get('legacyUserData')||{},reserved=await reservedCreditsFor(context,input,uid);
+  const accounts=Object.entries(TEACHING_CREDIT_LABELS).map(([account,label])=>({account,label,balance:number(legacy[account]),reserved:number(reserved[account]),available:number(legacy[account])-number(reserved[account])}));
+  const transactions=await Psql.query('SELECT t."objectId",t."kind",t."account",t."amount",t."storeId",t."studentId",t."sourceTransactionId",t."operatorId",t."reason",t."payMethod",t."balances",t."createdAt",t."amount"-COALESCE((SELECT SUM(r."amount") FROM "CreditTransaction" r WHERE r."company"=t."company" AND r."sourceTransactionId"=t."objectId" AND r."kind"=\'refund\'),0) AS "unreturned" FROM "CreditTransaction" t WHERE t."company"=$1 AND '+(store?'t."storeId"':'t."studentId"')+'=$2 ORDER BY t."createdAt" DESC LIMIT 200',[co,uid]);
+  const parent=store?null:await userByLegacyId(context,input,number(legacy.ParentUserID),2);
+  return{accounts,transactions,storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
+}
+async function postCredits(context,input,payload,store=false) {
+  if(store)requireSuperAdmin(context);else requireRole(context,['ops-manager']);
+  const target=store?await scopedStoreObject(context,input):await memberTarget(context,input,text(input.targetId));
+  const account=text(payload.account)||({1:'Purse',2:'SilverCoin',3:'UserExp',4:'UserPoint'})[number(payload.type)],kind=store?'fund':payload.direction==='deduct'?'refund':'transfer';
+  const result=await enrollmentSql('SELECT xs_credit_post($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) AS result',[pointerId(target.get('company')),context.current.id,kind,target.id,account,number(payload.amount),text(payload.sourceTransactionId),reasonOf(input),text(payload.payMethod),text(input.idempotencyKey)]);
+  const clean={...result.result};delete clean.request;return clean;
+}
+async function enrollmentCatalog(context,input) {
+  const co=companyIdOf(context,input);
+  // Course roots are direct children of the legacy academic-stage nodes, plus explicitly mapped content roots.
+  const rows=await Psql.query(`WITH RECURSIVE roots AS (
+    SELECT n.* FROM "Node" n WHERE n."company"=$1 AND (n."parentId" IN (8,9,10,11,12) OR EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p."company"=n."company" AND p."courseId"=n."nodeId"))) ,
+    descendants AS (SELECT "nodeId" AS root,"nodeId",ARRAY["nodeId"] AS visited FROM roots UNION ALL SELECT d.root,n."nodeId",d.visited||n."nodeId" FROM descendants d JOIN "Node" n ON n."company"=$1 AND n."parentId"=d."nodeId" WHERE NOT n."nodeId"=ANY(d.visited) AND COALESCE(n."zstatus",99)=99),
+    counts AS (SELECT d.root,COUNT(DISTINCT c."objectId") AS total FROM descendants d JOIN "CommonModel" c ON c."company"=$1 AND c."nodeId"=d."nodeId" AND c."modelId"=52 AND COALESCE(c."status",99)=99 GROUP BY d.root)
+    SELECT n."nodeId",n."nodeName",n."parentId",n."zstatus",p."nodeName" AS parent,c.total FROM roots n LEFT JOIN "Node" p ON p."company"=$1 AND p."nodeId"=n."parentId" LEFT JOIN counts c ON c.root=n."nodeId" ORDER BY n."nodeName"`,[co]);
+  return rows.map(row=>({courseId:number(row.nodeId),courseName:text(row.nodeName),stageKey:courseStageFromDatabase([row.nodeName,row.parent]).key,stageName:courseStageFromDatabase([row.nodeName,row.parent]).name,wordCount:number(row.total),active:number(row.zstatus,99)===99}));
+}
+async function enrollmentOptions(context,input) {
+  const categories=await teachingCategoryRows(context,input),categoryKey=text(input.categoryKey||input.payload?.categoryKey||categories[0]?.key),category=categories.find(row=>row.key===categoryKey);if(!category)fail(400,'课程分类无效');
+  const uid=number(input.studentId||input.payload?.studentId),student=await userByLegacyId(context,input,uid,1);if(!student)fail(404,'学员不存在');
+  const reserved=await reservedCreditsFor(context,input,uid),rules=await teachingRuleRows(context,input),date=businessDate();
+  const effective=rules.filter(row=>row.categoryKey===categoryKey&&teachingRuleFor(rules,categoryKey,row.durationMinutes,row.deliveryMode,date)?.objectId===row.objectId);
+  const options=effective.map(row=>({...row,affordable:row.creditCosts.length>0&&row.creditCosts.every(cost=>number(legacyOf(student)[cost.account])-number(reserved[cost.account])>=cost.amount)}));
+  const eligible=!student.isDisabled&&options.some(row=>row.affordable),reason=student.isDisabled?'学员已停用':!effective.length?'该分类尚无有效课时规则':!eligible?'对应课时或陪练时长不足,请先充值':'';
+  const bindings=await enrollmentBindings(context,input,uid),catalog=await enrollmentCatalog(context,input);
+  return{categories,categoryKey,eligible,reason,rules:options,items:catalog.filter(row=>categoryMatchesStage(categoryKey,row.stageKey)).map(row=>({...row,alreadyOpened:bindings.some(b=>b.courseId===row.courseId&&b.categoryKey===categoryKey),eligible:eligible&&row.active&&row.wordCount>0,reason:!row.active?'内容已停用':!row.wordCount?'课程内容为空':reason}))};
+}
+async function saveEnrollment(context,input,payload) {
+  requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),uid=number(payload.studentId),category=text(payload.categoryKey),ids=[...new Set((Array.isArray(payload.courseIds)?payload.courseIds:[payload.courseId]).map(Number))].sort((a,b)=>a-b);
+  if(!ids.length||ids.length>100||ids.some(id=>!Number.isInteger(id)||id<=0))fail(400,'请选择 1 至 100 项课程内容');
+  const req={uid,category,courseIds:ids,actor:context.current.id,reason},co=companyIdOf(context,input);
+  const prior=await Psql.oneOrNone('SELECT "request","result" FROM "EnrollmentCommand" WHERE "company"=$1 AND "idempotencyKey"=$2',[co,text(input.idempotencyKey)]);
+  if(prior){const same=await Psql.one('SELECT $1::jsonb=$2::jsonb AS same',[JSON.stringify(prior.request),JSON.stringify(req)]);if(!same.same)fail(409,'请求编号已用于其他开课操作');return prior.result;}
+  const options=await enrollmentOptions(context,{...input,studentId:uid,categoryKey:category});
+  if(!options.eligible)fail(409,options.reason);
+  const selected=ids.map(id=>options.items.find(row=>row.courseId===id));if(selected.some(row=>!row||!row.eligible))fail(409,'课程内容为空、已停用或与分类学阶不符');
+  const categoryName=options.categories.find(row=>row.key===category).name;
+  return(await enrollmentSql('SELECT xs_enroll($1,$2,$3,$4,$5::jsonb,$6::jsonb,$7,$8) AS result',[companyIdOf(context,input),context.current.id,uid,category,JSON.stringify(selected.map(row=>({...row,categoryName}))),JSON.stringify(options.rules.map(rule=>rule.creditCosts)),reason,text(input.idempotencyKey)])).result;
+}
+async function enrollmentBindings(context,input,uid=0) {
+  const rows=await Psql.query('SELECT b.*,c."objectId" AS "targetId",c."status",n."nodeName" AS "courseName" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid"::text WHERE b."company"=$1 AND ($2::numeric=0 OR b."yhid"::text=$2::text) AND c."status"=99 AND b."enrollmentStatus"=\'active\' AND COALESCE(n."zstatus",99)=99',[companyIdOf(context,input),uid]);
+  return rows.map(row=>({bindingId:text(row.objectId),targetId:text(row.targetId),studentId:number(row.yhid),courseId:number(row.kcid),courseName:text(row.courseName),categoryKey:text(row.categoryKey),categoryName:TEACHING_CATEGORY_LABELS.get(text(row.categoryKey))||text(row.categoryName),learnedCount:number(row.yxx),wordCount:number(row.cksl)}));
+}
+async function enrollmentTeachingContext(context,input,payload,date) {
+  const rows=(await enrollmentBindings(context,input,number(payload.studentId))).filter(row=>row.courseId===number(payload.courseId)&&(!payload.bindingId||row.bindingId===text(payload.bindingId)));
+  if(rows.length!==1)fail(409,rows.length?'该内容有多个开课分类,请选择具体开课记录':'该课程尚未有效开课,请先开课或确认历史分类');
+  const binding=rows[0],rules=await teachingRuleRows(context,input),rule=teachingRuleFor(rules,binding.categoryKey,appointmentDuration(payload),deliveryMode(payload.deliveryMode||payload.deliveryMethod),date);
+  if(!rule)fail(409,'该开课分类没有对应时长和授课方式的有效规则');
+  return{bindingId:binding.bindingId,mapping:{categoryKey:binding.categoryKey,categoryName:binding.categoryName},rule};
+}
+async function enrollmentSchedule(context,input,data) {
+  const [bindings,rules]=await Promise.all([enrollmentBindings(context,input),teachingRuleRows(context,input)]),date=text(input.dateFrom)||businessDate();
+  const courses=bindings.map(b=>{const active=rules.filter(r=>r.categoryKey===b.categoryKey&&teachingRuleFor(rules,b.categoryKey,r.durationMinutes,r.deliveryMode,date)?.objectId===r.objectId);return{...b,courseCategoryKey:b.categoryKey,courseCategoryName:b.categoryName,memberIds:[b.studentId],availableToAll:false,mappingConfirmed:active.length>0,supportedDurations:[...new Set(active.map(r=>r.durationMinutes))],supportedDeliveryModes:[...new Set(active.map(r=>r.deliveryMode))],categoryName:b.categoryName};});
+  return{...data,courses};
+}
+async function toggleEnrollment(context,input,restore) {
+  requireRole(context,['ops-manager']);const reason=reasonOf(input),co=companyIdOf(context,input),target=await scopedObject('CommonModel',text(input.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);
+  if(number(target.get('modelId'))!==58)fail(400,'目标不是开课记录');
+  const b=await findAddon('CourseBinding',target.get('company'),target.get('itemId'));if(!b)fail(404,'开课记录不存在');
+  let costs=[];if(restore){const opt=await enrollmentOptions(context,{...input,studentId:b.get('yhid'),categoryKey:b.get('categoryKey')});if(!opt.items.some(row=>row.courseId===number(b.get('kcid'))&&row.eligible))fail(409,opt.reason||'课程内容不可用');costs=opt.rules.map(rule=>rule.creditCosts);}
+  return(await enrollmentSql('SELECT xs_enrollment_status($1,$2,$3,$4,$5,$6::jsonb) AS result',[co,b.id,restore,context.current.id,reason,JSON.stringify(costs)])).result;
+}
+async function saveEnrollmentSchedule(context,input,payload,editing=false) {
+ requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),co=companyIdOf(context,input),idem=text(input.idempotencyKey),editId=editing?text(input.targetId):'',req={actor:context.current.id,operation:editing?'update':'create',targetId:editId,payload,reason};
+ if(idem.length<8)fail(400,'缺少排课请求编号');
+ const prior=await Psql.oneOrNone('SELECT "request","result" FROM "EnrollmentCommand" WHERE "company"=$1 AND "idempotencyKey"=$2',[co,idem]);
+ if(prior){const same=await Psql.one('SELECT $1::jsonb=$2::jsonb AS same',[JSON.stringify(prior.request),JSON.stringify(req)]);if(!same.same)fail(409,'请求编号已用于其他排课操作');return prior.result;}
+ const check=await preflightAppointments(context,input,payload,editId),skip=payload.skipConflicts===true&&!editing,conflicts=new Set(check.conflictIndexes),selected=check.candidates.filter(row=>!skip||!conflicts.has(row.index));
+ if(check.conflictCount&&!skip)fail(409,'排课存在时间冲突');if(!selected.length)fail(409,'没有可保存的排课');
+ const coach=number(payload.coachId)?await userByLegacyId(context,input,payload.coachId,3):null,student=await userByLegacyId(context,input,payload.studentId,1);
+ const entries=selected.map(item=>{const start=new Date(item.startsAt),date=businessDate(start),time=start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'});return{common:{title:displayName(student)+'('+check.courseCategoryName+')',subtitle:check.courseName},addon:{szyh:String(payload.studentId),pl:coach?String(payload.coachId):'',fxpl:coach?String(payload.coachId):'',plxm:coach?displayName(coach):'',kcid:String(payload.courseId),yykcid:String(payload.courseId),dslx:String(legacyClassTypeFor(check.courseCategoryKey,check.durationMinutes)),jffs:check.deliveryLabel,bindingId:check.bindingId,courseCategoryKey:check.courseCategoryKey,courseCategoryName:check.courseCategoryName,durationMinutes:check.durationMinutes,deliveryMode:check.deliveryMode,teachingRuleId:item.teachingRule.objectId,teacherPaySnapshot:item.teachingRule.teacherPay,creditCostsSnapshot:item.teachingRule.creditCosts,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}};});
+ const result=(await enrollmentSql('SELECT xs_schedule_save($1,$2,$3::jsonb,$4::jsonb,$5,$6,$7) AS result',[co,context.current.id,JSON.stringify(req),JSON.stringify(entries),idem,editId,text(input.expectedUpdatedAt)])).result;return{...result,skipped:check.candidates.length-selected.length,warnings:check.warnings};
+}
+async function cancelEnrollmentSchedule(context,input,restore){
+ requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),pair=await appointmentPair(context,input,text(input.targetId));assertVersion(pair.common,input.expectedUpdatedAt);
+
+ return(await enrollmentSql('SELECT xs_schedule_cancel($1,$2,$3,$4,$5,$6) AS result',[pointerId(pair.common.get('company')),pair.common.id,restore,context.current.id,reason,text(input.expectedUpdatedAt)])).result;
+}

Разница между файлами не показана из-за своего большого размера
+ 22 - 17
scripts/deploy-admin-functions.mjs


+ 8 - 0
scripts/member-enrollment-schema.mjs

@@ -0,0 +1,8 @@
+const S={type:'String'},N={type:'Number'},O={type:'Object'},A={type:'Array'},C={type:'Pointer',targetClass:'Company'};
+export const enrollmentSchemas={
+ CourseBinding:{categoryKey:S,categoryName:S,enrollmentStatus:S},CourseAppointment:{bindingId:S},
+ LessonRecord:{bindingId:S,courseCategoryKey:S,courseCategoryName:S,teacherPaySnapshot:N,creditCostsSnapshot:A},DailyStudyRecord:{bindingId:S},
+ CreditTransaction:{company:C,idempotencyKey:S,request:O,kind:S,account:S,amount:N,storeId:N,studentId:N,sourceTransactionId:S,operatorId:S,reason:S,payMethod:S,balances:O},
+ EnrollmentCommand:{company:C,idempotencyKey:S,request:O,result:O},
+ ...Object.fromEntries(['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'].map(t=>[t,{transactionId:S}]))
+};

+ 43 - 0
scripts/migrate-member-enrollment.mjs

@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
+import {randomBytes,createHash} from 'node:crypto';
+import {resolve,join} from 'node:path';
+import {enrollmentSchemas} from './member-enrollment-schema.mjs';
+const sql=readFileSync(new URL('./sql/member-enrollment.sql',import.meta.url),'utf8'),apply=process.argv.includes('--apply');
+if(process.argv.includes('--validate')){if(!sql.includes('xs_complete_appointment'))throw Error('迁移不完整');console.log('用户开课迁移与 '+Object.keys(enrollmentSchemas).length+' 个 schema 定义已就绪;未连接数据库。');process.exit(0);}
+const master=process.env.XIAOSHU_MASTER_KEY;if(!master)throw Error('缺少 XIAOSHU_MASTER_KEY;未连接生产环境');
+const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+async function request(path,init={}){const r=await fetch(parseUrl+path,{...init,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||`HTTP ${r.status}`);return d;}
+async function rows(name,where={},keys=''){const all=[];let last='';for(;;){const q={...where,...(last?{objectId:{$gt:last}}:{})},result=await request('/classes/'+name+'?'+new URLSearchParams({where:JSON.stringify(q),limit:'1000',order:'objectId',...(keys?{keys}: {})}));all.push(...result.results);if(result.results.length<1000)break;last=result.results.at(-1).objectId;}return all;}
+const backup={createdAt:new Date().toISOString(),sqlSha256:createHash('sha256').update(sql).digest('hex'),tables:{}};
+for(const t of ['CourseBinding','CourseTeachingProfile','CourseCreditReservation'])backup.tables[t]=await rows(t);
+backup.tables.CommonModel=await rows('CommonModel',{modelId:{$in:[54,58]}});
+backup.tables._User=await rows('_User',{},'objectId,company,legacyUserId,legacyGroupId,identityType,isDisabled,legacyUserData');
+backup.tables.Node=await rows('Node',{},'objectId,company,nodeId,parentId,zstatus,nodeName');
+backup.tables.CourseAppointment=await rows('CourseAppointment');
+const categories=new Set(['word','trial','primary_writing','middle_writing','high_writing','word_self_study']);
+const profiles=new Map(backup.tables.CourseTeachingProfile.filter(p=>p.confirmed&&categories.has(p.categoryKey)).map(p=>[(p.company?.objectId||p.company)+':'+p.courseId,p.categoryKey]));
+const report={bindings:backup.tables.CourseBinding.length,missingCategory:0,missingContentRecord:0,incorrectContentMetadata:0,duplicateGroups:0,reservations:backup.tables.CourseCreditReservation.length};const groups=new Map();
+for(const b of backup.tables.CourseBinding){const co=b.company?.objectId||b.company,cat=b.categoryKey||profiles.get(co+':'+b.kcid);if(!categories.has(cat))report.missingCategory++;const c=backup.tables.CommonModel.find(c=>(c.company?.objectId||c.company)===co&&c.modelId===58&&c.itemId===b.id);if(!c)report.missingContentRecord++;else if(c.nodeId!==28||c.tableName!=='ZL_C_kcbd')report.incorrectContentMetadata++;if(c?.status!==-2&&cat){const k=[co,b.yhid,cat,b.kcid].join(':');groups.set(k,(groups.get(k)||0)+1);}}
+report.duplicateGroups=[...groups.values()].filter(n=>n>1).length;
+const companyOf=r=>r.company?.objectId||r.company;
+report.orphanBindings=[];report.accountIssues=[];report.duplicateReservations=[];
+for(const b of backup.tables.CourseBinding){const co=companyOf(b);if(!backup.tables._User.some(u=>companyOf(u)===co&&String(u.legacyUserId)===String(b.yhid))||!backup.tables.Node.some(n=>companyOf(n)===co&&String(n.nodeId)===String(b.kcid)))report.orphanBindings.push(b.objectId);}
+const reserved=new Map(),appointments=new Map();
+for(const r of backup.tables.CourseCreditReservation.filter(r=>r.state==='reserved')){const co=companyOf(r),key=co+':'+r.appointmentId;appointments.set(key,(appointments.get(key)||0)+1);for(const cost of r.creditCosts||[]){const k=[co,r.studentId,cost.account].join(':');reserved.set(k,(reserved.get(k)||0)+Number(cost.amount));}}
+report.duplicateReservations=[...appointments].filter(([,count])=>count>1).map(([key,count])=>({key,count}));
+for(const u of backup.tables._User)for(const account of ['Purse','SilverCoin','UserExp','UserPoint']){const balance=Number(u.legacyUserData?.[account]||0),hold=reserved.get([companyOf(u),u.legacyUserId,account].join(':'))||0;if(!Number.isFinite(balance)||balance<0||hold>balance)report.accountIssues.push({objectId:u.objectId,account,balance,reserved:hold});}
+report.requiresManualReconciliation=report.accountIssues.length>0||report.duplicateReservations.length>0;
+const dir=resolve(process.argv.find(a=>a.startsWith('--backup-dir='))?.slice(13)||'tmp/member-enrollment-'+Date.now());mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(join(dir,'before.json'),JSON.stringify(backup,null,2),{mode:0o600});writeFileSync(join(dir,'report.json'),JSON.stringify(report,null,2),{mode:0o600});console.log(JSON.stringify({report,backupDirectory:dir,apply},null,2));
+if(!apply)process.exit(0);
+if(report.requiresManualReconciliation)throw Error('预检发现负余额、超额预留或重复预留;请先核对 report.json 后再迁移');
+// Register Parse metadata before adding constraints/functions. New business tables are master-only.
+for(const [name,fields] of Object.entries(enrollmentSchemas)){let schema;try{schema=await request('/schemas/'+name);}catch(e){if(!['CreditTransaction','EnrollmentCommand'].includes(name))throw e;schema=null;}
+ const missing=Object.fromEntries(Object.entries(fields).filter(([k])=>!schema?.fields?.[k]));if(!schema||Object.keys(missing).length)await request('/schemas/'+name,{method:schema?'PUT':'POST',body:JSON.stringify({className:name,fields:missing,...(!schema?{classLevelPermissions:{find:{},get:{},count:{},create:{},update:{},delete:{},addField:{}}}:{})})});}
+const co=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];if(!co)throw Error('帐套不存在');
+const suffix=Date.now()+'_'+randomBytes(4).toString('hex'),path='xiaoshu/system/member-enrollment-'+suffix;let uid='',fid='';
+try{const user=await request('/users',{method:'POST',body:JSON.stringify({username:'enrollment_migration_'+suffix,password:randomBytes(32).toString('base64url'),isAdmin:true,adminRoleKey:'super-admin',roles:['admin','super-admin'],company:{__type:'Pointer',className:'Company',objectId:co.objectId}})});uid=user.objectId;
+ const code=`async function handler(request,response){const current=request.user;if(!current||current.id!==${JSON.stringify(uid)})return response.status(403).json({success:false});try{await Psql.none(${JSON.stringify(sql)});return response.json({success:true});}catch(error){return response.status(500).json({success:false,message:String(error.message)});}}`;
+ const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'用户开课事务迁移',enabled:true,params:[],paramList:[],respType:'json'})});fid=fn.objectId;
+ const r=await fetch(parseUrl.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:user.sessionToken,params:{}})}),result=await r.json();if(!r.ok||!result.success)throw Error(result.message||'迁移失败');console.log('迁移已完成;请部署运营与 App 网关并运行隔离账户验收。');
+}finally{const failures=[];if(fid)try{await request('/classes/Function/'+fid,{method:'DELETE'});}catch{failures.push('Function '+fid);}if(uid)try{await request('/users/'+uid,{method:'DELETE'});}catch{failures.push('User '+uid);}if(failures.length)throw Error('需要清理临时资源:'+failures.join(', '));}

+ 351 - 0
scripts/sql/member-enrollment.sql

@@ -0,0 +1,351 @@
+-- New database is authoritative for member accounts, credit transfers and enrollments.
+-- Run the read-only migration report and back up the affected tables before installation.
+CREATE SEQUENCE IF NOT EXISTS xiaoshu_business_id START 8000000000000;
+DO $$ BEGIN
+ PERFORM setval('xiaoshu_business_id',GREATEST((SELECT last_value FROM xiaoshu_business_id),COALESCE((SELECT max("generalId") FROM "CommonModel"),0),COALESCE((SELECT max("id") FROM "CourseBinding"),0),COALESCE((SELECT max("id") FROM "CourseAppointment"),0),COALESCE((SELECT max("id") FROM "LessonRecord"),0))::bigint);
+END $$;
+CREATE TABLE IF NOT EXISTS "CreditTransaction" (
+ "objectId" text PRIMARY KEY,"company" text NOT NULL,"createdAt" timestamptz,"updatedAt" timestamptz,
+ "idempotencyKey" text NOT NULL,"request" jsonb NOT NULL,"kind" text NOT NULL,"account" text NOT NULL,
+ "amount" numeric NOT NULL,"storeId" numeric,"studentId" numeric,"sourceTransactionId" text,
+ "operatorId" text,"reason" text,"payMethod" text,"balances" jsonb
+);
+CREATE UNIQUE INDEX IF NOT EXISTS credit_transaction_request ON "CreditTransaction" ("company","idempotencyKey");
+CREATE TABLE IF NOT EXISTS "EnrollmentCommand" (
+ "objectId" text PRIMARY KEY,"company" text NOT NULL,"createdAt" timestamptz,"updatedAt" timestamptz,
+ "idempotencyKey" text NOT NULL,"request" jsonb,"result" jsonb
+);
+CREATE UNIQUE INDEX IF NOT EXISTS enrollment_command_request ON "EnrollmentCommand" ("company","idempotencyKey");
+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 "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;
+ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "teacherPaySnapshot" numeric;
+ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "creditCostsSnapshot" jsonb;
+ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "bindingId" text;
+DO $$ DECLARE t text; BEGIN
+ FOREACH t IN ARRAY ARRAY['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'] LOOP
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS "transactionId" text',t);
+ END LOOP;
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_object_id() RETURNS text LANGUAGE sql VOLATILE AS $$
+ SELECT substr(md5(random()::text || clock_timestamp()::text),1,20)
+$$;
+CREATE OR REPLACE FUNCTION xs_insert(t text, d jsonb) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ IF NOT t=ANY(ARRAY['CreditTransaction','EnrollmentCommand','CourseBinding','CommonModel','LessonRecord','CourseAppointment','CourseCreditReservation','UserExpDomP','UserSIcon','UserExpHis','UserUserPoint','SysLog']) THEN RAISE EXCEPTION 'Invalid business table'; END IF;
+ EXECUTE format('INSERT INTO %I SELECT * FROM jsonb_populate_record(NULL::%I,$1)',t,t) USING d;
+END $$;
+CREATE OR REPLACE FUNCTION xs_reserved(co text, uid numeric, account text, excluded text DEFAULT '') RETURNS numeric LANGUAGE sql STABLE AS $$
+ SELECT COALESCE(sum((cost->>'amount')::numeric),0) FROM "CourseCreditReservation" r,
+ LATERAL jsonb_array_elements(COALESCE(r."creditCosts"::jsonb,'[]')) cost
+ WHERE r."company"=co AND r."studentId"=uid AND r."state"='reserved'
+ AND COALESCE(r."appointmentId",'')<>excluded AND cost->>'account'=account
+$$;
+CREATE OR REPLACE FUNCTION xs_ledger(co text, uid numeric, account text, delta numeric, before_value numeric, tx text, actor text, reason text, method text DEFAULT '') RETURNS void LANGUAGE plpgsql AS $$
+DECLARE t text; typ int;
+BEGIN
+ t=CASE account WHEN 'Purse' THEN 'UserExpDomP' WHEN 'SilverCoin' THEN 'UserSIcon' WHEN 'UserExp' THEN 'UserExpHis' WHEN 'UserPoint' THEN 'UserUserPoint' END;
+ typ=array_position(ARRAY['Purse','SilverCoin','UserExp','UserPoint'],account);
+ IF t IS NULL THEN RAISE EXCEPTION '课时类型无效'; END IF;
+ PERFORM xs_insert(t,jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','credit:'||tx||':'||uid||':'||account,
+ 'transactionId',tx,'userId',uid,'score',delta,'scoreBefore',before_value,'hisTime',now(),'operator',CASE WHEN delta>0 THEN 1 ELSE 2 END,'scoreType',typ,'detail',reason,'remark',actor,'payMethod',method));
+END $$;
+CREATE OR REPLACE FUNCTION xs_credit_post(co text, actor text, kind text, target text, account text, amount numeric, original text, reason text, method text, idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE u "_User"; st "_User"; src "CreditTransaction"; existing "CreditTransaction"; req jsonb; tx text=xs_object_id(); delta numeric; sb numeric; ub numeric; refunded numeric; result jsonb;
+BEGIN
+ IF co IS NULL OR length(idem)<8 OR length(trim(reason))<2 OR amount IS NULL OR amount<=0 OR amount>1000000000 OR amount::text IN ('NaN','Infinity') THEN RAISE EXCEPTION '数量、操作原因或请求编号无效'; END IF;
+ IF account IS NULL OR NOT account=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR (account<>'UserPoint' AND trunc(amount)<>amount) OR round(amount,2)<>amount THEN RAISE EXCEPTION '课时必须为整数,陪练时长最多两位小数'; END IF;
+ IF NOT kind=ANY(ARRAY['fund','transfer','refund']) OR length(method)=0 THEN RAISE EXCEPTION '业务类型或付款方式无效'; END IF;
+ req=jsonb_build_object('actor',actor,'kind',kind,'target',target,'account',account,'amount',amount,'original',original,'reason',reason,'method',method);
+ -- A company lock orders all fund/transfer/enrollment operations; account locks also serialize scheduling and completion.
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO existing FROM "CreditTransaction" WHERE "company"=co AND "idempotencyKey"=idem;
+ IF FOUND THEN IF existing."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他操作'; END IF; RETURN to_jsonb(existing); END IF;
+ SELECT * INTO u FROM "_User" WHERE "company"=co AND "objectId"=target;
+ IF NOT FOUND OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '账户不存在或已停用'; END IF;
+ IF kind='fund' THEN
+  IF COALESCE(u."identityType",'')<>'store' AND COALESCE(u."legacyGroupId",0)<>2 THEN RAISE EXCEPTION '目标必须是门店'; END IF; st=u;
+ ELSE
+  IF COALESCE(u."identityType",'')<>'member' AND COALESCE(u."legacyGroupId",0)<>1 THEN RAISE EXCEPTION '目标必须是学员'; END IF;
+  IF kind='refund' THEN
+   SELECT t.* INTO src FROM "CreditTransaction" t WHERE t."company"=co AND t."objectId"=original AND t."kind"='transfer';
+   IF NOT FOUND OR src."studentId"<>u."legacyUserId" OR src."account"<>account THEN RAISE EXCEPTION '原划拨单不存在或不匹配;历史流水需先核对'; END IF;
+   SELECT COALESCE(sum(t."amount"),0) INTO refunded FROM "CreditTransaction" t WHERE t."company"=co AND t."kind"='refund' AND t."sourceTransactionId"=original;
+   IF amount+refunded>src."amount" THEN RAISE EXCEPTION '超过原划拨单可退数量'; END IF;
+   SELECT * INTO st FROM "_User" WHERE "company"=co AND "legacyUserId"=src."storeId";
+  ELSE SELECT * INTO st FROM "_User" WHERE "company"=co AND "legacyUserId"=COALESCE(NULLIF(u."legacyUserData"->>'ParentUserID',''),'0')::numeric; END IF;
+  IF st."objectId" IS NULL OR COALESCE(st."isDisabled",false) OR (COALESCE(st."identityType",'')<>'store' AND COALESCE(st."legacyGroupId",0)<>2) THEN RAISE EXCEPTION '请先关联有效门店'; END IF;
+ END IF;
+ PERFORM 1 FROM "_User" WHERE "objectId" IN(u."objectId",st."objectId") ORDER BY "objectId" FOR UPDATE;
+ SELECT * INTO u FROM "_User" WHERE "objectId"=u."objectId";
+ SELECT * INTO st FROM "_User" WHERE "objectId"=st."objectId";
+ IF COALESCE(u."isDisabled",false) OR COALESCE(st."isDisabled",false) THEN RAISE EXCEPTION '账户已停用';END IF;
+ IF kind='transfer' AND COALESCE(NULLIF(u."legacyUserData"->>'ParentUserID',''),'0')::numeric<>st."legacyUserId" THEN RAISE EXCEPTION '所属门店已变更,请重新提交';END IF;
+ PERFORM set_config('xiaoshu.credit_write','yes',true);
+ sb=COALESCE((st."legacyUserData"->>account)::numeric,0);ub=COALESCE((u."legacyUserData"->>account)::numeric,0);
+ IF kind='transfer' AND sb-xs_reserved(co,st."legacyUserId",account)<amount THEN RAISE EXCEPTION '门店可用课时不足'; END IF;
+ IF kind='refund' AND ub-xs_reserved(co,u."legacyUserId",account)<amount THEN RAISE EXCEPTION '学员可退课时不足,已使用或已预留课时不能退回'; END IF;
+ delta=CASE WHEN kind='transfer' THEN -amount ELSE amount END;
+ UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[account],to_jsonb(sb+delta)),"updatedAt"=now() WHERE "objectId"=st."objectId";
+ PERFORM xs_ledger(co,st."legacyUserId",account,delta,sb,tx,actor,reason,method);
+ IF kind<>'fund' THEN
+  UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[account],to_jsonb(ub-delta)),"updatedAt"=now() WHERE "objectId"=u."objectId";
+  PERFORM xs_ledger(co,u."legacyUserId",account,-delta,ub,tx,actor,reason,method);
+ END IF;
+ result=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'kind',kind,'account',account,'amount',amount,'storeId',st."legacyUserId",'studentId',CASE WHEN kind='fund' THEN NULL ELSE u."legacyUserId" END,'sourceTransactionId',original,'operatorId',actor,'reason',reason,'payMethod',method,'balances',jsonb_build_object('storeBefore',sb,'storeAfter',sb+delta,'studentBefore',ub,'studentAfter',CASE WHEN kind='fund' THEN NULL ELSE ub-delta END));
+ PERFORM xs_insert('CreditTransaction',result);
+ PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'userId',actor,'logType','operations-admin','remind','[Operations] credit-'||kind||' CreditTransaction/'||tx||',原因:'||reason,'detail',result::text,'content',result::text));
+ RETURN result;
+END $$;
+
+-- Safely repair only unambiguous historical mappings. Preserve conflicting progress for review.
+UPDATE "CourseBinding" b SET "categoryKey"=p."categoryKey","enrollmentStatus"=CASE WHEN EXISTS(SELECT 1 FROM "CourseBinding" other WHERE other."company"=b."company" AND other."yhid"=b."yhid" AND other."kcid"=b."kcid" AND other."objectId"<>b."objectId" AND COALESCE(other."enrollmentStatus",'')<>'recycled' AND (other."categoryKey" IS NULL OR other."categoryKey"=p."categoryKey")) THEN 'pending' ELSE 'active' END
+FROM (SELECT "company","courseId",min("categoryKey") AS "categoryKey",true AS "confirmed" FROM "CourseTeachingProfile" WHERE "confirmed"=true GROUP BY "company","courseId" HAVING count(DISTINCT "categoryKey")=1) p WHERE b."company"=p."company" AND b."kcid"::text=p."courseId"::text AND p."confirmed"=true
+AND p."categoryKey" IN ('word','trial','primary_writing','middle_writing','high_writing','word_self_study') AND b."categoryKey" IS NULL;
+UPDATE "CourseBinding" SET "enrollmentStatus"=CASE WHEN "categoryKey" IN ('word','trial','primary_writing','middle_writing','high_writing','word_self_study') THEN 'active' ELSE 'pending' END WHERE "enrollmentStatus" IS NULL;
+UPDATE "CourseBinding" b SET "enrollmentStatus"='recycled' FROM "CommonModel" c WHERE c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" AND c."status"=-2;
+UPDATE "CourseBinding" b SET "enrollmentStatus"='pending' WHERE (b."company",b."yhid",b."kcid",b."categoryKey") IN
+(SELECT "company","yhid","kcid","categoryKey" FROM "CourseBinding" WHERE "enrollmentStatus"='active' GROUP BY 1,2,3,4 HAVING count(*)>1);
+UPDATE "CommonModel" c SET "nodeId"=28,"tableName"='ZL_C_kcbd' FROM "CourseBinding" b WHERE c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" AND (c."nodeId"<>28 OR c."tableName"<>'ZL_C_kcbd');
+UPDATE "CourseBinding" b SET "enrollmentStatus"='pending' WHERE b."enrollmentStatus"='active' AND (
+ b."categoryKey" NOT IN ('word','trial','primary_writing','middle_writing','high_writing','word_self_study')
+ OR NOT EXISTS(SELECT 1 FROM "CommonModel" c WHERE c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id")
+ OR NOT EXISTS(SELECT 1 FROM "Node" n WHERE n."company"=b."company" AND n."nodeId"::text=b."kcid"::text)
+ OR NOT EXISTS(SELECT 1 FROM "_User" u WHERE u."company"=b."company" AND u."legacyUserId"::text=b."yhid"::text));
+CREATE UNIQUE INDEX IF NOT EXISTS enrollment_active_identity ON "CourseBinding" ("company","yhid","categoryKey","kcid") WHERE "enrollmentStatus"='active';
+
+CREATE OR REPLACE FUNCTION xs_course_size(co text,course text) RETURNS numeric LANGUAGE plpgsql AS $$
+DECLARE total numeric;
+BEGIN
+ PERFORM 1 FROM "Node" WHERE "company"=co AND "nodeId"::text=course AND COALESCE("zstatus",99)=99 FOR SHARE;
+ IF NOT FOUND THEN RAISE EXCEPTION '课程内容不存在或已停用';END IF;
+ WITH RECURSIVE nodes AS (
+  SELECT n."nodeId",ARRAY[n."nodeId"] visited FROM "Node" n WHERE n."company"=co AND n."nodeId"::text=course
+  UNION ALL SELECT n."nodeId",p.visited||n."nodeId" FROM nodes p JOIN "Node" n ON n."company"=co AND n."parentId"=p."nodeId" WHERE NOT n."nodeId"=ANY(p.visited) AND COALESCE(n."zstatus",99)=99
+ ) SELECT count(DISTINCT c."objectId") INTO total FROM nodes n JOIN "CommonModel" c ON c."company"=co AND c."nodeId"=n."nodeId" AND c."modelId"=52 AND COALESCE(c."status",99)=99;
+ IF total=0 THEN RAISE EXCEPTION '课程内容为空';END IF;
+ RETURN total;
+END $$;
+CREATE OR REPLACE FUNCTION xs_enroll(co text, actor text, uid numeric, category text, items jsonb, costs jsonb, reason text, idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE u "_User"; req jsonb; old "EnrollmentCommand"; item jsonb; option_costs jsonb; cost jsonb; affordable boolean=false; valid boolean; ids jsonb='[]'; bid text; cid text; ident bigint; existing text; content_count numeric;
+BEGIN
+ IF NOT category=ANY(ARRAY['word','trial','primary_writing','middle_writing','high_writing','word_self_study']) OR jsonb_array_length(items)<1 OR length(idem)<8 THEN RAISE EXCEPTION '请选择分类和课程内容'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ req=jsonb_build_object('uid',uid,'category',category,'courseIds',(SELECT jsonb_agg((value->>'courseId')::numeric ORDER BY (value->>'courseId')::numeric) FROM jsonb_array_elements(items)),'actor',actor,'reason',reason);
+ SELECT * INTO old FROM "EnrollmentCommand" WHERE "company"=co AND "idempotencyKey"=idem;
+ IF FOUND THEN IF old."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他开课操作'; END IF;RETURN old."result";END IF;
+ SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"=uid FOR UPDATE;
+ IF NOT FOUND OR COALESCE(u."isDisabled",false) OR COALESCE(u."identityType",'member')<>'member' THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
+ FOR option_costs IN SELECT value FROM jsonb_array_elements(costs) LOOP
+  valid=jsonb_array_length(option_costs)>0;
+  FOR cost IN SELECT value FROM jsonb_array_elements(option_costs) LOOP
+   IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,uid,cost->>'account')<(cost->>'amount')::numeric THEN valid=false;END IF;
+  END LOOP;
+  affordable=affordable OR valid;
+ END LOOP;
+ IF NOT affordable THEN RAISE EXCEPTION '对应可用课时不足,请先充值(含规则要求的陪练时长)'; END IF;
+ FOR item IN SELECT value FROM jsonb_array_elements(items) LOOP
+  content_count=xs_course_size(co,item->>'courseId');
+  SELECT b."objectId" INTO existing FROM "CourseBinding" b WHERE b."company"=co AND b."yhid"::text=uid::text AND b."kcid"::text=item->>'courseId' AND b."categoryKey"=category AND b."enrollmentStatus"='active';
+  IF existing IS NOT NULL THEN RAISE EXCEPTION '该内容在所选分类已开课,请刷新列表';END IF;
+  bid=xs_object_id();cid=xs_object_id();ident=nextval('xiaoshu_business_id');
+  PERFORM xs_insert('CourseBinding',jsonb_build_object('objectId',bid,'company',co,'id',ident,'createdAt',now(),'updatedAt',now(),'sourceKey','enroll:'||bid,'yhid',uid,'kcid',(item->>'courseId')::numeric,'yxx',0,'cksl',content_count,'syjd','','categoryKey',category,'categoryName',item->>'categoryName','enrollmentStatus','active'));
+  PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',cid,'company',co,'createdAt',now(),'updatedAt',now(),'createTime',now(),'sourceKey','enroll:'||bid,'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',58,'nodeId',28,'tableName','ZL_C_kcbd','status',99,'title',item->>'courseName','inputer',actor));
+  ids=ids||jsonb_build_array(bid);
+ END LOOP;
+ req=jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'result',jsonb_build_object('bindingIds',ids,'count',jsonb_array_length(ids)));
+ PERFORM xs_insert('EnrollmentCommand',req);RETURN req->'result';
+END $$;
+
+-- Shared account lock protects EVERY reservation against parallel transfers/refunds/completion.
+CREATE OR REPLACE FUNCTION xs_reservation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE u "_User"; cost jsonb;
+BEGIN
+ SELECT * INTO u FROM "_User" WHERE "company"=NEW."company" AND "legacyUserId"=NEW."studentId" FOR UPDATE;
+ IF NEW."state"='reserved' THEN
+  IF COALESCE(jsonb_array_length(NEW."creditCosts"::jsonb),0)=0 THEN RAISE EXCEPTION '预约缺少有效课时';END IF;
+  IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
+  FOR cost IN SELECT value FROM jsonb_array_elements(NEW."creditCosts"::jsonb) LOOP
+   IF NOT (cost->>'account')=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR (cost->>'amount')::numeric<=0 OR COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(NEW."company",NEW."studentId",cost->>'account',COALESCE(NEW."appointmentId",''))<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足,不能重复预留';END IF;
+  END LOOP;
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS xs_reservation_guard ON "CourseCreditReservation";
+CREATE TRIGGER xs_reservation_guard BEFORE INSERT OR UPDATE ON "CourseCreditReservation" FOR EACH ROW EXECUTE FUNCTION xs_reservation_guard();
+CREATE OR REPLACE FUNCTION xs_enrollment_status(co text,bid text,restore boolean,actor text,reason text,costs jsonb DEFAULT '[]') RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE b "CourseBinding"; u "_User"; option_costs jsonb; cost jsonb; valid boolean; affordable boolean=false;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=bid;
+ IF NOT FOUND THEN RAISE EXCEPTION '开课记录不存在';END IF;
+ SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=b."yhid"::text FOR UPDATE;
+ IF EXISTS(SELECT 1 FROM "CourseAppointment" a JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" WHERE a."company"=co AND a."szyh"::text=b."yhid"::text AND c."status"<>-2 AND a."dszt"::text IN ('0','10') AND (a."bindingId"=bid OR (a."bindingId" IS NULL AND a."kcid"::text=b."kcid"::text))) THEN RAISE EXCEPTION '请先处理该课程未结束的预约';END IF;
+ IF restore AND (b."categoryKey" IS NULL OR b."enrollmentStatus"='pending') THEN RAISE EXCEPTION '历史开课分类待确认';END IF;
+ IF restore THEN
+  PERFORM xs_course_size(co,b."kcid"::text);
+  IF COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员已停用';END IF;
+  FOR option_costs IN SELECT value FROM jsonb_array_elements(costs) LOOP
+   valid=jsonb_array_length(option_costs)>0;
+   FOR cost IN SELECT value FROM jsonb_array_elements(option_costs) LOOP
+    IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,b."yhid"::numeric,cost->>'account')<(cost->>'amount')::numeric THEN valid=false;END IF;
+   END LOOP;
+   affordable=affordable OR valid;
+  END LOOP;
+  IF NOT affordable THEN RAISE EXCEPTION '对应可用课时不足,请先充值';END IF;
+ END IF;
+ UPDATE "CourseBinding" SET "enrollmentStatus"=CASE WHEN restore THEN 'active' ELSE 'recycled' END,"updatedAt"=now() WHERE "objectId"=bid;
+ UPDATE "CommonModel" SET "status"=CASE WHEN restore THEN 99 ELSE -2 END,"updatedAt"=now() WHERE "company"=co AND "modelId"=58 AND "itemId"=b."id";
+ 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,'bindingId',bid,'restore',restore),'result','{}'::jsonb));
+ RETURN jsonb_build_object('bindingId',bid,'restored',restore);
+END $$;
+CREATE OR REPLACE FUNCTION xs_validate_appointment(co text,aid text) RETURNS text LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment"; c "CommonModel"; u "_User"; matches int; cost jsonb;
+BEGIN
+ SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "modelId"=54 AND "objectId"=aid;
+ 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;
+ SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
+ IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
+ SELECT count(*) INTO matches FROM "CourseBinding" b JOIN "CommonModel" bc ON bc."company"=b."company" AND bc."modelId"=58 AND bc."itemId"=b."id" WHERE b."company"=co AND b."yhid"::text=a."szyh"::text AND b."kcid"::text=a."kcid"::text AND b."enrollmentStatus"='active' AND bc."status"=99 AND (NULLIF(a."bindingId",'') IS NULL OR b."objectId"=a."bindingId");
+ PERFORM xs_course_size(co,a."kcid"::text);
+ IF matches<>1 THEN RAISE EXCEPTION '预约必须关联唯一有效开课记录,请先确认分类';END IF;
+ IF COALESCE(a."creditHoldStatus",'ready')<>'ready' THEN RAISE EXCEPTION '历史预约课时待处理';END IF;
+ IF jsonb_array_length(COALESCE(a."creditCostsSnapshot"::jsonb,'[]'))=0 THEN RAISE EXCEPTION '预约缺少课时快照';END IF;
+ FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
+  IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId",cost->>'account',aid)<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足'; END IF;
+ END LOOP;
+ RETURN u."objectId";
+END $$;
+CREATE OR REPLACE FUNCTION xs_complete_appointment(co text,aid text,actor text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment"; c "CommonModel"; u "_User"; existing text; lid text=xs_object_id(); lc text=xs_object_id(); ident bigint; cost jsonb; before_value numeric; tx text; charged boolean=false;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ -- Acquire user before appointment/reservation locks, matching transfer and reservation lock ordering.
+ SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"=c."itemId";
+ SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
+ IF u."objectId" IS NULL THEN RAISE EXCEPTION '预约学员不存在';END IF;
+ SELECT "objectId" INTO existing FROM "LessonRecord" WHERE "company"=co AND "yyds"::text=c."generalId"::text LIMIT 1;
+ IF existing IS NOT NULL THEN RETURN jsonb_build_object('lessonId',existing,'idempotent',true,'periodDeducted',false);END IF;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
+ IF a."dszt"::text NOT IN ('10','11') OR COALESCE(NULLIF(a."pl"::text,''),a."fxpl"::text,'0')='0' THEN RAISE EXCEPTION '预约尚未上课或未分配教师';END IF;
+ PERFORM xs_validate_appointment(co,aid);
+ SELECT EXISTS(SELECT 1 FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=aid AND "state"='consumed') INTO charged;
+ tx='complete:'||aid;
+ IF NOT charged THEN
+  PERFORM set_config('xiaoshu.credit_write','yes',true);
+  UPDATE "CourseCreditReservation" SET "state"='consumed',"consumedAt"=now(),"updatedAt"=now() WHERE "company"=co AND "appointmentId"=aid;
+  FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
+   before_value=COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0);
+   u."legacyUserData"=jsonb_set(u."legacyUserData",ARRAY[cost->>'account'],to_jsonb(before_value-(cost->>'amount')::numeric));
+   PERFORM xs_ledger(co,u."legacyUserId",cost->>'account',-(cost->>'amount')::numeric,before_value,tx,actor,'完课扣除课时');
+  END LOOP;
+  UPDATE "_User" SET "legacyUserData"=u."legacyUserData","updatedAt"=now() WHERE "objectId"=u."objectId";
+ END IF;
+ ident=nextval('xiaoshu_business_id');
+ PERFORM xs_insert('LessonRecord',jsonb_build_object('objectId',lid,'company',co,'id',ident,'sourceKey',tx,'createdAt',now(),'updatedAt',now(),'xymz',a."szyh",'jsmz',COALESCE(NULLIF(a."pl"::text,''),a."fxpl"::text),'kcid',a."kcid",'kclx',a."dslx",'kcmc',c."subtitle",'yyds',c."generalId"::text,'jffs',a."jffs",'kzsj','[]','lessonAt',now(),'bindingId',a."bindingId",'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'creditCostsSnapshot',a."creditCostsSnapshot"));
+ PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',lc,'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey',tx,'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',59,'nodeId',296,'tableName','ZL_C_skjl','title',c."title",'inputer',actor,'status',99));
+ UPDATE "CourseAppointment" SET "dszt"='11',"jssj"=COALESCE(NULLIF("jssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
+ RETURN jsonb_build_object('lessonId',lid,'idempotent',false,'periodDeducted',NOT charged);
+END $$;
+CREATE OR REPLACE FUNCTION xs_schedule_save(co text,actor text,req jsonb,entries jsonb,idem text,edit_id text DEFAULT '',expected text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE old "EnrollmentCommand"; entry jsonb; uid numeric; student "_User"; b "CourseBinding"; cid text; aid text; ident bigint; common_data jsonb; addon_data jsonb; ids jsonb='[]'; costs jsonb; old_c "CommonModel"; old_a "CourseAppointment"; requested_start timestamptz; requested_end timestamptz;
+BEGIN
+ IF length(idem)<8 OR jsonb_array_length(entries)=0 THEN RAISE EXCEPTION '排课请求无效';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO old FROM "EnrollmentCommand" WHERE "company"=co AND "idempotencyKey"=idem;
+ IF FOUND THEN IF old."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他排课操作';END IF;RETURN old."result";END IF;
+ IF edit_id<>'' THEN
+  SELECT * INTO old_c FROM "CommonModel" WHERE "company"=co AND "objectId"=edit_id AND "modelId"=54;
+  SELECT * INTO old_a FROM "CourseAppointment" WHERE "company"=co AND "id"=old_c."itemId";
+  IF old_a."objectId" IS NULL OR old_a."dszt"::text<>'0' OR old_c."status"=-2 THEN RAISE EXCEPTION '只有未开始的预约可以修改';END IF;
+  IF expected<>'' AND old_c."updatedAt"<>expected::timestamptz THEN RAISE EXCEPTION '预约已修改,请刷新';END IF;
+ END IF;
+ -- Account locks precede reservation locks. Stable order handles changing an appointment's student.
+ PERFORM 1 FROM "_User" WHERE "company"=co AND ("legacyUserId"::text=old_a."szyh"::text OR "legacyUserId"::text IN(SELECT value->'addon'->>'szyh' FROM jsonb_array_elements(entries))) ORDER BY "objectId" FOR UPDATE;
+ FOR entry IN SELECT value FROM jsonb_array_elements(entries) LOOP
+  addon_data=entry->'addon';uid=(addon_data->>'szyh')::numeric;costs=addon_data->'creditCostsSnapshot';
+  SELECT * INTO student FROM "_User" WHERE "company"=co AND "legacyUserId"=uid;
+  SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=addon_data->>'bindingId' AND "yhid"::text=uid::text AND "enrollmentStatus"='active';
+  IF b."objectId" IS NULL OR b."kcid"::text<>addon_data->>'kcid' OR b."categoryKey"<>addon_data->>'courseCategoryKey' OR COALESCE(student."isDisabled",false) THEN RAISE EXCEPTION '学员或开课记录已失效';END IF;
+  PERFORM xs_course_size(co,b."kcid"::text);
+  requested_start=(addon_data->>'yysj')::timestamp AT TIME ZONE 'Asia/Shanghai';requested_end=requested_start+make_interval(mins=>(addon_data->>'durationMinutes')::int);
+  IF EXISTS(SELECT 1 FROM "CourseAppointment" a JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" WHERE a."company"=co AND c."status"<>-2 AND c."objectId"<>edit_id AND a."dszt"::text IN ('0','10','11','20') AND (a."szyh"::text=uid::text OR (NULLIF(addon_data->>'pl','') IS NOT NULL AND a."pl"::text=addon_data->>'pl')) AND COALESCE(a."yysj",'') ~ '^\d{4}-\d{2}-\d{2} ' AND (a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')<requested_end AND (a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')+make_interval(mins=>COALESCE(a."durationMinutes",30)::int)>requested_start) THEN RAISE EXCEPTION '会员或老师时间冲突,请重新检查排课';END IF;
+  IF edit_id='' THEN
+   cid=xs_object_id();aid=xs_object_id();ident=nextval('xiaoshu_business_id');
+   addon_data=addon_data||jsonb_build_object('objectId',aid,'id',ident,'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','schedule:'||idem||':'||jsonb_array_length(ids));
+   common_data=(entry->'common')||jsonb_build_object('objectId',cid,'company',co,'createdAt',now(),'updatedAt',now(),'createTime',now(),'sourceKey','schedule:'||idem||':'||jsonb_array_length(ids),'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',54,'nodeId',29,'tableName','ZL_C_order','status',99,'inputer',actor);
+   PERFORM xs_insert('CourseAppointment',addon_data);PERFORM xs_insert('CommonModel',common_data);
+  ELSE
+   cid=old_c."objectId";aid=old_a."objectId";
+   -- Preserve all unrelated lesson/feedback fields; replace only scheduling fields supplied by the gateway.
+   addon_data=to_jsonb(old_a)||addon_data||jsonb_build_object('updatedAt',now());
+   UPDATE "CourseAppointment" a SET ("szyh","pl","fxpl","plxm","kcid","yykcid","dslx","jffs","bindingId","courseCategoryKey","courseCategoryName","durationMinutes","deliveryMode","teachingRuleId","teacherPaySnapshot","creditCostsSnapshot","bxrq","sdsd","yysj","updatedAt")=(SELECT r."szyh",r."pl",r."fxpl",r."plxm",r."kcid",r."yykcid",r."dslx",r."jffs",r."bindingId",r."courseCategoryKey",r."courseCategoryName",r."durationMinutes",r."deliveryMode",r."teachingRuleId",r."teacherPaySnapshot",r."creditCostsSnapshot",r."bxrq",r."sdsd",r."yysj",r."updatedAt" FROM jsonb_populate_record(NULL::"CourseAppointment",addon_data) r) WHERE a."objectId"=aid;
+   UPDATE "CommonModel" SET "subtitle"=entry->'common'->>'subtitle',"updatedAt"=now() WHERE "objectId"=cid;
+   UPDATE "CourseCreditReservation" SET "state"='released',"updatedAt"=now() WHERE "company"=co AND "appointmentId"=cid;
+   DELETE FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=cid AND "state"='released';
+   common_data=to_jsonb(old_c);
+  END IF;
+  PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:appointment-reservation:'||cid,'appointmentId',cid,'appointmentGeneralId',common_data->'generalId','studentId',uid,'ruleId',addon_data->>'teachingRuleId','creditCosts',costs,'state','reserved','reservedAt',now()));
+  ids=ids||jsonb_build_array(cid);
+ END LOOP;
+ common_data=jsonb_build_object('created',jsonb_array_length(ids),'targetIds',ids);
+ PERFORM xs_insert('EnrollmentCommand',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'result',common_data));
+ RETURN common_data;
+END $$;
+CREATE OR REPLACE FUNCTION xs_schedule_cancel(co text,aid text,restore boolean,actor text,reason text,expected text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE c "CommonModel"; a "CourseAppointment"; u "_User"; requested_start timestamptz; requested_end timestamptz; restore_coach text;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54;
+ IF expected<>'' AND c."updatedAt"<>expected::timestamptz THEN RAISE EXCEPTION '预约已变更,请刷新';END IF;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"=c."itemId";
+ SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
+ IF a."objectId" IS NULL OR a."dszt"::text NOT IN ('0','10') THEN RAISE EXCEPTION '预约已经上课或不存在';END IF;
+ IF restore THEN
+  requested_start=a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai';requested_end=requested_start+make_interval(mins=>COALESCE(a."durationMinutes",30)::int);restore_coach=a."pl"::text;
+  IF EXISTS(SELECT 1 FROM "CourseAppointment" ca JOIN "CommonModel" cc ON cc."company"=ca."company" AND cc."modelId"=54 AND cc."itemId"=ca."id" WHERE ca."company"=co AND cc."status"<>-2 AND cc."objectId"<>aid AND ca."dszt"::text IN ('0','10','11','20') AND (ca."szyh"::text=u."legacyUserId"::text OR (NULLIF(restore_coach,'') IS NOT NULL AND ca."pl"::text=restore_coach)) AND COALESCE(ca."yysj",'') ~ '^\d{4}-\d{2}-\d{2} ' AND (ca."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')<requested_end AND (ca."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')+make_interval(mins=>COALESCE(ca."durationMinutes",30)::int)>requested_start) THEN RAISE EXCEPTION '会员或老师时间冲突,请重新检查排课';END IF;
+  UPDATE "CommonModel" SET "status"=99,"updatedAt"=now() WHERE "objectId"=aid;
+  PERFORM xs_validate_appointment(co,aid);
+  UPDATE "CourseCreditReservation" SET "state"='reserved',"updatedAt"=now() WHERE "company"=co AND "appointmentId"=aid;
+  IF NOT FOUND THEN PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:appointment-reservation:'||aid,'appointmentId',aid,'studentId',u."legacyUserId",'creditCosts',a."creditCostsSnapshot",'state','reserved'));END IF;
+ ELSE
+  UPDATE "CourseCreditReservation" SET "state"='released',"updatedAt"=now() WHERE "company"=co AND "appointmentId"=aid;
+  UPDATE "CommonModel" SET "status"=-2,"updatedAt"=now() WHERE "objectId"=aid;
+ END IF;
+ 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('appointmentId',aid,'restore',restore,'actor',actor,'reason',reason),'result','{}'::jsonb));
+ RETURN jsonb_build_object('targetId',aid,'restored',restore);
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_start_appointment(co text,aid text) RETURNS void LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment";
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ PERFORM xs_validate_appointment(co,aid);
+ SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c."company"=a0."company" AND c."modelId"=54 AND c."itemId"=a0."id" WHERE c."company"=co AND c."objectId"=aid FOR UPDATE OF a0;
+ IF a."dszt"::text NOT IN ('0','10') THEN RAISE EXCEPTION '预约状态已变更';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";
+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 $$
+DECLARE account text;
+BEGIN
+ IF current_setting('xiaoshu.credit_write',true) IS DISTINCT FROM 'yes' THEN
+  FOREACH account IN ARRAY ARRAY['Purse','SilverCoin','UserExp','UserPoint'] LOOP
+   IF COALESCE((OLD."legacyUserData"->>account)::numeric,0)<>COALESCE((NEW."legacyUserData"->>account)::numeric,0) THEN RAISE EXCEPTION '课时账户已变更,请刷新;余额仅能通过课时业务单修改';END IF;
+  END LOOP;
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS xs_user_credit_guard ON "_User";
+CREATE TRIGGER xs_user_credit_guard BEFORE UPDATE OF "legacyUserData" ON "_User" FOR EACH ROW EXECUTE FUNCTION xs_user_credit_guard();
+
+CREATE UNIQUE INDEX IF NOT EXISTS enrollment_reservation_identity ON "CourseCreditReservation" ("company","appointmentId") WHERE "state"='reserved';

+ 18 - 0
scripts/tests/member-enrollment-gateway.test.mjs

@@ -0,0 +1,18 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const extension=readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8');
+function gateway(){const c={Date};vm.createContext(c);vm.runInContext(ops+'\n'+extension,c);return c;}
+const plain=x=>JSON.parse(JSON.stringify(x));
+function setup(){const c=gateway();c.companyIdOf=()=> 'co';c.requireRole=()=>{};c.reasonOf=i=>i.reason;c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{Purse:5,SilverCoin:5,UserPoint:5,UserExp:1}});c.reservedCreditsFor=async()=>({});c.teachingCategoryRows=async()=>vm.runInContext('TEACHING_CATEGORY_DEFAULTS',c);c.teachingRuleRows=async()=>c.defaultTeachingRules();c.businessDate=()=> '2026-09-09';c.enrollmentBindings=async()=>[];c.enrollmentCatalog=async()=>['primary','middle','high'].map((stageKey,index)=>({courseId:100+index,courseName:stageKey,stageKey,wordCount:20,active:true}));return c;}
+test('六个新分类完整可选,语法写作仅选择对应学阶',async()=>{const c=setup();for(const category of await c.teachingCategoryRows()){const options=await c.enrollmentOptions({}, {studentId:20,categoryKey:category.key});assert.equal(options.categories.length,6);assert.equal(options.eligible,true,category.key);assert.equal(options.items.length,category.stageKey?1:3);if(category.stageKey)assert.equal(options.items[0].stageKey,category.stageKey);}});
+test('零余额、配套陪练不足和空内容分别说明原因',async()=>{const c=setup();c.userByLegacyId=async()=>({legacyUserData:{}});let o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.equal(o.eligible,false);assert.match(o.reason,/课时|陪练/);c.userByLegacyId=async()=>({legacyUserData:{Purse:5,SilverCoin:5}});o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.equal(o.eligible,false);c.userByLegacyId=async()=>({legacyUserData:{Purse:5,SilverCoin:5,UserPoint:5}});c.enrollmentCatalog=async()=>[{courseId:100,wordCount:0,active:true},{courseId:101,wordCount:5,active:false}];o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.match(o.items[0].reason,/空/);assert.match(o.items[1].reason,/停用/);});
+test('同一内容有多个开课分类时旧请求拒绝,指定 bindingId 匹配各自规则',async()=>{const c=setup();c.enrollmentBindings=async()=>[{bindingId:'word',courseId:100,categoryKey:'word'},{bindingId:'self',courseId:100,categoryKey:'word_self_study'}];await assert.rejects(c.enrollmentTeachingContext({}, {},{studentId:20,courseId:100,durationMinutes:60,deliveryMode:'online'},'2026-09-09'),/多个开课分类/);for(const [id,key] of [['word','word'],['self','word_self_study']]){const r=await c.enrollmentTeachingContext({}, {},{studentId:20,courseId:100,bindingId:id,durationMinutes:60,deliveryMode:'online'},'2026-09-09');assert.equal(r.rule.categoryKey,key);assert.equal(r.bindingId,id);}});
+test('开课请求成功后的重试复用结果,不重新校验已经耗尽的余额',async()=>{const c=setup(),req={uid:20,category:'word',courseIds:[100],actor:'a',reason:'正式开课'};c.enrollmentOptions=async()=>{throw Error('不应重新计算资格');};c.Psql={oneOrNone:async()=>({request:req,result:{bindingIds:['b'],count:1}}),one:async(sql,args)=>({same:JSON.stringify(JSON.parse(args[0]))===JSON.stringify(JSON.parse(args[1]))})};assert.deepEqual(plain(await c.saveEnrollment({current:{id:'a'}},{reason:'正式开课',idempotencyKey:'test-0001'},{studentId:20,categoryKey:'word',courseIds:[100]})),{bindingIds:['b'],count:1});});
+test('批量排课跨规则日期分别保存快照',async()=>{const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.enrollmentBindings=async()=>[{bindingId:'b',courseId:100,categoryKey:'trial'}];c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{UserExp:10,UserPoint:10}});const result=await c.preflightAppointments({}, {},{studentId:20,courseId:100,bindingId:'b',durationMinutes:60,deliveryMode:'online',date:'2026-09-08',startTime:'10:00',recurrence:'daily',occurrences:2});assert.deepEqual(plain(result.candidates.map(r=>r.teachingRule.teacherPay)),[40,30]);assert.equal(result.creditAvailability.find(r=>r.account==='UserExp').required,2);});
+test('新后台列表按学员精确查询,筛选发生在全站记录上限之前',async()=>{const c=setup();const calls=[];c.Psql={query:async(sql,args)=>{calls.push([sql,args]);return[];}};for(const kind of ['course-bindings','appointments','lessons','learning-records'])await c.businessRows(kind,{}, {studentId:20});await c.moneyRows({}, {studentId:20});for(const [sql,args] of calls){assert.equal(args[1],'20');assert.ok(sql.indexOf('$2')<sql.indexOf('LIMIT 5000'));}});
+test('旧同步不能覆盖账号、开课和预约,跨分类进度查询不得随机选择第一条',async()=>{const c=setup();await assert.rejects(c.applyUserSync({}, {},{}),/禁止旧数据覆盖/);for(const kind of ['users','course-bindings','appointments','lessons'])await assert.rejects(c.applyProjectionRecord({},'upsert',{}, {},kind),/禁止旧数据覆盖/);});
+test('用户端通用内容接口不能绕过开课、预留及完课事务',async()=>{const c={};vm.createContext(c);vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c);for(const modelId of [54,58,59])await assert.rejects(c.createContentPair({content:JSON.stringify({modelId}),addon:'{}'},{}),/正式完课流程/);});

+ 147 - 0
scripts/tests/member-enrollment.test.mjs

@@ -0,0 +1,147 @@
+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 sql=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');
+const query=async(s,p=[])=>(await db.query(s,p)).rows;
+const one=async(s,p=[])=>(await query(s,p))[0];
+const post=async(kind,target,amount,idem,account='Purse',source='')=>(await one('SELECT xs_credit_post(\'co\',\'admin\',$1,$2,$3,$4,$5,\'测试入账\',\'转账\',$6) AS result',[kind,target,account,amount,source,idem])).result;
+const balance=async(id,account='Purse')=>Number((await one('SELECT "legacyUserData"->>$2 AS b FROM "_User" WHERE "objectId"=$1',[id,account])).b||0);
+const enroll=async(category,idem,uid=20,course=100,costs=[[{account:'Purse',amount:1}]])=>(await one('SELECT xs_enroll(\'co\',\'admin\',$1,$2,$3::jsonb,$4::jsonb,\'测试开课\',$5) AS result',[uid,category,JSON.stringify([{courseId:course,courseName:'词表',wordCount:200,categoryName:category}]),JSON.stringify(costs),idem])).result;
+before(async()=>{
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" numeric,"cksl" numeric,"syjd" text);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"tableName" text,"title" text,"subtitle" text,"status" numeric,"inputer" text);
+ CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" numeric,"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" numeric);
+ CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" numeric,"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" numeric,"categoryKey" text,"confirmed" boolean);
+ CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" numeric,"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" numeric,"score" numeric,"scoreBefore" numeric,"hisTime" timestamptz,"operator" numeric,"scoreType" numeric,"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,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" text,"logType" text,"remind" text,"detail" text,"content" 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(sql);
+});
+after(()=>db.close());
+test('总部入账、双方划拨、幂等与原请求校验',async()=>{
+ await post('fund','store',10,'fund-00001');await post('fund','store',10,'fund-00001');assert.equal(await balance('store'),10);
+ await assert.rejects(post('fund','store',9,'fund-00001'),/请求编号/);
+ const tx=await post('transfer','student',4,'transfer-01');assert.equal(await balance('store'),6);assert.equal(await balance('student'),4);
+ assert.equal((await one('SELECT count(*)::int AS n FROM "UserExpDomP" WHERE "transactionId"=$1',[tx.objectId])).n,2);
+ await assert.rejects(post('transfer','student',20,'transfer-fail'),/不足/);assert.equal(await balance('store'),6);assert.equal(await balance('student'),4);
+ await assert.rejects(post('transfer','foreign',1,'foreign-01'),/不存在/);await assert.rejects(post('transfer','empty',1,'no-store-01'),/关联/);
+});
+test('退回原门店,重复退款和数量校验',async()=>{
+ const tx=await post('transfer','student',2,'transfer-02');await post('refund','student',1,'refund-001','Purse',tx.objectId);
+ assert.equal(await balance('student'),5);await assert.rejects(post('refund','student',2,'refund-002','Purse',tx.objectId),/可退/);
+ await assert.rejects(post('fund','store',0.5,'fraction-01'),/整数/);await assert.rejects(post('refund','student',1,'unlinked-01','Purse','unknown'),/原划拨单/);
+});
+test('零余额不能开课,多账户规则全部满足',async()=>{
+ await assert.rejects(enroll('word','no-credit-1',21),/课时不足/);
+ await assert.rejects(enroll('word','no-point-01',20,100,[[{account:'Purse',amount:1},{account:'UserPoint',amount:0.5}]]),/课时不足/);
+ await post('fund','store',5,'fund-point-01','UserPoint');await post('transfer','student',2,'transfer-point','UserPoint');
+});
+test('同词表跨分类可开,同分类幂等且不扣课时,正确栏目和自动数量',async()=>{
+ const before=await balance('student');await enroll('word','enroll-word-01');await enroll('word','enroll-word-01');await assert.rejects(enroll('word','enroll-word-02'),/已开课/);await enroll('word_self_study','enroll-self-01');
+ const rows=await query('SELECT * FROM "CourseBinding" WHERE "yhid"=\'20\'');assert.equal(rows.length,2);assert.equal(Number(rows[0].cksl),200);assert.equal(await balance('student'),before);
+ assert.equal((await one('SELECT count(*)::int AS n FROM "CommonModel" WHERE "modelId"=58 AND "nodeId"=28 AND "tableName"=\'ZL_C_kcbd\'')).n,2);
+ await assert.rejects(enroll('word','enroll-word-01',20,101),/请求编号/);
+});
+async function appointment(id,binding,cost=1){await query('INSERT INTO "CourseAppointment" ("objectId","company","id","szyh","kcid","pl","dszt","dslx","bindingId","creditCostsSnapshot") VALUES ($1,\'co\',$2,\'20\',\'100\',\'30\',\'10\',\'1\',$3,$4::jsonb)',[id,Number(id.replace(/\D/g,'')),binding,JSON.stringify([{account:'Purse',amount:cost}])]);await query('INSERT INTO "CommonModel" ("objectId","company","modelId","generalId","itemId","status") VALUES ($1,\'co\',54,$2,$2,99)',['c'+id,Number(id.replace(/\D/g,''))]);}
+test('预约必须明确分类,预留阻止超额退款和重复预留',async()=>{
+ const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
+ await appointment('a1001',b,4);await appointment('a1002','',4);
+ await assert.rejects(query('SELECT xs_validate_appointment(\'co\',\'ca1002\')'),/唯一/);
+ await query('INSERT INTO "CourseCreditReservation" ("objectId","company","studentId","appointmentId","creditCosts","state","consumedAt","updatedAt") VALUES (\'r1\',\'co\',20,\'ca1001\',\'[{"account":"Purse","amount":4}]\',\'reserved\',NULL,now())');
+ await assert.rejects(query('INSERT INTO "CourseCreditReservation" ("objectId","company","studentId","appointmentId","creditCosts","state","consumedAt","updatedAt") VALUES (\'r2\',\'co\',20,\'ca1002\',\'[{"account":"Purse","amount":4}]\',\'reserved\',NULL,now())'),/不足/);
+ const tx=(await one('SELECT "objectId" FROM "CreditTransaction" WHERE "idempotencyKey"=\'transfer-01\'')).objectId;
+ await assert.rejects(post('refund','student',2,'reserved-refund','Purse',tx),/预留/);
+ await assert.rejects(query('SELECT xs_enrollment_status(\'co\',$1,false,\'admin\',\'取消测试\')',[b]),/未结束/);
+});
+test('完课单事务扣减、消费预留和学习事实,重试不重复',async()=>{
+ const before=await balance('student');const done=(await one('SELECT xs_complete_appointment(\'co\',\'ca1001\',\'teacher\') AS r')).r;
+ assert.equal(done.periodDeducted,true);assert.equal(await balance('student'),before-4);
+ const again=(await one('SELECT xs_complete_appointment(\'co\',\'ca1001\',\'teacher\') AS r')).r;assert.equal(again.idempotent,true);assert.equal(await balance('student'),before-4);
+ assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "objectId"=\'r1\'')).state,'consumed');
+ assert.equal((await one('SELECT count(*)::int AS n FROM "LessonRecord"')).n,1);
+});
+test('迁移可重复运行,已有分类和学习进度不重置',async()=>{
+ await query('UPDATE "CourseBinding" SET "yxx"=50 WHERE "categoryKey"=\'word\'');await db.exec(sql);assert.equal(Number((await one('SELECT "yxx" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).yxx),50);
+});
+const scheduleEntry=(binding,start,cost=1)=>({common:{title:'学员单词课',subtitle:'词表'},addon:{szyh:'20',kcid:'100',yykcid:'100',bindingId:binding,courseCategoryKey:'word',courseCategoryName:'单词课',durationMinutes:30,deliveryMode:'online',teachingRuleId:'rule',teacherPaySnapshot:15,creditCostsSnapshot:[{account:'Purse',amount:cost}],pl:'30',fxpl:'30',plxm:'老师',dslx:'1',dszt:'0',jffs:'线上',bxrq:start.slice(0,10),sdsd:start.slice(11),yysj:start}});
+const saveSchedule=async(entries,key,edit='',expected='')=>(await one('SELECT xs_schedule_save(\'co\',\'admin\',$1::jsonb,$2::jsonb,$3,$4,$5) AS r',[JSON.stringify({entries,edit}),JSON.stringify(entries),key,edit,expected])).r;
+test('排课保存、修改、取消恢复均同步预留,重试不重复',async()=>{
+ const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
+ const entry=scheduleEntry(b,'2030-01-01 10:00');const saved=await saveSchedule([entry],'schedule-001');const again=await saveSchedule([entry],'schedule-001');assert.deepEqual(again,saved);
+ const aid=saved.targetIds[0];assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
+ await assert.rejects(saveSchedule([scheduleEntry(b,'2030-01-01 10:10')],'schedule-conflict'),/冲突/);
+ await saveSchedule([scheduleEntry(b,'2030-01-01 11:00')],'schedule-update',aid);
+ await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[aid]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'released');
+ await query('SELECT xs_schedule_cancel(\'co\',$1,true,\'admin\',\'测试恢复\')',[aid]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
+ await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[aid]);
+});
+test('批量排课超余额整体回滚,不留下半张预约或预留',async()=>{
+ const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
+ const before=(await one('SELECT count(*)::int AS n FROM "CourseAppointment"')).n;
+ await assert.rejects(saveSchedule([scheduleEntry(b,'2030-02-01 10:00'),scheduleEntry(b,'2030-02-02 10:00')],'schedule-rollback'),/不足/);
+ assert.equal((await one('SELECT count(*)::int AS n FROM "CourseAppointment"')).n,before);
+});
+test('余额更新不能被旧资料覆盖,空课程开课事务回滚',async()=>{
+ await assert.rejects(query('UPDATE "_User" SET "legacyUserData"=\'{"Purse":999}\' WHERE "objectId"=\'student\''),/业务单/);
+ await query('UPDATE "_User" SET "legacyUserData"="legacyUserData"||\'{"HoneyName":"新名字"}\' WHERE "objectId"=\'student\'');
+ await assert.rejects(enroll('trial','enroll-empty-01',20,101),/内容为空/);
+});
+test('双流水或审计失败时总部入账与业务单全部回滚',async()=>{
+ const before=await balance('store');
+ await db.exec(`CREATE FUNCTION xs_test_audit_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'audit failure';END $$;CREATE TRIGGER xs_test_audit_fail BEFORE INSERT ON "SysLog" FOR EACH ROW EXECUTE FUNCTION xs_test_audit_fail();`);
+ await assert.rejects(post('fund','store',2,'fund-audit-fail'),/audit failure/);assert.equal(await balance('store'),before);
+ assert.equal((await one('SELECT count(*)::int AS n FROM "CreditTransaction" WHERE "idempotencyKey"=\'fund-audit-fail\'')).n,0);
+ await db.exec('DROP TRIGGER xs_test_audit_fail ON "SysLog"');
+});
+test('最后一节课的竞争提交仅一张预约成功,完课后重试原开课仍成功',async()=>{
+ const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
+ const results=await Promise.allSettled([saveSchedule([scheduleEntry(b,'2031-01-01 10:00')],'last-credit-01'),saveSchedule([scheduleEntry(b,'2031-01-02 10:00')],'last-credit-02')]);
+ assert.equal(results.filter(r=>r.status==='fulfilled').length,1);const aid=results.find(r=>r.status==='fulfilled').value.targetIds[0];
+ await query('SELECT xs_start_appointment(\'co\',$1)',[aid]);assert.equal((await one('SELECT "dszt" FROM "CourseAppointment" a JOIN "CommonModel" c ON c."itemId"=a."id" AND c."modelId"=54 WHERE c."objectId"=$1',[aid])).dszt,'10');
+ 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()=>{
+ 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()=>[];
+ await query('INSERT INTO "_User" VALUES (\'teacher\',\'co\',30,3,\'coach\',false,\'{}\',now())');
+ await post('fund','store',1,'integration-fund','UserExp');await post('transfer','student',1,'integration-transfer','UserExp');
+ const context={current:{id:'admin'}},result=await c.saveEnrollment(context,{reason:'联调开课',idempotencyKey:'integration-enroll'},{studentId:20,categoryKey:'trial',courseIds:[100]});
+ 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:'2032-01-01',startTime:'10: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);
+ 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);
+});
+test('回收保留进度,恢复重新校验余额并保留原分类',async()=>{
+ const b=await one('SELECT * FROM "CourseBinding" WHERE "categoryKey"=\'word\'');
+ await query('UPDATE "CommonModel" SET "status"=-2 WHERE "objectId"=\'ca1002\'');
+ await query('SELECT xs_enrollment_status(\'co\',$1,false,\'admin\',\'回收测试\')',[b.objectId]);
+ const costs=JSON.stringify([[{account:'Purse',amount:1}]]);
+ await assert.rejects(query('SELECT xs_enrollment_status(\'co\',$1,true,\'admin\',\'恢复测试\',$2::jsonb)',[b.objectId,costs]),/课时不足/);
+ await post('transfer','student',1,'restore-transfer');
+ await query('SELECT xs_enrollment_status(\'co\',$1,true,\'admin\',\'恢复测试\',$2::jsonb)',[b.objectId,costs]);
+ const restored=await one('SELECT * FROM "CourseBinding" WHERE "objectId"=$1',[b.objectId]);assert.equal(restored.enrollmentStatus,'active');assert.equal(restored.categoryKey,'word');assert.equal(Number(restored.yxx),50);assert.equal(await balance('student'),1);
+});
+test('已有唯一约束下重复迁移,冲突历史绑定保留进度并标记待确认',async()=>{
+ 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);
+ 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);
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов