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

fix(payroll): price reviews by lesson duration and authenticated actor

彭峰 1 день назад
Родитель
Сommit
f04326a581

+ 21 - 0
docs/operations/review-pay-policy-2026-10-20.md

@@ -0,0 +1,21 @@
+# 复习工资按课时时长与实际完成身份计薪
+
+2026-10-20,用户确认:30 分钟课对应复习工资 0.50 元/条,60 分钟课对应 1.00 元/条;学生账号自行完成复习不产生老师工资。仍需计划当天首次完成;逾期补做、重复提交不增加工资。
+
+## 实现
+
+- `scripts/sql/review-pay-policy.sql` 在既有 review-rewards、lesson-records、payroll-integrity 迁移之后执行。发布时只应用本次增量,保存现网函数、视图、约束、相关工资及来源课次备份。
+- 使用服务端认证的账号识别实际完成者,学生本人优先识别为自习;会员即使保留历史分组 3 也不能被当作老师计薪。按现有任务行锁核对转交关系,保存 `completedBy`、`completionRole`、`durationMinutes`、`rateVersion` 快照。学生完成写入零元 `self_study` 凭据,仍保存复习进度;老师随后重试不能补记工资。
+- 时长沿学习记录关联至同一学员的唯一来源预约,优先读取排课时长快照;仅在缺失快照时使用已知旧课型 1→30、2/3→60。来源缺失、冲突或不支持的时长不能默认按 60 分钟计薪。
+- 历史导入仍只生成零元待核对凭据。管理员人工确认时按可验证课时时长计薪,并保留确认身份。既有原始收入凭据不改写。
+- 新增独立、不可改写的 `ReviewRewardRateCorrection` 保存历史 30 分钟工资差额;现有 `ReviewRewardIncome` 统一提供净收入,老师端、后台和工资入账都消费该净额。原有逾期纠正可以继续冲销剩余 0.50 元,不能重复扣除原 1 元。
+- 时长批量纠正仅允许明确选中的、未进入工资单的凭据;如出现工资单归属或来源变化,整笔事务拒绝,不自动修改已审核或已发放工资。
+
+## 检查
+
+- 新增数据库回归 7 项:0.5/1 元、学生自习、身份与租户限制、幂等、日期边界、缺失来源、历史确认、差额纠正、工资净额、二次纠正和增量重跑。
+- 既有复习、转交、工资完整性及老师收入测试共 49 项通过。
+- 移动端页面集成 43 项、后台复习工资组件 6 项通过;双端生产构建通过,云函数定义静态校验通过。
+- 修改历史金额已获用户明确授权;2026-10-20 只读基线包含 179 条来源为 30 分钟且尚未进入工资单的 1 元凭据,计划差额 -89.50 元。旧完成记录没有保存实际完成账号,身份不明部分保留待核对,不能据此认定为学生自习或直接扣到零。
+
+本机受保护的审计、备份及发布证据目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-20-review-pay/`。

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

@@ -984,7 +984,7 @@ export interface ReviewReward {
   objectId: string; taskId: string; coachId: number; coachName: string; studentName: string;
   courseName: string; storeId: number; storeName: string; plannedDate: string; completedAt: string;
   incomeMonth: string; amount: number; status: 'earned' | 'historical' | 'exception' | 'ineligible';
-  statusLabel: string; exception: string; batchId?: string; settlementMonth?: string;
+  statusLabel: string; exception: string; ineligibleReason?: string; batchId?: string; settlementMonth?: string;
   canConfirm?: boolean; canCorrect?: boolean; batchStatus?: string; originalAmount?: number; correctionAmount?: number; correctionReason?: string; correctionId?: string; adjustmentBatchId?: string;
 }
 export interface ReviewRewardPage {

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/review-rewards.component.html

@@ -8,12 +8,12 @@
 @if(error()){<p class="notice error" role="alert">{{error()}} <button class="ui-button secondary compact" (click)="load()">重新加载</button></p>}
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 <section class="content-card reward-card" [attr.aria-busy]="loading()">
-  <header><div><strong>抗遗忘工资</strong><span>每条抗遗忘任务仅计划当天首次完成计薪 1 元,逾期补做不计薪,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="incorrect">逾期错计待核对</option><option value="ineligible">逾期不计薪</option><option value="earned">有效计薪</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出工资明细'}}</button></div></header>
+  <header><div><strong>抗遗忘工资</strong><span>老师在计划当天首次完成复习,来源为30分钟课计0.5元/条,60分钟课计1元/条;学生自习、逾期补做不计薪,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="incorrect">逾期错计待核对</option><option value="ineligible">不计薪记录</option><option value="earned">有效计薪</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出工资明细'}}</button></div></header>
   @if(data();as result){
     <div class="reward-summary"><span>有效复习 <strong>{{result.summary.reviewCount}}</strong> 次</span><span>工资合计 <strong>¥{{result.summary.reviewAmount|number:'1.2-2'}}</strong></span><span>历史待核对 {{result.summary.historicalCount}} 条</span><span>异常待核对 {{result.summary.exceptionCount}} 条</span></div>
-    @if(canManage&&hasConfirmable()){<div class="reward-confirm"><label>人工核对依据<input [ngModel]="confirmReason()" (ngModelChange)="confirmReason.set($event)" placeholder="例如:已核对旧系统完成与历史发放记录,确认未发放" /></label><button class="ui-button primary" type="button" (click)="confirmSelected()" [disabled]="confirming()||!selectedIds().length">{{confirming()?'核对中…':'确认选中 '+selectedIds().length+' 条,每条 ¥1'}}</button><small>仅可勾选 2026-09-01 起的旧系统完成记录;服务端再次校验来源、身份、时间和重复计薪。</small></div>}
+    @if(canManage&&hasConfirmable()){<div class="reward-confirm"><label>人工核对依据<input [ngModel]="confirmReason()" (ngModelChange)="confirmReason.set($event)" placeholder="例如:已核对旧系统完成与历史发放记录,确认未发放" /></label><button class="ui-button primary" type="button" (click)="confirmSelected()" [disabled]="confirming()||!selectedIds().length">{{confirming()?'核对中…':'确认选中 '+selectedIds().length+' 条,按来源课时时长计薪'}}</button><small>仅可勾选 2026-09-01 起的旧系统完成记录;服务端再次校验来源、身份、时间和重复计薪。</small></div>}
     <div class="table-scroll"><table class="data-table"><thead><tr><th>核对</th><th>老师 / 门店</th><th>学生 / 课程</th><th>计划日期</th><th>首次完成时间</th><th>金额</th><th>结算状态</th><th>说明 / 任务</th></tr></thead><tbody>
-      @for(row of result.items;track row.objectId){<tr><td>@if(canManage&&row.canConfirm){<input type="checkbox" [attr.aria-label]="'核对复习凭据 '+row.taskId" [checked]="selectedIds().includes(row.objectId)" (change)="toggleSelection(row.objectId,$any($event.target).checked)" />}@else{—}@if(canManage&&row.canCorrect){<button class="ui-button secondary compact" type="button" (click)="openCorrection(row)">核对纠正</button>}</td><td><strong>{{row.coachName}}</strong><small>{{row.storeName}}</small></td><td>{{row.studentName}}<small>{{row.courseName||'未记录课程'}}</small></td><td>{{row.plannedDate||'未记录'}}</td><td>{{row.completedAt?(row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'):'待核对'}}</td><td class="money">¥{{row.amount|number:'1.2-2'}}</td><td><span class="badge" [class.warn]="row.status!=='earned'">{{row.statusLabel}}</span><small>{{row.settlementMonth}}</small></td><td>{{row.exception|| (row.status==='ineligible'?'逾期补做,不计薪':row.status==='historical'?'需核对历史发放情况':'当天首次完成,仅计薪一次')}}<small>{{row.correctionReason}}</small>@if(row.correctionId){<small>原金额 ¥{{row.originalAmount}} · 调整 ¥{{row.correctionAmount}}</small>}<small>{{row.taskId}}</small></td></tr>}
+      @for(row of result.items;track row.objectId){<tr><td>@if(canManage&&row.canConfirm){<input type="checkbox" [attr.aria-label]="'核对复习凭据 '+row.taskId" [checked]="selectedIds().includes(row.objectId)" (change)="toggleSelection(row.objectId,$any($event.target).checked)" />}@else{—}@if(canManage&&row.canCorrect){<button class="ui-button secondary compact" type="button" (click)="openCorrection(row)">核对纠正</button>}</td><td><strong>{{row.coachName}}</strong><small>{{row.storeName}}</small></td><td>{{row.studentName}}<small>{{row.courseName||'未记录课程'}}</small></td><td>{{row.plannedDate||'未记录'}}</td><td>{{row.completedAt?(row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'):'待核对'}}</td><td class="money">¥{{row.amount|number:'1.2-2'}}</td><td><span class="badge" [class.warn]="row.status!=='earned'">{{row.statusLabel}}</span><small>{{row.settlementMonth}}</small></td><td>{{row.exception|| (row.status==='ineligible'?(row.ineligibleReason==='self_study'?'学生自习,不计薪':'逾期补做,不计薪'):row.status==='historical'?'需核对历史发放情况':'当天首次完成,仅计薪一次')}}<small>{{row.correctionReason}}</small>@if(row.correctionId){<small>原金额 ¥{{row.originalAmount}} · 调整 ¥{{row.correctionAmount}}</small>}<small>{{row.taskId}}</small></td></tr>}
       @empty{<tr><td colspan="8"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无抗遗忘工资记录'}}</div></td></tr>}
     </tbody></table></div>
     <footer class="reward-pagination"><span>共 {{result.total}} 条 · 第 {{result.page}} 页</span><button class="ui-button secondary compact" (click)="load(result.page-1)" [disabled]="loading()||result.page<=1">上一页</button><button class="ui-button secondary compact" (click)="load(result.page+1)" [disabled]="loading()||result.page*result.pageSize>=result.total">下一页</button></footer>

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

@@ -1885,6 +1885,20 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
     expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
   });
+  it('shows the server half-yuan reward and distinguishes student self-study from overdue work', async()=>{
+    user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1});
+    const harness=await RouterTestingHarness.create('/pages/stu/record?id=900&gid=103');await harness.fixture.whenStable();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    component.reviewCompletion.set({amount:0.5,rewardStatus:'earned'});harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('本次收入 ¥0.50');
+    component.reviewCompletion.set({amount:0,rewardStatus:'ineligible',ineligibleReason:'self_study',unchanged:true});harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('学生自习,不计薪');
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toContain('逾期补做');
+    user.set({userId:100,groupId:1,honeyName:'学生'});session.groupId.set(1);harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toContain('收入');
+  });
+
   it('waits for server completion, blocks duplicate clicks and shows zero for overdue work', async()=>{
     user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1});
     const api=TestBed.inject(ApiService),post=api.post.bind(api),reply=new Subject<ApiEnvelope<any>>();

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

@@ -454,7 +454,7 @@
   } @else if (isRecordPage) {
     @if(reviewCompletion(); as result){
       <section class="review-complete" role="status"><h2>复习已完成</h2>
-        @if(canStartPointReading){<strong>{{result.unchanged?'已记账金额':'本次收入'}} ¥{{result.amount | number:'1.2-2'}}</strong><p>{{result.rewardStatus==='ineligible'?'逾期补做,不计薪':result.unchanged?'此任务已完成,不重复计薪':result.rewardStatus==='earned'?'已记录当天复习工资':'计薪凭据待核对'}}</p>}
+        @if(canStartPointReading){<strong>{{result.unchanged?'已记账金额':'本次收入'}} ¥{{result.amount | number:'1.2-2'}}</strong><p>{{result.ineligibleReason==='self_study'?'学生自习,不计薪':result.rewardStatus==='ineligible'?'逾期补做,不计薪':result.unchanged?'此任务已完成,不重复计薪':result.rewardStatus==='earned'?'已记录当天复习工资':'计薪凭据待核对'}}</p>}
         <button class="primary-button" type="button" (click)="returnToReview(true)">继续今日任务</button>
         <button class="secondary-button" type="button" (click)="returnToReview()">返回原任务列表</button>
         <a class="secondary-button" routerLink="/pages/home/index">返回首页</a>

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

@@ -164,7 +164,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12));
   readonly memorySelectedDate = signal('');
   readonly reviewScope=signal('');
-  readonly reviewCompletion=signal<{amount:number;rewardStatus:string;unchanged?:boolean}|null>(null);
+  readonly reviewCompletion=signal<{amount:number;rewardStatus:string;ineligibleReason?:string;unchanged?:boolean}|null>(null);
   private reviewReturn: {date?:string;studentId?:string;page?:number;scope?:string;scroll?:number}={};
   readonly orderInfo = signal<SourceItem>({});
   readonly learnedRecord = signal<SourceItem>({});
@@ -975,7 +975,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   endRecord(): void {
     if(this.submitting()||this.reviewCompletion())return;
     this.submitting.set(true);
-    this.api.post<{amount:number;rewardStatus:string;unchanged?:boolean}>('app_review_complete',{id:this.query('gid')}).pipe(finalize(()=>this.submitting.set(false))).subscribe({
+    this.api.post<{amount:number;rewardStatus:string;ineligibleReason?:string;unchanged?:boolean}>('app_review_complete',{id:this.query('gid')}).pipe(finalize(()=>this.submitting.set(false))).subscribe({
       next:({result})=>{this.reviewCache().clear();if(Number(this.session.groupId())===3)this.todayReviews.markCompleted(this.query('gid'));this.reviewCompletion.set(result);},
       error:(e:ApiError)=>this.platform.toast(e.message,'error'),
     });

+ 1 - 1
scripts/cloud/review-rewards.js

@@ -39,5 +39,5 @@ async function reviewRewardPage(companyId, input) {
   const where = clauses.join(' AND ');
   const summary = await Psql.one('SELECT COUNT(*)::int total, COUNT(*) FILTER (WHERE r."status"=\'earned\')::int "reviewCount", COALESCE(SUM(r."amount"),0) "reviewAmount",COUNT(*) FILTER (WHERE r."status"=\'historical\')::int "historicalCount",COUNT(*) FILTER (WHERE r."status"=\'exception\')::int "exceptionCount" FROM "ReviewRewardIncome" r WHERE '+summaryWhere, summaryValues);
   const rows = await Psql.query('SELECT r.*,m."sourceKey" AS "taskSourceKey",m."fxzt" AS "taskStatus",m."rewardDuplicate" AS "taskDuplicate",l."batchId",b."month" AS "settlementMonth",b."status" AS "batchStatus" FROM "ReviewRewardIncome" r LEFT JOIN "MemoryPracticeRecord" m ON m."company"=r."company" AND m."objectId"=r."taskId" LEFT JOIN "PayrollLine" l ON l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND l."kind"=\'review_reward\' AND COALESCE(l."excluded",false)=false LEFT JOIN "PayrollBatch" b ON b."company"=l."company" AND b."objectId"=l."batchId" WHERE '+where+(input.exportMode?' ORDER BY r."objectId"':' ORDER BY r."completedAt" DESC NULLS FIRST,r."objectId"')+' LIMIT $'+(values.length+1)+' OFFSET $'+(values.length+2), [...values,pageSize,input.exportMode?0:(page-1)*pageSize]);
-  return { ruleVersion:'beijing-day-v1',snapshot,nextCursor:input.exportMode&&rows.length===pageSize?rows[rows.length-1].objectId:'',items: rows.map((row)=>{const completedAt=row.completedAt?new Date(row.completedAt).toISOString():'',businessDay=completedAt?new Date(completedAt).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'',canConfirm=row.status==='historical'&&!row.exception&&businessDay>='2026-09-01'&&businessDay===row.plannedDate&&row.taskStatus==='1'&&row.taskDuplicate!==true&&(/^(legacy:model:60:|legacy-sync:memory-records:addon:)/.test(row.taskSourceKey||''));const {taskSourceKey,taskStatus,taskDuplicate,...visible}=row;return {...visible,amount:Number(row.amount),completedAt,canConfirm,canCorrect:row.status==='earned'&&businessDay>row.plannedDate&&Boolean(row.plannedDate)&&!row.correctionId,statusLabel:row.correctionId?'已纠正,净收入 ¥0':row.status==='ineligible'?'逾期补做,不计薪':row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')};}), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
+  return { ruleVersion:'beijing-day-v1',snapshot,nextCursor:input.exportMode&&rows.length===pageSize?rows[rows.length-1].objectId:'',items: rows.map((row)=>{const completedAt=row.completedAt?new Date(row.completedAt).toISOString():'',businessDay=completedAt?new Date(completedAt).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'',canConfirm=row.status==='historical'&&!row.exception&&businessDay>='2026-09-01'&&businessDay===row.plannedDate&&row.taskStatus==='1'&&row.taskDuplicate!==true&&(/^(legacy:model:60:|legacy-sync:memory-records:addon:)/.test(row.taskSourceKey||''));const {taskSourceKey,taskStatus,taskDuplicate,...visible}=row;return {...visible,amount:Number(row.amount),completedAt,canConfirm,canCorrect:row.status==='earned'&&businessDay>row.plannedDate&&Boolean(row.plannedDate),statusLabel:row.correctionId?(Number(row.amount)>0?'已按课时时长纠正':'已纠正,净收入 ¥0'):row.status==='ineligible'?(row.ineligibleReason==='self_study'?'学生自习,不计薪':'逾期补做,不计薪'):row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')};}), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
 }

+ 2 - 2
scripts/migrate-teacher-earnings.mjs

@@ -12,14 +12,14 @@ async function parse(path,method='GET',body){const r=await fetch(base+path,{meth
 const user=await parse('/users/me');if(!['super-admin','ops-manager'].includes(user.adminRoleKey)||user.isDisabled||user.isDeleted||!user.company?.objectId)throw Error('有效管理员身份缺失');
 const name='xiaoshu/system/teacher-earnings-'+randomUUID(),expires=Date.now()+15*60000;let functionId;
 const backup=resolve(backupArg);await mkdir(backup,{recursive:true,mode:0o700});
-const tables=['CourseAppointment','LessonRecord','LessonCompletionReport','MemoryPracticeRecord','ReviewReward','ReviewRewardCorrection','PayrollBatch','PayrollLine','PayrollCommand'];
+const tables=['CourseAppointment','LessonRecord','LessonCompletionReport','MemoryPracticeRecord','ReviewReward','ReviewRewardCorrection','ReviewRewardRateCorrection','PayrollBatch','PayrollLine','PayrollCommand'];
 const code=`async function handler(request,response){try{
  const current=request.user;if(!current||current.id!==${JSON.stringify(user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});await current.fetch({useMasterKey:true});if(current.get('isDisabled')||current.get('isDeleted')||!['super-admin','ops-manager'].includes(current.get('adminRoleKey')))return response.status(403).json({success:false});
  const active=await Psql.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[${JSON.stringify(user.company.objectId)},'running']);if(active.n)throw Error('夜间同步正在运行,暂缓迁移');
  const mode=request.body.params.mode;
  if(mode==='backup'){const snapshot=await Psql.tx(async t=>{await t.none('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');const out={};for(const table of ${JSON.stringify(tables)}){const exists=await t.one('SELECT to_regclass($1) present',['"'+table+'"']);if(exists.present)out[table]=await t.query('SELECT to_jsonb(x) AS row FROM "'+table+'" x');}out.functions=await t.query("SELECT proname,pg_get_functiondef(oid) AS definition FROM pg_proc WHERE pronamespace='public'::regnamespace AND (proname LIKE 'xs_%' OR proname LIKE 'xiaoshu_%')");out.columns=await t.query('SELECT * FROM information_schema.columns WHERE table_schema=$1',['public']);return out;});return response.json({success:true,snapshot});}
  if(mode!=='apply'||request.body.params.sha256!==${JSON.stringify(sha)})throw Error('迁移版本不匹配');await Psql.none(${JSON.stringify(sql)});
- const readback=await Psql.one('SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name=$1 AND column_name=$2) AS "feedbackStatus",to_regprocedure($3) IS NOT NULL AS "reviewCompletion",to_regprocedure($4) IS NOT NULL AS correction',['LessonCompletionReport','feedbackStatus','xs_review_complete_result(text,text,text)','xs_review_reward_correct(text,text,text,text,text,integer)']);return response.json({success:true,readback,sha256:${JSON.stringify(sha)}});
+ const readback=await Psql.one('SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name=$1 AND column_name=$2) AS "feedbackStatus",to_regprocedure($3) IS NOT NULL AS "reviewCompletion",to_regprocedure($4) IS NOT NULL AS correction,to_regprocedure($5) IS NOT NULL AS "reviewDurationPolicy"',['LessonCompletionReport','feedbackStatus','xs_review_complete_result(text,text,text)','xs_review_reward_correct(text,text,text,text,text,integer)','xs_review_duration(text,text)']);return response.json({success:true,readback,sha256:${JSON.stringify(sha)}});
  }catch(e){return response.status(500).json({success:false,message:String(e.message||e)});}}`;
 async function call(mode){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+name,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{mode,sha256:sha}})});const d=await r.json();if(!r.ok||!d.success)throw Error(d.message||'迁移失败');return d;}
 try{

+ 242 - 0
scripts/sql/review-pay-policy.sql

@@ -0,0 +1,242 @@
+-- Apply after review-rewards.sql, lesson-records.sql and payroll-integrity.sql.
+-- Immutable receipts keep their original amount; historical rate corrections are separate evidence.
+BEGIN;
+SET LOCAL lock_timeout = '10s';
+LOCK TABLE "MemoryPracticeRecord", "ReviewReward" IN SHARE ROW EXCLUSIVE MODE;
+ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "completedBy" text;
+ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "completionRole" text;
+ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "durationMinutes" integer;
+ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "rateVersion" text;
+ALTER TABLE "ReviewReward" DROP CONSTRAINT IF EXISTS "ReviewReward_check";
+ALTER TABLE "ReviewReward" ADD CONSTRAINT "ReviewReward_check" CHECK (
+ (status='earned' AND amount IN (0.5,1) AND "completedAt" IS NOT NULL AND "coachId">0)
+ OR (status<>'earned' AND amount=0));
+ALTER TABLE "ReviewReward" DROP CONSTRAINT IF EXISTS review_reward_duration_rate_check;
+ALTER TABLE "ReviewReward" ADD CONSTRAINT review_reward_duration_rate_check CHECK (
+ "rateVersion" IS DISTINCT FROM 'duration-actor-v2' OR status<>'earned' OR
+ ("completionRole" IS NOT NULL AND "completionRole" IN ('coach','historical_verified') AND NULLIF("completedBy",'') IS NOT NULL
+ AND "durationMinutes" IS NOT NULL AND (("durationMinutes"=30 AND amount=0.5) OR ("durationMinutes"=60 AND amount=1))));
+ALTER TABLE "ReviewRewardCorrection" DROP CONSTRAINT IF EXISTS "ReviewRewardCorrection_amount_check";
+ALTER TABLE "ReviewRewardCorrection" ADD CONSTRAINT "ReviewRewardCorrection_amount_check" CHECK(amount IN (-0.5,-1));
+
+-- Resolve the source lesson using the same student and unambiguous study-to-appointment link.
+-- An explicit unsupported duration or a missing/conflicting source is never assumed to be 60 minutes.
+CREATE OR REPLACE FUNCTION xs_review_duration(co text, task_id text) RETURNS integer LANGUAGE sql STABLE AS $$
+ SELECT CASE WHEN count(*)=1 THEN min(CASE
+  WHEN a."durationMinutes" IN (30,60) THEN a."durationMinutes"::integer
+  WHEN COALESCE(a."durationMinutes",0)=0 THEN CASE a.dslx::text WHEN '1' THEN 30 WHEN '2' THEN 60 WHEN '3' THEN 60 END
+ END) END
+ FROM xs_review_task_link link
+ JOIN "CommonModel" c ON c.company=link.company AND c."modelId"=54 AND c."generalId"::text=link."appointmentId"
+ JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" AND a.szyh::text=link."studentId"
+ WHERE link.company=co AND link."taskId"=task_id
+$$;
+
+CREATE OR REPLACE FUNCTION xiaoshu_record_review_reward(task "MemoryPracticeRecord", historical boolean) RETURNS void
+LANGUAGE plpgsql AS $$
+DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text; eligibility text; minutes integer; actor_id text; actor_data jsonb; actor_uid text; actor_role text := 'unknown';
+BEGIN
+ IF task."company" IS NULL THEN RAISE EXCEPTION '复习任务缺少帐套'; END IF;
+ IF EXISTS (SELECT 1 FROM "ReviewReward" WHERE "company"=task."company" AND "taskId"=task."objectId") THEN RETURN; END IF;
+ SELECT * INTO coach FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=task."plid"::text LIMIT 1;
+ SELECT * INTO student FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=task."yhid"::text LIMIT 1;
+ SELECT * INTO store FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=coach."legacyUserData"->>'ParentUserID' LIMIT 1;
+ SELECT "nodeName" INTO course FROM "Node" WHERE "company"=task."company" AND "nodeId"::text=task."kcid"::text LIMIT 1;
+ BEGIN
+   -- The BEFORE trigger overwrites client time on every first live completion.
+   finished := NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai';
+ EXCEPTION WHEN OTHERS THEN finished := NULL; END;
+ IF task."rewardDuplicate" THEN problem := '来源复习任务重复';
+ ELSIF task."rewardTaskKey" IS NULL OR NOT EXISTS (SELECT 1 FROM "CommonModel" WHERE "company"=task."company" AND "modelId"::text='56' AND "generalId"::text=task."xxjlid"::text) THEN problem := '缺少有效来源学习记录或计划日期';
+ ELSIF coach."objectId" IS NULL OR COALESCE(task."plid"::text,'0') !~ '^[1-9][0-9]*$' THEN problem := '缺少有效复习老师';
+ ELSIF student."objectId" IS NULL THEN problem := '缺少有效学员';
+ ELSIF finished IS NULL THEN problem := '缺少有效完成时间'; END IF;
+ minutes := xs_review_duration(task.company,task."objectId");
+ IF NOT historical AND current_setting('xiaoshu.review_completion',true)=task.company||':'||task."objectId" THEN
+  actor_id := NULLIF(current_setting('xiaoshu.review_actor',true),'');
+  SELECT to_jsonb(u) INTO actor_data FROM "_User" u WHERE u.company=task.company AND u."objectId"=actor_id;
+  actor_uid := COALESCE(actor_data->>'legacyUserId',actor_data->'legacyUserData'->>'UserID');
+  IF actor_data IS NOT NULL AND COALESCE(actor_data->>'isDisabled','false')<>'true' AND COALESCE(actor_data->>'isDeleted','false')<>'true' THEN
+   IF actor_uid=task.yhid::text THEN actor_role:='student';
+   ELSIF actor_uid=task.plid::text AND COALESCE(actor_data->>'identityType','coach')='coach'
+    AND COALESCE(actor_data->>'legacyGroupId',actor_data->>'groupId',actor_data->'legacyUserData'->>'GroupID')='3' THEN actor_role:='coach'; END IF;
+  END IF;
+ END IF;
+ IF NOT historical AND actor_role='unknown' THEN problem:='缺少有效实际完成者身份';
+ ELSIF NOT historical AND actor_role='coach' AND minutes IS NULL THEN problem:='来源课次时长缺失或冲突,需核对'; END IF;
+ eligibility := xiaoshu_review_eligibility(left(task."kywrq"::text,10),finished);
+ IF problem='' AND eligibility='invalid_date' THEN problem := '缺少有效计划日期'; END IF;
+ reward_status := CASE WHEN NOT historical AND actor_role='student' THEN 'ineligible' WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' WHEN eligibility<>'eligible' THEN 'ineligible' ELSE 'earned' END;
+ INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception","ineligibleReason","completedBy","completionRole","durationMinutes","rateVersion")
+ VALUES (md5(task."company"||':review:'||task."objectId"),task."company",task."objectId",task."rewardTaskKey",
+ CASE WHEN task."plid"::text ~ '^[1-9][0-9]*$' THEN task."plid"::double precision ELSE 0 END,
+ COALESCE(NULLIF(coach."realName",''),NULLIF(coach."nickname",''),coach."legacyUserData"->>'HoneyName',coach."username",'未识别老师'),
+ CASE WHEN task."yhid"::text ~ '^[1-9][0-9]*$' THEN task."yhid"::double precision ELSE 0 END,
+ COALESCE(NULLIF(student."realName",''),NULLIF(student."nickname",''),student."legacyUserData"->>'HoneyName',student."username",'未识别学员'),COALESCE(course,''),
+ CASE WHEN coach."legacyUserData"->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach."legacyUserData"->>'ParentUserID')::double precision ELSE 0 END,
+ COALESCE(NULLIF(store."realName",''),NULLIF(store."nickname",''),store."legacyUserData"->>'HoneyName',store."username",'未绑定门店'),
+ COALESCE(left(task."kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),CASE WHEN reward_status='earned' THEN minutes::numeric/60 ELSE 0 END,reward_status,CASE WHEN actor_role='student' THEN '' ELSE problem END,CASE WHEN actor_role='student' THEN 'self_study' WHEN reward_status='ineligible' THEN eligibility ELSE '' END,actor_id,CASE WHEN historical THEN 'historical_unknown' ELSE actor_role END,minutes,'duration-actor-v2')
+ ON CONFLICT ("company","taskId") DO NOTHING;
+END $$;
+CREATE OR REPLACE FUNCTION xs_complete_assigned_review(co text, task_id text, actor text) RETURNS void LANGUAGE plpgsql AS $$
+DECLARE task record; u jsonb; uid text; previous_actor text:=COALESCE(current_setting('xiaoshu.review_actor',true),'');
+BEGIN
+ SELECT to_jsonb(t) INTO u FROM "_User" t WHERE t."company"=co AND t."objectId"=actor;
+ uid:=COALESCE(u->>'legacyUserId',u->'legacyUserData'->>'UserID');
+ IF u IS NULL OR u->>'isDisabled'='true' OR u->>'isDeleted'='true' THEN RAISE EXCEPTION '账号不可用'; END IF;
+ SELECT * INTO task FROM "MemoryPracticeRecord" WHERE "company"=co AND "objectId"=task_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
+ IF uid IS NULL OR (uid<>task."plid"::text AND uid<>task."yhid"::text) THEN RAISE EXCEPTION '复习已转交或不属于当前账号,请刷新列表'; END IF;
+ IF uid<>task.yhid::text AND (COALESCE(u->>'identityType','coach')<>'coach' OR COALESCE(u->>'legacyGroupId',u->>'groupId',u->'legacyUserData'->>'GroupID','')<>'3') THEN RAISE EXCEPTION '当前账号不是有效陪练'; END IF;
+ PERFORM set_config('xiaoshu.review_actor',actor,true);
+ PERFORM xiaoshu_complete_review(co,task_id);
+ PERFORM set_config('xiaoshu.review_actor',previous_actor,true);
+EXCEPTION WHEN OTHERS THEN
+ PERFORM set_config('xiaoshu.review_actor',previous_actor,true);
+ RAISE;
+END $$;
+CREATE OR REPLACE FUNCTION xiaoshu_reward_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP='UPDATE' AND current_setting('xiaoshu.review_reward_confirm',true)='yes' AND OLD."status"='historical' AND NEW."status"='earned' AND NEW."amount"=NEW."durationMinutes"::numeric/60 AND NEW."durationMinutes" IN (30,60) AND NEW."rateVersion"='duration-actor-v2' AND NEW."completionRole"='historical_verified' AND NULLIF(NEW."completedBy",'') IS NOT NULL AND (to_jsonb(NEW)-'status'-'amount'-'updatedAt'-'durationMinutes'-'rateVersion'-'completionRole'-'completedBy')=(to_jsonb(OLD)-'status'-'amount'-'updatedAt'-'durationMinutes'-'rateVersion'-'completionRole'-'completedBy') THEN RETURN NEW; END IF;
+ RAISE EXCEPTION '抗遗忘工资凭据不可改写或删除,请使用工资调整';
+END $$;
+CREATE OR REPLACE FUNCTION xs_review_reward_confirm(co text,actor text,ids jsonb,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE reward "ReviewReward"; task "MemoryPracticeRecord"; item text; finished timestamptz; confirmed integer:=0; already integer:=0; minutes integer;
+BEGIN
+ IF co IS NULL OR actor IS NULL OR length(trim(COALESCE(reason,'')))<2 OR jsonb_typeof(ids)<>'array' OR jsonb_array_length(ids) NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION '缺少帐套、核对原因或有效凭据'; END IF;
+ PERFORM xs_review_manager(co,actor);
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ FOR item IN SELECT DISTINCT value FROM jsonb_array_elements_text(ids) LOOP
+  SELECT * INTO reward FROM "ReviewReward" WHERE "company"=co AND "objectId"=item FOR UPDATE;
+  IF NOT FOUND THEN RAISE EXCEPTION '抗遗忘凭据不存在:%',item; END IF;
+  IF reward."status"='earned' AND reward."amount" IN (0.5,1) THEN already:=already+1;CONTINUE;END IF;
+  IF reward."status"<>'historical' OR reward."amount"<>0 OR COALESCE(reward."exception",'')<>'' THEN RAISE EXCEPTION '凭据不是可核对的历史完成记录:%',item; END IF;
+  SELECT * INTO task FROM "MemoryPracticeRecord" WHERE "company"=co AND "objectId"=reward."taskId" FOR UPDATE;
+  finished:=xiaoshu_safe_shanghai_timestamp(task."wcsj"::text);
+  IF task."objectId" IS NULL OR task."fxzt"::text<>'1' OR (COALESCE(task."sourceKey",'') NOT LIKE 'legacy:model:60:%' AND COALESCE(task."sourceKey",'') NOT LIKE 'legacy-sync:memory-records:addon:%') OR task."rewardDuplicate" IS TRUE OR reward."taskKey" IS NULL OR reward."taskKey"<>xiaoshu_review_key(task."yhid"::text,task."xxjlid"::text,left(task."kywrq"::text,10)) OR finished IS NULL OR xiaoshu_review_eligibility(reward."plannedDate",finished)<>'eligible' OR finished AT TIME ZONE 'Asia/Shanghai'<'2026-09-01'::timestamp OR reward."completedAt" IS DISTINCT FROM finished OR reward."incomeMonth"<>to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM') OR reward."coachId"::text<>task."plid"::text OR reward."studentId"::text<>task."yhid"::text THEN RAISE EXCEPTION '旧系统复习记录未通过来源、身份或完成时间核对:%',item; END IF;
+  IF NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE "company"=co AND "modelId"=56 AND "generalId"::text=task."xxjlid"::text) OR NOT EXISTS(SELECT 1 FROM "_User" WHERE "company"=co AND "legacyUserId"::text=task."plid"::text) OR NOT EXISTS(SELECT 1 FROM "_User" WHERE "company"=co AND "legacyUserId"::text=task."yhid"::text) OR EXISTS(SELECT 1 FROM "ReviewReward" WHERE "company"=co AND "taskKey"=reward."taskKey" AND "status"='earned') THEN RAISE EXCEPTION '旧系统复习记录缺少有效关联或存在重复计薪:%',item; END IF;
+  minutes:=xs_review_duration(co,task."objectId");
+  IF minutes IS NULL THEN RAISE EXCEPTION '来源课次时长缺失或冲突,不能确认计薪'; END IF;
+  PERFORM set_config('xiaoshu.review_reward_confirm','yes',true);
+  UPDATE "ReviewReward" SET "status"='earned',"amount"=minutes::numeric/60,"durationMinutes"=minutes,"rateVersion"='duration-actor-v2',"completionRole"='historical_verified',"completedBy"=actor WHERE "company"=co AND "objectId"=item;
+  PERFORM set_config('xiaoshu.review_reward_confirm','no',true);
+  PERFORM xs_payroll_audit(co,actor,'confirm-legacy-review-reward',item,reason,to_jsonb(reward),to_jsonb(reward)||jsonb_build_object('status','earned','amount',minutes::numeric/60,'durationMinutes',minutes,'rateVersion','duration-actor-v2','completionRole','historical_verified','completedBy',actor));
+  confirmed:=confirmed+1;
+ END LOOP;
+ RETURN jsonb_build_object('confirmed',confirmed,'alreadyConfirmed',already);
+END $$;
+
+CREATE TABLE IF NOT EXISTS "ReviewRewardRateCorrection" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, "rewardId" text NOT NULL REFERENCES "ReviewReward"("objectId"),
+ amount numeric(12,2) NOT NULL CHECK(amount=-0.5), "durationMinutes" integer NOT NULL CHECK("durationMinutes"=30),
+ reason text NOT NULL CHECK(length(trim(reason))>=2), actor text NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(),
+ UNIQUE(company,"rewardId")
+);
+CREATE OR REPLACE FUNCTION xs_review_rate_correction_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP<>'INSERT' OR current_setting('xiaoshu.review_rate_correction',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '时长纠正凭据只能由核对事务新增'; END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS review_rate_correction_immutable ON "ReviewRewardRateCorrection";
+CREATE TRIGGER review_rate_correction_immutable BEFORE INSERT OR UPDATE OR DELETE ON "ReviewRewardRateCorrection" FOR EACH ROW EXECUTE FUNCTION xs_review_rate_correction_guard();
+REVOKE ALL ON "ReviewRewardRateCorrection" FROM PUBLIC;
+CREATE OR REPLACE VIEW "ReviewRewardIncome" AS
+SELECT r."objectId",r.company,r."taskId",r."taskKey",r."coachId",r."coachName",r."studentId",r."studentName",r."courseName",r."storeId",r."storeName",r."plannedDate",r."completedAt",r."incomeMonth",
+ (r.amount+COALESCE(c.amount,0)+COALESCE(rc.amount,0))::numeric(12,2) AS amount,
+ CASE WHEN c."objectId" IS NULL THEN r.status ELSE 'ineligible' END AS status,
+ r.exception,r."createdAt",CASE WHEN c."objectId" IS NULL THEN r."ineligibleReason" ELSE 'corrected_overdue' END AS "ineligibleReason",
+ r.amount AS "originalAmount",COALESCE(c."objectId",rc."objectId") AS "correctionId",
+ NULLIF(concat_ws(';',rc.reason,c.reason),'') AS "correctionReason",
+ CASE WHEN c."objectId" IS NOT NULL OR rc."objectId" IS NOT NULL THEN COALESCE(c.amount,0)+COALESCE(rc.amount,0) END::numeric(12,2) AS "correctionAmount",c."adjustmentBatchId"
+FROM "ReviewReward" r
+LEFT JOIN "ReviewRewardCorrection" c ON c.company=r.company AND c."rewardId"=r."objectId"
+LEFT JOIN "ReviewRewardRateCorrection" rc ON rc.company=r.company AND rc."rewardId"=r."objectId";
+
+-- Deliberately bounded to unbatched receipts: paid or drafted wages need their own settlement adjustment.
+CREATE OR REPLACE FUNCTION xs_review_correct_unbatched_rates(co text, actor text, ids jsonb, reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE item text; r "ReviewReward"; corrected integer:=0; already integer:=0; previous text:=COALESCE(current_setting('xiaoshu.review_rate_correction',true),'');
+BEGIN
+ PERFORM xs_review_manager(co,actor);
+ IF length(trim(COALESCE(reason,'')))<2 OR jsonb_typeof(ids) IS DISTINCT FROM 'array' OR jsonb_array_length(ids) NOT BETWEEN 1 AND 500 THEN RAISE EXCEPTION '缺少有效核对清单或原因'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ FOR item IN SELECT DISTINCT value FROM jsonb_array_elements_text(ids) ORDER BY value LOOP
+  SELECT * INTO r FROM "ReviewReward" WHERE company=co AND "objectId"=item FOR UPDATE;
+  IF NOT FOUND THEN RAISE EXCEPTION '工资凭据不存在'; END IF;
+  IF EXISTS(SELECT 1 FROM "ReviewRewardRateCorrection" WHERE company=co AND "rewardId"=item) THEN already:=already+1;CONTINUE; END IF;
+  IF r.status<>'earned' OR r.amount<>1 OR xs_review_duration(co,r."taskId") IS DISTINCT FROM 30 OR EXISTS(SELECT 1 FROM "ReviewRewardCorrection" WHERE company=co AND "rewardId"=item) THEN RAISE EXCEPTION '凭据不符合30分钟时长纠正条件'; END IF;
+  IF EXISTS(SELECT 1 FROM "PayrollLine" WHERE company=co AND "reviewRewardId"=item AND NOT COALESCE(excluded,false)) THEN RAISE EXCEPTION '凭据已进入工资单,请单独核对结算调整'; END IF;
+  PERFORM set_config('xiaoshu.review_rate_correction','yes',true);
+  INSERT INTO "ReviewRewardRateCorrection"("objectId",company,"rewardId",amount,"durationMinutes",reason,actor)
+  VALUES(md5(co||':review-rate-v2:'||item),co,item,-0.5,30,reason,actor);
+  PERFORM xs_payroll_audit(co,actor,'correct-review-duration',item,reason,to_jsonb(r),jsonb_build_object('amount',0.5,'adjustment',-0.5,'durationMinutes',30));
+  corrected:=corrected+1;
+ END LOOP;
+ PERFORM set_config('xiaoshu.review_rate_correction',previous,true);
+ RETURN jsonb_build_object('corrected',corrected,'alreadyCorrected',already,'adjustment',-0.5*corrected);
+EXCEPTION WHEN OTHERS THEN
+ PERFORM set_config('xiaoshu.review_rate_correction',previous,true);RAISE;
+END $$;
+REVOKE ALL ON FUNCTION xs_review_correct_unbatched_rates(text,text,jsonb,text) FROM PUBLIC;
+
+CREATE OR REPLACE FUNCTION xs_review_reward_correct(co text,actor text,reward_id text,reason text,target_batch text,expected integer) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "ReviewReward"; correction "ReviewRewardCorrection"; source_line "PayrollLine"; source_batch "PayrollBatch"; target_doc "PayrollBatch"; result jsonb; correction_id text:=xs_object_id(); request_id text;
+BEGIN
+ IF length(trim(COALESCE(reason,'')))<2 THEN RAISE EXCEPTION '请填写工资纠正依据'; END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."objectId"=actor AND NOT COALESCE((to_jsonb(u)->>'isDisabled')::boolean,false) AND NOT COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false) AND (to_jsonb(u)->>'adminRoleKey' IN ('super-admin','ops-manager') OR to_jsonb(u)->'roles' ? 'ops-manager')) THEN RAISE EXCEPTION '仅管理人员可以纠正工资'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ SELECT * INTO correction FROM "ReviewRewardCorrection" WHERE company=co AND "rewardId"=reward_id;
+ IF FOUND THEN
+  IF correction.reason IS DISTINCT FROM reason OR COALESCE(correction."adjustmentBatchId",'') IS DISTINCT FROM COALESCE(target_batch,'') THEN RAISE EXCEPTION '该工资已纠正,请刷新核对原调整'; END IF;
+  RETURN to_jsonb(correction)||jsonb_build_object('unchanged',true);
+ END IF;
+ SELECT * INTO r FROM "ReviewReward" WHERE company=co AND "objectId"=reward_id FOR UPDATE;
+ IF NOT FOUND OR r.status<>'earned' OR r.amount NOT IN (0.5,1) OR xiaoshu_review_eligibility(r."plannedDate",r."completedAt")<>'overdue' THEN RAISE EXCEPTION '仅已核实的逾期错计凭据可纠正'; END IF;
+ SELECT amount INTO r.amount FROM "ReviewRewardIncome" WHERE company=co AND "objectId"=reward_id;
+ IF r.amount NOT IN (0.5,1) THEN RAISE EXCEPTION '该凭据已无可纠正收入'; END IF;
+ SELECT * INTO source_line FROM "PayrollLine" WHERE company=co AND "reviewRewardId"=reward_id AND kind='review_reward' AND NOT COALESCE(excluded,false) FOR UPDATE;
+ IF FOUND THEN SELECT * INTO source_batch FROM "PayrollBatch" WHERE company=co AND "objectId"=source_line."batchId" FOR UPDATE; END IF;
+ IF source_batch.status IN ('reviewed','paid') THEN
+  SELECT * INTO target_doc FROM "PayrollBatch" WHERE company=co AND "objectId"=target_batch FOR UPDATE;
+  IF NOT FOUND OR target_doc.status<>'draft' OR target_doc.version IS DISTINCT FROM expected THEN RAISE EXCEPTION '请选择有效草稿工资单并刷新版本'; END IF;
+  request_id:='review-correction:'||reward_id;
+  result:=xs_payroll_command(co,actor,'adjustment',target_batch,jsonb_build_object('version',expected,'coachId',r."coachId",'amount',-r.amount,'reason','复习工资纠正 ['||reward_id||']:'||reason),request_id);
+ ELSE
+  IF NULLIF(target_batch,'') IS NOT NULL THEN RAISE EXCEPTION '尚未审核的工资只冲销来源,不应重复扣减结算'; END IF;
+  IF source_batch.status='draft' THEN
+   PERFORM set_config('xiaoshu.payroll_write','yes',true);
+   DELETE FROM "PayrollLine" WHERE company=co AND "objectId"=source_line."objectId";
+   UPDATE "PayrollBatch" SET "updatedAt"=now() WHERE company=co AND "objectId"=source_batch."objectId";
+  ELSIF source_line."objectId" IS NOT NULL THEN RAISE EXCEPTION '原工资单状态异常,请先核对'; END IF;
+ END IF;
+ PERFORM set_config('xiaoshu.review_correction','yes',true);
+ INSERT INTO "ReviewRewardCorrection"("objectId",company,"rewardId",amount,reason,actor,"sourceBatchId","adjustmentBatchId","adjustmentRequestId")
+ VALUES(correction_id,co,reward_id,-r.amount,reason,actor,source_batch."objectId",NULLIF(target_batch,''),request_id) RETURNING * INTO correction;
+ PERFORM set_config('xiaoshu.review_correction','no',true);
+ PERFORM xs_payroll_audit(co,actor,'correct-review-reward',reward_id,reason,to_jsonb(r),to_jsonb(correction));
+ RETURN to_jsonb(correction);
+END $$;
+CREATE OR REPLACE FUNCTION xiaoshu_payroll_reward_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE b "PayrollBatch"; r "ReviewReward";
+BEGIN
+ IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资明细只能通过工资业务事务修改'; END IF;
+ IF TG_OP='DELETE' THEN SELECT * INTO b FROM "PayrollBatch" WHERE "company"=OLD."company" AND "objectId"=OLD."batchId" FOR UPDATE;
+ ELSE SELECT * INTO b FROM "PayrollBatch" WHERE "company"=NEW."company" AND "objectId"=NEW."batchId" FOR UPDATE; END IF;
+ IF b."objectId" IS NULL OR b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定或不存在'; END IF;
+ IF TG_OP IN ('UPDATE','DELETE') AND EXISTS(SELECT 1 FROM "ReviewRewardCorrection" c WHERE c.company=OLD.company AND OLD."sourceKey"='adjustment:'||c."adjustmentRequestId") THEN RAISE EXCEPTION '工资纠正的调整行不可改写或删除'; END IF;
+ IF TG_OP='DELETE' THEN RETURN OLD; END IF;
+ IF TG_OP='UPDATE' AND ROW(OLD."company",OLD."batchId",OLD."kind",OLD."reviewRewardId",OLD."standardLessonKey") IS DISTINCT FROM ROW(NEW."company",NEW."batchId",NEW."kind",NEW."reviewRewardId",NEW."standardLessonKey") THEN RAISE EXCEPTION '不能修改工资明细归属'; END IF;
+ IF NEW."kind" IS NULL OR NEW."kind" NOT IN ('lesson','review_reward','adjustment') OR NEW."amount" IS NULL OR NEW."amount"::text IN ('NaN','Infinity','-Infinity') OR round(NEW."amount"::numeric,2)<>NEW."amount"::numeric THEN RAISE EXCEPTION '工资类型或金额无效'; END IF;
+ IF NEW."kind"='lesson' AND NULLIF(NEW."standardLessonKey",'') IS NULL THEN RAISE EXCEPTION '课次缺少标准标识'; END IF;
+ NEW."settlementMonth":=b."month";
+ IF NEW."kind"='review_reward' THEN
+  SELECT (jsonb_populate_record(NULL::"ReviewReward",to_jsonb(income))).* INTO r FROM "ReviewRewardIncome" income WHERE "company"=b."company" AND "objectId"=NEW."reviewRewardId";
+  IF EXISTS(SELECT 1 FROM "ReviewRewardCorrection" WHERE company=b.company AND "rewardId"=r."objectId") THEN RAISE EXCEPTION '复习工资已纠正,不可重新计薪'; END IF;
+  IF r."status" IS DISTINCT FROM 'earned' OR r."incomeMonth">b."month" OR NEW."excluded" THEN RAISE EXCEPTION '抗遗忘工资不可结算或排除'; END IF;
+  NEW."coachId":=r."coachId";NEW."coachName":=r."coachName";NEW."studentName":=r."studentName";NEW."courseName":=r."courseName";
+  NEW."storeId":=r."storeId";NEW."storeName":=r."storeName";NEW."reviewTaskId":=r."taskId";NEW."plannedDate":=r."plannedDate";
+  NEW."amount":=r."amount";NEW."rateAmount":=r."amount";NEW."durationHours":=0;NEW."originalMonth":=r."incomeMonth";
+  NEW."lessonAt":=r."completedAt";NEW."exception":='';NEW."classType":=0;NEW."courseCategoryName":='抗遗忘工资';
+ END IF;
+ RETURN NEW;
+END $$;
+COMMIT;

+ 1 - 1
scripts/teacher-earnings-migration.mjs

@@ -1,5 +1,5 @@
 import {readFileSync} from 'node:fs';
 export function teacherEarningsMigration(){
- const files=['review-rewards','lesson-workflow','lesson-records','payroll-integrity'];
+ const files=['review-rewards','lesson-workflow','lesson-records','payroll-integrity','review-pay-policy'];
  return 'BEGIN;\n'+files.map(name=>readFileSync(new URL('./sql/'+name+'.sql',import.meta.url),'utf8').replace(/^BEGIN;\s*$/gm,'').replace(/^COMMIT;\s*$/gm,'')).join('\n')+'\nCOMMIT;';
 }

+ 113 - 0
scripts/tests/review-pay-policy.test.mjs

@@ -0,0 +1,113 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite(),q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
+const sql=name=>readFileSync(new URL('../sql/'+name+'.sql',import.meta.url),'utf8');
+const today=()=>new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
+const complete=async(id,actor='coach')=>(await one('SELECT xs_review_complete_result($1,$2,$3) result',['c',id,actor])).result;
+const receipt=async id=>one('SELECT * FROM "ReviewRewardIncome" WHERE "taskId"=$1',[id]);
+let sequence=100;
+async function task(name,{minutes=60,type='2',day=today(),student=20,source='',done=false}={}){
+ const id=++sequence;
+ await q(`INSERT INTO "CourseAppointment"("objectId",company,id,szyh,dslx,"durationMinutes") VALUES($1,'c',$2,$3,$4,$5)`,[name+'-appointment',id,String(student),type,minutes]);
+ await q(`INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId") VALUES($1,'c',54,$2,$2),($3,'c',56,$2,$2)`,[name+'-appointment-common',id,name+'-study-common']);
+ await q(`INSERT INTO "DailyStudyRecord"("objectId",company,id,"userId",dsid) VALUES($1,'c',$2,$3,$4)`,[name+'-study',id,student,String(id)]);
+ await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey") VALUES($1,'c',$2,10,$3,$2,$2,$4,$4,30,$5,$6,$7)`,[name,id,student,day,done?'1':'0',done?day+' 10:00:00':'',source]);
+ return id;
+}
+before(async()=>{
+ await db.exec(readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8'));
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const schemas=vm.runInNewContext('('+source.slice(source.indexOf('async function ensurePayrollSchemas()')).match(/const schemas = (\{[\s\S]*?\n  });/)[1]+')');
+ for(const [name,fields] of Object.entries(schemas)){
+  if(name==='CommonModel')continue;
+  await db.exec(`CREATE TABLE IF NOT EXISTS "${name}" ("objectId" text PRIMARY KEY,"createdAt" timestamptz,"updatedAt" timestamptz)`);
+  const existing=new Set((await q('SELECT column_name FROM information_schema.columns WHERE table_name=$1',[name])).map(r=>r.column_name));
+  for(const [key,v] of Object.entries(fields))if(!existing.has(key))await db.exec(`ALTER TABLE "${name}" ADD COLUMN "${key}" ${({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text'}`);
+ }
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN szyh text,ADD COLUMN kcid text,ADD COLUMN pl text,ADD COLUMN fxpl text,ADD COLUMN dszt text,ADD COLUMN dslx text,ADD COLUMN jffs text,ADD COLUMN jssj text,ADD COLUMN kssj text,ADD COLUMN yysj text,ADD COLUMN bxrq text,ADD COLUMN sdsd text,ADD COLUMN plxm text,ADD COLUMN yykcid text;
+ CREATE TABLE "CourseBinding"("objectId" text PRIMARY KEY,company text,id numeric,yhid text,kcid text,"sourceKey" text);
+ CREATE TABLE "SysLog"("objectId" text PRIMARY KEY,company text,"sourceKey" text NOT NULL UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,cdate timestamptz,cname text,type1 text,type2 text,type3 text,clevel numeric,"cadminId" numeric,detail text,content1 text,content2 text);
+ INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","adminRoleKey","legacyUserData") VALUES
+ ('manager','c',1,1,'member','ops-manager','{}'),('coach','c',10,3,'coach',null,'{}'),('student','c',20,1,'member',null,'{}'),('other-coach','c',11,3,'coach',null,'{}'),('other-tenant','x',10,3,'coach',null,'{}');
+ INSERT INTO "Node" VALUES('c',30,'单词课');`);
+ 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(sql('member-enrollment'));await db.exec(sql('review-rewards'));await db.exec(sql('lesson-records'));await db.exec(sql('payroll-integrity'));
+ await task('old-rate',{minutes:30});await complete('old-rate');
+ await task('old-overdue',{minutes:30,day:'2026-09-01'});
+ const old=await receipt('old-rate');
+ await q(`INSERT INTO "ReviewReward"("objectId",company,"taskId","coachId","studentId","plannedDate","completedAt","incomeMonth",amount,status) VALUES('old-overdue-receipt','c','old-overdue',10,20,'2026-09-01','2026-09-02 10:00+08','2026-09',1,'earned')`);
+ assert.equal(Number(old.amount),1);
+ await db.exec(sql('review-pay-policy'));
+});
+after(()=>db.close());
+test('30分钟0.5元、60分钟1元,按排课快照优先并记录实际老师身份',async()=>{
+ for(const [name,minutes,type,expected] of [['thirty',30,'2',.5],['sixty',60,'1',1],['legacy30',null,'1',.5],['legacy60',null,'2',1]]){
+  await task(name,{minutes,type});const r=await complete(name);assert.equal(Number(r.amount),expected);assert.equal(r.rewardStatus,'earned');
+  const snapshot=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name]);assert.equal(snapshot.completedBy,'coach');assert.equal(snapshot.completionRole,'coach');assert.equal(snapshot.durationMinutes,expected*60);
+  assert.equal((await complete(name)).unchanged,true);assert.equal((await q('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name])).length,1);
+ }
+});
+test('学生自行复习保存完成进度但零工资;老师随后重试不能补记工资,旧分组3会员也不计薪',async()=>{
+ for(const group of [1,3]){
+  await q('UPDATE "_User" SET "legacyGroupId"=$1 WHERE "objectId"=\'student\'',[group]);
+  const id='self-'+group;await task(id,{minutes:30});const result=await complete(id,'student');
+  assert.equal(Number(result.amount),0);assert.equal(result.ineligibleReason,'self_study');assert.equal(result.rewardStatus,'ineligible');
+  assert.equal((await one('SELECT fxzt FROM "MemoryPracticeRecord" WHERE "objectId"=$1',[id])).fxzt,'1');
+  assert.equal(Number((await complete(id,'coach')).amount),0);
+  assert.equal((await one('SELECT "completionRole" FROM "ReviewReward" WHERE "taskId"=$1',[id])).completionRole,'student');
+ }
+});
+test('跨账号、跨帐套及停用账号拒绝;未认证的底层完成不能计薪,身份上下文不泄漏',async()=>{
+ await task('protected');
+ await assert.rejects(complete('protected','other-coach'),/不属于/);await assert.rejects(complete('protected','other-tenant'),/账号不可用/);
+ await q(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='coach'`);await assert.rejects(complete('protected'),/账号不可用/);await q(`UPDATE "_User" SET "isDisabled"=false WHERE "objectId"='coach'`);
+ await task('no-actor');await q(`SELECT xiaoshu_complete_review('c','no-actor')`);assert.equal((await receipt('no-actor')).status,'exception');assert.equal(Number((await receipt('no-actor')).amount),0);
+ assert.equal((await one("SELECT COALESCE(current_setting('xiaoshu.review_actor',true),'') value")).value,'');
+});
+test('逾期零元、未来拒绝、缺失或冲突时长不默认计薪',async()=>{
+ await task('overdue',{minutes:30,day:'2001-01-01'});assert.equal((await complete('overdue')).ineligibleReason,'overdue');
+ await task('future',{day:'2099-01-01'});await assert.rejects(complete('future'),/未到期/);
+ for(const id of ['missing','unsupported','mismatch']){
+  await task(id,{minutes:id==='unsupported'?45:30});
+  if(id==='missing')await q(`DELETE FROM "CourseAppointment" WHERE "objectId"=$1`,[id+'-appointment']);
+  if(id==='mismatch')await q(`UPDATE "MemoryPracticeRecord" SET "orderId"=99999 WHERE "objectId"=$1`,[id]);
+  assert.equal((await complete(id)).rewardStatus,'exception');assert.equal(Number((await receipt(id)).amount),0);
+ }
+});
+test('旧系统历史完成保持零元;人工核对后按课时时长计薪,不允许来源不明确认',async()=>{
+ await task('imported',{minutes:30,day:'2026-09-05',source:'legacy:model:60:general:900',done:true});
+ const r=await receipt('imported');assert.equal(r.status,'historical');assert.equal(Number(r.amount),0);
+ const confirm=async(id,actor='manager')=>(await one('SELECT xs_review_reward_confirm(\'c\',$1,$2::jsonb,\'核实老师完成且未发放\') result',[actor,JSON.stringify([id])])).result;
+ await assert.rejects(confirm(r.objectId,'student'),/仅管理/);
+ assert.equal((await confirm(r.objectId)).confirmed,1);assert.equal(Number((await receipt('imported')).amount),.5);assert.equal((await confirm(r.objectId)).alreadyConfirmed,1);
+});
+test('历史时长差额以独立凭据纠正,幂等、不改原凭据,收入和工资明细消费净金额',async()=>{
+ const r=await receipt('old-rate');
+ const original=await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]);
+ const correct=async(ids)=>(await one(`SELECT xs_review_correct_unbatched_rates('c','manager',$1::jsonb,'按30分钟0.5元核实') result`,[JSON.stringify(ids)])).result;
+ await assert.rejects(correct([r.objectId,'missing']),/不存在/);assert.equal(Number((await receipt('old-rate')).amount),1);
+ assert.equal((await correct([r.objectId])).corrected,1);assert.equal((await correct([r.objectId])).alreadyCorrected,1);
+ assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]),original);
+ assert.equal(Number((await receipt('old-rate')).amount),.5);
+ await assert.rejects(q(`UPDATE "ReviewRewardRateCorrection" SET amount=-1`),/只能/);
+ const ctx=vm.createContext({Date,Psql:{one,query:q}});vm.runInContext(readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),ctx);
+ const expected=(await one(`SELECT sum(amount) total FROM "ReviewRewardIncome" WHERE company='c' AND status='earned' AND "coachId"=10`)).total;
+ assert.equal(await ctx.reviewIncomeTotal('c',10),Number(expected));
+ const lines=await ctx.reviewRewardPayrollLines('c','2099-01',false);assert.equal(lines.find(l=>l.reviewTaskId==='old-rate').amount,.5);
+ const page=await ctx.reviewRewardPage('c',{});assert.equal(page.items.find(i=>i.taskId==='old-rate').statusLabel,'已按课时时长纠正');assert.equal(page.items.find(i=>i.taskId==='self-1').statusLabel,'学生自习,不计薪');
+ await q(`SELECT set_config('xiaoshu.payroll_write','yes',false)`);
+ await q(`INSERT INTO "PayrollBatch"("objectId",company,month,status) VALUES('draft','c','2099-01','draft')`);
+ await q(`INSERT INTO "PayrollLine"("objectId",company,"batchId",kind,"reviewRewardId",amount) VALUES('line','c','draft','review_reward',$1,99)`,[r.objectId]);
+ assert.equal(Number((await one(`SELECT amount FROM "PayrollLine" WHERE "objectId"='line'`)).amount),.5);
+ await q(`SELECT set_config('xiaoshu.payroll_write','no',false)`);
+});
+test('时长差额纠正后仍能纠正逾期余额,累计扣减不超过原金额;迁移重跑不改变收据',async()=>{
+ const r=await receipt('old-overdue');
+ await one(`SELECT xs_review_correct_unbatched_rates('c','manager',$1::jsonb,'核实时长')`,[JSON.stringify([r.objectId])]);
+ const result=await one(`SELECT xs_review_reward_correct('c','manager',$1,'核实逾期','',null) result`,[r.objectId]);assert.equal(Number(result.result.amount),-.5);
+ assert.equal(Number((await receipt('old-overdue')).amount),0);assert.equal((await receipt('old-overdue')).status,'ineligible');
+ const before=await q('SELECT * FROM "ReviewRewardIncome" ORDER BY "objectId"');await db.exec(sql('review-pay-policy'));assert.deepEqual(await q('SELECT * FROM "ReviewRewardIncome" ORDER BY "objectId"'),before);
+});