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

Guard payroll settlement on legacy sync and reconcile review rewards

彭峰 4 часов назад
Родитель
Сommit
efd1a97d80

+ 17 - 0
docs/migration/payroll-sync-rollout.md

@@ -0,0 +1,17 @@
+# 旧系统同步与工资结算上线清单
+
+本次代码只在三类旧系统数据(预约 `54`、上课 `59`、抗遗忘 `60`)均已完成全量、增量水位追平、旧库与新库有效业务 ID 集合的 SHA-256 一致、最近成功同步不超过 120 秒且无待处理失败或冲突时,允许生成、刷新或审核工资草稿。页面上的「查询于」不是旧库同步时间。
+
+## 上线顺序
+
+1. 旧 SQL Server 执行 `legacy-sync-bridge/database/install.sql` 和 `verify-readonly.sql`,确认 12 个变更触发器、增量日志和 `WritesFailClosed=1`。部署只读 IIS 桥;按 `legacy-sync-bridge/README.md` 的预检、安装和签名测试完成 `/xiaoshu-sync/v1/health`、`manifest`、`changes` 验证。当前线上健康地址返回 404,在解决前不得放开结算。
+2. 配置云函数环境的 `XIAOSHU_LEGACY_SYNC_BASE_URL`(IIS 应用根路径,不含 `/v1`)、`XIAOSHU_LEGACY_SYNC_KEY_ID`、`XIAOSHU_LEGACY_SYNC_SECRET`;不启用旧系统写入开关。先执行复习奖励迁移,再执行工资完整性迁移,最后发布网关与管理后台。所有密钥仅放服务端安全配置。
+3. 用同步中心对 `appointments`、`lessons`、`memory-records` 分别执行全量补同步(`full=true`),直到每个数据集 `hasMore=false`;依次处理失败队列和冲突。核对三组业务 `GeneralID` 数量、SHA-256、增量水位,并抽查 9 月跨月、作废、重复课次和旧复习完成记录。
+4. 以超级管理员会话启动 `scripts/run-legacy-sync-worker.mjs`(默认 5 秒)。运行器会先追平学习记录再处理抗遗忘、先追平预约再处理上课,避免缺少来源时生成不可计薪的异常凭据。仅当三组 checkpoint 均为 `healthy`、worker 健康检查为 200 且工资接口 `sourceStatus.sync.settlementReady=true` 时,按老师逐条对照旧系统 9 月完课与工资明细。若数据继续变化,重复对账。
+5. 管理员在「抗遗忘工资 → 历史待核对」中逐条确认 2026-09-01 起旧系统完成、且确认没有历史发放的凭据;操作会复核任务、时间、老师、学生、来源学习记录及重复项,并写审计日志。9 月前记录保持零元。刷新工资草稿后再审核;已审核或已发放批次不得重开,差异走下期调整。
+
+## 故障处理
+
+- IIS 桥、同步进程或云函数配置故障时,可查看新库已有统计,但必须标明来源延迟,服务端会拒绝生成、刷新和审核。不要将「查询时间」或 `catching_up` 视作追平。
+- 失败队列、冲突或主键校验和不一致时,先定位旧业务 ID 和附表写入失败,再重试同步;不得为了结算手动改 checkpoint、水位或工资凭据。
+- 生产发布前复跑 `npm run test:payroll-integrity`、`npm run test:review-rewards`、`npm run cloud:validate` 和 `npm run build:admin`。旧 IIS/SQL Server 部署权限、云函数配置权限与 9 月旧库对账数据均是正式验收的必要条件。

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

@@ -852,6 +852,12 @@ export interface PayrollSourceStatus extends Record<string, unknown> {
   message: string;
   message: string;
   total: number;
   total: number;
   refreshedAt: string;
   refreshedAt: string;
+  sync?: {
+    state: 'not_configured' | 'synced' | 'delayed' | 'inconsistent' | 'error';
+    settlementReady: boolean; message: string; checkedAt: string;
+    lastSuccessAt: string; lagSeconds: number | null;
+    datasets: Array<{ key: string; state: string; lastSuccessAt: string; lagSeconds: number | null; pendingFailures: number; conflicts: number; legacyCount: number | null; localCount: number | null; complete?: boolean }>;
+  };
 }
 }
 
 
 export interface PayrollOverview extends PayrollDetail {
 export interface PayrollOverview extends PayrollDetail {
@@ -966,6 +972,7 @@ export interface ReviewReward {
   courseName: string; storeId: number; storeName: string; plannedDate: string; completedAt: string;
   courseName: string; storeId: number; storeName: string; plannedDate: string; completedAt: string;
   incomeMonth: string; amount: number; status: 'earned' | 'historical' | 'exception';
   incomeMonth: string; amount: number; status: 'earned' | 'historical' | 'exception';
   statusLabel: string; exception: string; batchId?: string; settlementMonth?: string;
   statusLabel: string; exception: string; batchId?: string; settlementMonth?: string;
+  canConfirm?: boolean;
 }
 }
 export interface ReviewRewardPage {
 export interface ReviewRewardPage {
   snapshot?: string; nextCursor?: string;
   snapshot?: string; nextCursor?: string;

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

@@ -148,6 +148,10 @@ export class OperationsService {
     return this.functions.operations('ops/payroll/review-rewards', { ...filters, page, pageSize, status });
     return this.functions.operations('ops/payroll/review-rewards', { ...filters, page, pageSize, status });
   }
   }
 
 
+  confirmLegacyReviewRewards(rewardIds: string[], reason: string): Promise<{ confirmed: number; alreadyConfirmed: number }> {
+    return this.mutate('ops/payroll/review-rewards/confirm', '', reason, { rewardIds });
+  }
+
   payrolls(page = 1, pageSize = 20, month = ''): Promise<OperationsPage<PayrollSummary>> {
   payrolls(page = 1, pageSize = 20, month = ''): Promise<OperationsPage<PayrollSummary>> {
     return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize, month });
     return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize, month });
   }
   }

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


+ 4 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss

@@ -291,6 +291,10 @@
   box-shadow: 0 0 0 4px rgba(217, 45, 32, .1);
   box-shadow: 0 0 0 4px rgba(217, 45, 32, .1);
 }
 }
 
 
+.sync-status{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;padding:12px 16px;border:1px solid #b9dfd2;border-radius:12px;background:#edf9f5;color:#176c55;font-size:13px}
+.sync-status span{color:#486a5e}.sync-status small{flex-basis:100%;color:#58776c}
+.sync-status.delayed{border-color:#efc7bd;background:#fff4f0;color:#ad3b2f}.sync-status.delayed span,.sync-status.delayed small{color:#8a5149}
+
 .payroll-metrics {
 .payroll-metrics {
   display: grid;
   display: grid;
   grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
   grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));

+ 4 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts

@@ -97,4 +97,8 @@ describe('payroll interactions', () => {
   let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));
   let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));
   const old=component.loadBatchPage(2);await component.loadBatchPage(3);resolveOld({items:[],total:50,page:2,pageSize:20});await old;expect(component.page()?.page).toBe(3);
   const old=component.loadBatchPage(2);await component.loadBatchPage(3);resolveOld({items:[],total:50,page:2,pageSize:20});await old;expect(component.page()?.page).toBe(3);
  });
  });
+ it('does not create a payroll draft when the legacy source is not settled',async()=>{
+  component.overview.set(overview('2026-09'));
+  await component.create();expect(service.mutate).not.toHaveBeenCalled();expect(component.error()).toContain('旧系统数据尚未追平');
+ });
 });
 });

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts

@@ -67,13 +67,13 @@ export class OperationsPayrollComponent implements OnInit,OnDestroy{
   }finally{if(!background&&request===this.loadRequest)this.loading.set(false);}
   }finally{if(!background&&request===this.loadRequest)this.loading.set(false);}
  }
  }
 
 
- async refreshLive():Promise<void>{if(this.tab()==='rewards'){this.openRewards();return;}await this.load(true);}
+ async refreshLive():Promise<void>{if(this.refreshing())return;this.refreshing.set(true);try{if(this.tab()==='rewards'){this.openRewards();return;}await this.load(true);}finally{this.refreshing.set(false);}}
  async resetFilters():Promise<void>{this.resetRevision++;this.closeDrawer();this.search.set('');this.storeId.set(0);this.rewardCoachId.set(0);await this.applyFilters();}
  async resetFilters():Promise<void>{this.resetRevision++;this.closeDrawer();this.search.set('');this.storeId.set(0);this.rewardCoachId.set(0);await this.applyFilters();}
  async applyFilters():Promise<void>{this.message.set('');if(this.tab()==='batches')this.allBatchMonths.set(false);if(this.tab()==='rewards'){this.openRewards();return;}await this.load(true);}
  async applyFilters():Promise<void>{this.message.set('');if(this.tab()==='batches')this.allBatchMonths.set(false);if(this.tab()==='rewards'){this.openRewards();return;}await this.load(true);}
  async open(row:PayrollSummary):Promise<void>{const request=++this.openRequest;this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);const detail=await this.operations.payrollDetail(row.objectId);if(request===this.openRequest)this.detail.set(detail);}catch(error){if(request===this.openRequest)this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{if(request===this.openRequest)this.loading.set(false);}}
  async open(row:PayrollSummary):Promise<void>{const request=++this.openRequest;this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);const detail=await this.operations.payrollDetail(row.objectId);if(request===this.openRequest)this.detail.set(detail);}catch(error){if(request===this.openRequest)this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{if(request===this.openRequest)this.loading.set(false);}}
  coachLabel(name:string,coachId:number):string{const value=String(name||'').trim();return !value||value===String(coachId)?`陪练 ${coachId}`:value;}
  coachLabel(name:string,coachId:number):string{const value=String(name||'').trim();return !value||value===String(coachId)?`陪练 ${coachId}`:value;}
  openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.selectedCoachName.set(this.coachLabel(row.coachName,row.coachId));this.detail.set(null);}closeDrawer():void{this.openRequest++;this.detail.set(null);this.selectedCoachId.set(0);this.selectedCoachName.set('');}
  openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.selectedCoachName.set(this.coachLabel(row.coachName,row.coachId));this.detail.set(null);}closeDrawer():void{this.openRequest++;this.detail.set(null);this.selectedCoachId.set(0);this.selectedCoachName.set('');}
- async create():Promise<void>{if(this.saving())return;if(!this.overview()?.sourceStatus.available){this.error.set('实时上课记录不可用,不能生成工资草稿');return;}const generatedMonth=this.month();this.saving.set(true);this.error.set('');try{const result=await this.payrollCommand<PayrollSummary>('ops/payroll/create','',`生成 ${generatedMonth} 月全公司工资草稿`,{month:generatedMonth});await this.load();await this.open(result);this.message.set(`${this.monthLabelOf(generatedMonth)}全公司工资草稿已生成,尚未审核或发放`);this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
+ async create():Promise<void>{if(this.saving())return;if(!this.overview()?.sourceStatus.sync?.settlementReady){this.error.set(this.overview()?.sourceStatus.sync?.message||'旧系统数据尚未追平,不能生成工资草稿');return;}const generatedMonth=this.month();this.saving.set(true);this.error.set('');try{const result=await this.payrollCommand<PayrollSummary>('ops/payroll/create','',`生成 ${generatedMonth} 月全公司工资草稿`,{month:generatedMonth});await this.load();await this.open(result);this.message.set(`${this.monthLabelOf(generatedMonth)}全公司工资草稿已生成,尚未审核或发放`);this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
  async saveRate():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
  async saveRate():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
  creditCosts(categoryKey=this.ruleCategoryKey(),duration=this.ruleDuration()):Array<{account:TeachingCreditAccount;amount:number}>{if(categoryKey==='trial')return[{account:'UserExp',amount:1}];if(categoryKey==='review')return[{account:'UserPoint',amount:duration===30?.5:1}];return duration===30?[{account:'Purse',amount:1},{account:'UserPoint',amount:.5}]:[{account:'SilverCoin',amount:1},{account:'UserPoint',amount:1}];}
  creditCosts(categoryKey=this.ruleCategoryKey(),duration=this.ruleDuration()):Array<{account:TeachingCreditAccount;amount:number}>{if(categoryKey==='trial')return[{account:'UserExp',amount:1}];if(categoryKey==='review')return[{account:'UserPoint',amount:duration===30?.5:1}];return duration===30?[{account:'Purse',amount:1},{account:'UserPoint',amount:.5}]:[{account:'SilverCoin',amount:1},{account:'UserPoint',amount:1}];}
  creditSummary(rule:TeachingRule):string{return rule.creditCosts.map((item)=>`${item.label} -${item.amount}`).join('+');}
  creditSummary(rule:TeachingRule):string{return rule.creditCosts.map((item)=>`${item.label} -${item.amount}`).join('+');}

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

@@ -1,11 +1,13 @@
 @if(error()){<p class="notice error" role="alert">{{error()}} <button class="ui-button secondary compact" (click)="load()">重新加载</button></p>}
 @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()">
 <section class="content-card reward-card" [attr.aria-busy]="loading()">
-  <header><div><strong>抗遗忘工资</strong><span>每条抗遗忘复习计划任务首次完成计薪 1 元,重复复习不重复计薪。历史待核对和异常记录不计入工资。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</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>每条抗遗忘复习计划任务首次完成计薪 1 元,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</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){
   @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>
     <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>
-    <div class="table-scroll"><table class="data-table"><thead><tr><th>老师 / 门店</th><th>学生 / 课程</th><th>计划日期</th><th>首次完成时间</th><th>金额</th><th>结算状态</th><th>说明 / 任务</th></tr></thead><tbody>
-      @for(row of result.items;track row.objectId){<tr><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==='historical'?'需核对历史发放情况':'首次完成,仅计薪一次')}}<small>{{row.taskId}}</small></td></tr>}
-      @empty{<tr><td colspan="7"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无抗遗忘工资记录'}}</div></td></tr>}
+    @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>}
+    <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{—}</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==='historical'?'需核对历史发放情况':'首次完成,仅计薪一次')}}<small>{{row.taskId}}</small></td></tr>}
+      @empty{<tr><td colspan="8"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无抗遗忘工资记录'}}</div></td></tr>}
     </tbody></table></div>
     </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>
     <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>
   }@else if(loading()){<p class="empty" role="status">正在加载抗遗忘工资…</p>}
   }@else if(loading()){<p class="empty" role="status">正在加载抗遗忘工资…</p>}

+ 1 - 0
projects/xiaoshu-admin/src/app/pages/review-rewards.component.scss

@@ -1 +1,2 @@
 :host{display:block}.reward-card{overflow:hidden}.reward-card>header{align-items:flex-start;gap:20px}.reward-card>header>div:first-child{max-width:640px}.reward-card header span{line-height:1.7}.reward-actions{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap}.reward-actions label{display:grid;gap:6px;font-size:13px;color:var(--text-muted,#687a76)}.reward-actions select{min-width:140px}.reward-summary{display:flex;gap:24px;flex-wrap:wrap;padding:16px 20px;background:var(--surface-subtle,#f3f8f6);font-size:14px}.reward-summary strong{color:var(--primary,#1e7d6b)}.reward-pagination{display:flex;justify-content:flex-end;align-items:center;gap:12px;padding:16px 20px}.reward-pagination>span{margin-right:auto;font-size:13px;color:#687a76}table{min-width:960px}td:last-child{max-width:260px;overflow-wrap:anywhere}@media(max-width:700px){.reward-card>header{flex-direction:column}.reward-summary{gap:12px 20px}.reward-pagination{padding:12px;gap:8px}}
 :host{display:block}.reward-card{overflow:hidden}.reward-card>header{align-items:flex-start;gap:20px}.reward-card>header>div:first-child{max-width:640px}.reward-card header span{line-height:1.7}.reward-actions{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap}.reward-actions label{display:grid;gap:6px;font-size:13px;color:var(--text-muted,#687a76)}.reward-actions select{min-width:140px}.reward-summary{display:flex;gap:24px;flex-wrap:wrap;padding:16px 20px;background:var(--surface-subtle,#f3f8f6);font-size:14px}.reward-summary strong{color:var(--primary,#1e7d6b)}.reward-pagination{display:flex;justify-content:flex-end;align-items:center;gap:12px;padding:16px 20px}.reward-pagination>span{margin-right:auto;font-size:13px;color:#687a76}table{min-width:960px}td:last-child{max-width:260px;overflow-wrap:anywhere}@media(max-width:700px){.reward-card>header{flex-direction:column}.reward-summary{gap:12px 20px}.reward-pagination{padding:12px;gap:8px}}
+.reward-confirm{display:flex;align-items:end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-top:1px solid #dfe9e5;background:#f8fbf9}.reward-confirm label{display:grid;gap:6px;flex:1;min-width:280px;color:#50635d;font-size:13px}.reward-confirm input{min-height:42px;padding:0 12px;border:1px solid #cfddd7;border-radius:10px}.reward-confirm small{width:100%;color:#697c75}.reward-card td:first-child input{width:18px;height:18px;accent-color:#176c55}

+ 7 - 1
projects/xiaoshu-admin/src/app/pages/review-rewards.component.spec.ts

@@ -7,7 +7,7 @@ describe('ReviewRewardsComponent',()=>{
   const empty:ReviewRewardPage={items:[],total:0,page:1,pageSize:20,summary:{reviewCount:0,reviewAmount:0,historicalCount:0,exceptionCount:0}};
   const empty:ReviewRewardPage={items:[],total:0,page:1,pageSize:20,summary:{reviewCount:0,reviewAmount:0,historicalCount:0,exceptionCount:0}};
   let service:jasmine.SpyObj<OperationsService>;
   let service:jasmine.SpyObj<OperationsService>;
   beforeEach(async()=>{
   beforeEach(async()=>{
-    service=jasmine.createSpyObj('OperationsService',['reviewRewards']);service.reviewRewards.and.resolveTo(empty);
+    service=jasmine.createSpyObj('OperationsService',['reviewRewards','confirmLegacyReviewRewards']);service.reviewRewards.and.resolveTo(empty);service.confirmLegacyReviewRewards.and.resolveTo({confirmed:1,alreadyConfirmed:0});
     await TestBed.configureTestingModule({imports:[ReviewRewardsComponent],providers:[{provide:OperationsService,useValue:service}]}).compileComponents();
     await TestBed.configureTestingModule({imports:[ReviewRewardsComponent],providers:[{provide:OperationsService,useValue:service}]}).compileComponents();
   });
   });
   it('passes coach, store, month and status filters, resetting pagination when filters change',async()=>{
   it('passes coach, store, month and status filters, resetting pagination when filters change',async()=>{
@@ -29,4 +29,10 @@ describe('ReviewRewardsComponent',()=>{
   });
   });
   it('clears record type only for an explicit reset', async()=>{const fixture=TestBed.createComponent(ReviewRewardsComponent),component=fixture.componentInstance;component.filters={month:'2026-09',coachId:0,storeId:0,search:''};component.status.set('historical');fixture.componentRef.setInput('filters',{...component.filters,resetRevision:1});fixture.detectChanges();await fixture.whenStable();expect(component.status()).toBe('');});
   it('clears record type only for an explicit reset', async()=>{const fixture=TestBed.createComponent(ReviewRewardsComponent),component=fixture.componentInstance;component.filters={month:'2026-09',coachId:0,storeId:0,search:''};component.status.set('historical');fixture.componentRef.setInput('filters',{...component.filters,resetRevision:1});fixture.detectChanges();await fixture.whenStable();expect(component.status()).toBe('');});
 
 
+  it('requires manager review and a reason before confirming selected historical evidence',async()=>{
+    const fixture=TestBed.createComponent(ReviewRewardsComponent),component=fixture.componentInstance;component.filters={month:'2026-09',coachId:0,storeId:0,search:''};
+    component.toggleSelection('reward-1',true);component.confirmReason.set('已核对未发放');await component.confirmSelected();expect(service.confirmLegacyReviewRewards).not.toHaveBeenCalled();
+    component.canManage=true;await component.confirmSelected();expect(service.confirmLegacyReviewRewards).toHaveBeenCalledWith(['reward-1'],'已核对未发放');expect(component.selectedIds()).toEqual([]);
+  });
+
 });
 });

+ 13 - 2
projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts

@@ -8,17 +8,28 @@ import { OperationsService } from '../operations.service';
 @Component({selector:'app-review-rewards',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 @Component({selector:'app-review-rewards',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class ReviewRewardsComponent implements OnChanges {
 export class ReviewRewardsComponent implements OnChanges {
   @Input({required:true}) filters!: ReviewRewardFilters;
   @Input({required:true}) filters!: ReviewRewardFilters;
+  @Input() canManage=false;
   readonly data=signal<ReviewRewardPage|null>(null);
   readonly data=signal<ReviewRewardPage|null>(null);
-  readonly status=signal(''); readonly loading=signal(false); readonly exporting=signal(false); readonly error=signal('');
+  readonly status=signal(''); readonly loading=signal(false); readonly exporting=signal(false); readonly confirming=signal(false); readonly error=signal(''); readonly message=signal(''); readonly selectedIds=signal<string[]>([]); readonly confirmReason=signal('');
   private requestId=0;private resetRevision=0;
   private requestId=0;private resetRevision=0;
   private readonly operations=inject(OperationsService);
   private readonly operations=inject(OperationsService);
   ngOnChanges():void { if((this.filters.resetRevision||0)!==this.resetRevision){this.resetRevision=this.filters.resetRevision||0;this.status.set('');}void this.load(); }
   ngOnChanges():void { if((this.filters.resetRevision||0)!==this.resetRevision){this.resetRevision=this.filters.resetRevision||0;this.status.set('');}void this.load(); }
   async load(page=1):Promise<void> {
   async load(page=1):Promise<void> {
     const requestId=++this.requestId;this.loading.set(true);this.error.set('');
     const requestId=++this.requestId;this.loading.set(true);this.error.set('');
-    try { const data=await this.operations.reviewRewards(this.filters,page,this.status());if(requestId===this.requestId)this.data.set(data); }
+    try { const data=await this.operations.reviewRewards(this.filters,page,this.status());if(requestId===this.requestId){this.data.set(data);this.selectedIds.set([]);} }
     catch(error){if(requestId===this.requestId){this.data.set(null);this.error.set(error instanceof Error?error.message:'抗遗忘工资加载失败');}}
     catch(error){if(requestId===this.requestId){this.data.set(null);this.error.set(error instanceof Error?error.message:'抗遗忘工资加载失败');}}
     finally{if(requestId===this.requestId)this.loading.set(false);}
     finally{if(requestId===this.requestId)this.loading.set(false);}
   }
   }
+  toggleSelection(id:string,checked:boolean):void{this.selectedIds.update(ids=>checked?[...new Set([...ids,id])]:ids.filter(value=>value!==id));}
+  hasConfirmable():boolean{return Boolean(this.data()?.items.some(row=>row.canConfirm));}
+  async confirmSelected():Promise<void>{
+    if(!this.canManage||this.confirming()||!this.selectedIds().length)return;
+    const reason=this.confirmReason().trim();if(reason.length<2){this.error.set('请填写至少两个字的核对依据,并确认旧系统记录尚未发放工资');return;}
+    this.confirming.set(true);this.error.set('');this.message.set('');
+    try{const result=await this.operations.confirmLegacyReviewRewards(this.selectedIds(),reason);this.message.set(`已核对 ${result.confirmed} 条抗遗忘工资凭据;重复确认 ${result.alreadyConfirmed} 条。`);this.confirmReason.set('');await this.load(this.data()?.page||1);}
+    catch(error){this.error.set(error instanceof Error?error.message:'历史复习工资核对失败');}
+    finally{this.confirming.set(false);}
+  }
   async export():Promise<void> {
   async export():Promise<void> {
     if(this.exporting())return;this.exporting.set(true);this.error.set('');
     if(this.exporting())return;this.exporting.set(true);this.error.set('');
     const filters={...this.filters,exportMode:true,snapshot:'',afterId:''},status=this.status();
     const filters={...this.filters,exportMode:true,snapshot:'',afterId:''},status=this.status();

+ 30 - 3
scripts/cloud/payroll-integrity.js

@@ -12,9 +12,31 @@ function payrollDateIso(value) {
 function payrollTenant(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'工资结算必须指定帐套');return co;}
 function payrollTenant(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'工资结算必须指定帐套');return co;}
 async function payrollSql(sql,args){try{return await Psql.one(sql,args);}catch(error){fail(/does not exist/.test(text(error.message))?503:409,text(error.message).replace(/^.*ERROR:\s*/,''));}}
 async function payrollSql(sql,args){try{return await Psql.one(sql,args);}catch(error){fail(/does not exist/.test(text(error.message))?503:409,text(error.message).replace(/^.*ERROR:\s*/,''));}}
 function payrollIdentity(item){return text(item.standardLessonKey)||(number(item.legacyGeneralId)?'legacy:'+number(item.legacyGeneralId):'lesson:'+item.objectId);}
 function payrollIdentity(item){return text(item.standardLessonKey)||(number(item.legacyGeneralId)?'legacy:'+number(item.legacyGeneralId):'lesson:'+item.objectId);}
+const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
+async function payrollSettlementSyncStatus(context,input){
+ const checkedAt=new Date().toISOString(),datasets=PAYROLL_SYNC_DATASETS.map(key=>({key,state:'never_synced',lastSuccessAt:'',lagSeconds:null,pendingFailures:0,conflicts:0,legacyCount:null,localCount:null})),config=syncConfig();
+ const result={state:'not_configured',settlementReady:false,message:'旧系统持续同步尚未配置,工资统计可能缺少最新记录',checkedAt,lastSuccessAt:'',lagSeconds:null,datasets};
+ if(!config.baseUrl||!config.keyId||!config.secret)return result;
+ try{
+  const co=payrollTenant(context,input),[checkpoints,issues,localCounts,manifest]=await Promise.all([
+   Psql.query('SELECT "dataset","state","cursor","lastSuccessAt","lastWatermark" FROM "LegacySyncCheckpoint" WHERE "company"=$1 AND "dataset"=ANY($2::text[]) ORDER BY "updatedAt" DESC',[co,PAYROLL_SYNC_DATASETS]),
+   Psql.query('SELECT "dataset","kind",COUNT(*)::int AS total FROM (SELECT "dataset",\'failure\' AS kind FROM "LegacySyncDeadLetter" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE UNION ALL SELECT "dataset",\'conflict\' AS kind FROM "LegacySyncConflict" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE) x WHERE "dataset"=ANY($2::text[]) GROUP BY "dataset","kind"',[co,PAYROLL_SYNC_DATASETS]),
+   Psql.query('SELECT "modelId"::int AS model,ARRAY_AGG("generalId"::bigint ORDER BY "generalId"::bigint) AS keys FROM "CommonModel" WHERE "company"=$1 AND "modelId" IN (54,59,60) AND COALESCE("status",99)<>-2 AND ("sourceKey" LIKE \'legacy:model:%\' OR "sourceKey" LIKE \'legacy-sync:%\') GROUP BY "modelId"',[co]),
+   syncBridgeRequest('/v1/manifest')
+  ]);
+  const byKey=new Map();for(const row of checkpoints)if(!byKey.has(text(row.dataset)))byKey.set(text(row.dataset),row);
+  const manifestByKey=new Map((manifest.datasets||[]).map(row=>[text(row.key),row])),modelByKey={'appointments':54,'lessons':59,'memory-records':60},localByModel=new Map(localCounts.map(row=>[number(row.model),Array.isArray(row.keys)?row.keys.map(text):[]]));
+  for(const item of datasets){const checkpoint=byKey.get(item.key),remote=manifestByKey.get(item.key),keys=localByModel.get(modelByKey[item.key])||[];item.state=text(checkpoint?.state)||'never_synced';item.lastSuccessAt=payrollDateIso(checkpoint?.lastSuccessAt);item.lagSeconds=item.lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(item.lastSuccessAt).getTime())/1000)):null;item.legacyCount=remote?number(remote.count):null;item.localCount=keys.length;item.localChecksum=await sha256(keys.length?keys.join('\n')+'\n':'');item.pendingFailures=issues.filter(row=>row.dataset===item.key&&row.kind==='failure').reduce((sum,row)=>sum+number(row.total),0);item.conflicts=issues.filter(row=>row.dataset===item.key&&row.kind==='conflict').reduce((sum,row)=>sum+number(row.total),0);item.watermarkCurrent=Boolean(remote&&checkpoint&&number(checkpoint.lastWatermark)>=number(remote.watermark));item.complete=Boolean(checkpoint?.cursor&&item.state==='healthy'&&item.watermarkCurrent&&item.legacyCount!==null&&item.localCount===item.legacyCount&&item.localChecksum===text(remote?.checksum));}
+  result.lastSuccessAt=datasets.map(row=>row.lastSuccessAt).filter(Boolean).sort()[0]||'';result.lagSeconds=result.lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(result.lastSuccessAt).getTime())/1000)):null;
+  result.settlementReady=manifest.consistent===true&&datasets.every(row=>row.complete&&row.lagSeconds!==null&&row.lagSeconds<=120&&!row.pendingFailures&&!row.conflicts);
+  result.state=result.settlementReady?'synced':manifest.consistent===true?'delayed':'inconsistent';
+  result.message=result.settlementReady?'旧系统预约、上课和抗遗忘记录已追平':manifest.consistent!==true?'旧系统数据在核对期间发生变化,请刷新重试':datasets.filter(row=>!row.complete||row.lagSeconds===null||row.lagSeconds>120||row.pendingFailures||row.conflicts).map(row=>row.key+':'+(row.pendingFailures||row.conflicts?'有失败或冲突记录':row.state==='catching_up'||row.state==='running'?'仍在追赶':row.state!=='healthy'?'同步未完成':row.localCount!==row.legacyCount||row.localChecksum!==text(manifestByKey.get(row.key)?.checksum)?'旧库与新库记录主键不一致':!row.watermarkCurrent?'增量水位未追平':'最近同步超过两分钟')).join(';');
+ }catch(error){result.state='error';result.message='旧系统同步状态无法核验:'+text(error?.message).slice(0,160);}
+ return result;
+}
 async function payrollCanonicalSnapshot(context,input) {
 async function payrollCanonicalSnapshot(context,input) {
   const co=payrollTenant(context,input);
   const co=payrollTenant(context,input);
-  const snapshot=await payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]);
+  const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]),payrollSettlementSyncStatus(context,input)]);
   const users=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1',[co]);
   const users=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1',[co]);
   const map=new Map(users.map(u=>[number(u.legacyUserId||legacyOf(u).UserID),u]));
   const map=new Map(users.map(u=>[number(u.legacyUserId||legacyOf(u).UserID),u]));
   const items=(snapshot.rows||[]).map(row=>{
   const items=(snapshot.rows||[]).map(row=>{
@@ -24,7 +46,7 @@ async function payrollCanonicalSnapshot(context,input) {
       studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
       studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
       teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
       teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
   });
   });
-  return {items,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:items.length,refreshedAt:new Date().toISOString(),complete:true,stale:false,cacheHit:false}};
+  return {items,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:items.length,refreshedAt:new Date().toISOString(),complete:sync.settlementReady,stale:!sync.settlementReady,cacheHit:false,sync}};
 }
 }
 async function payrollSourceRows(context,input,month,requireLive=false){const data=await payrollCanonicalSnapshot(context,input);return {...data,items:data.items.filter(i=>payrollBusinessDate(i.lessonAt).slice(0,7)===month)};}
 async function payrollSourceRows(context,input,month,requireLive=false){const data=await payrollCanonicalSnapshot(context,input);return {...data,items:data.items.filter(i=>payrollBusinessDate(i.lessonAt).slice(0,7)===month)};}
 function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
 function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
@@ -55,10 +77,15 @@ async function payrollOverviewData(context,input){
  return {...visible,batches,stores,incomeAmount:visible.payableAmount,settlementAmount:settlementTotals?.payableAmount??null,settlementAdjustmentAmount:settlementTotals?.adjustmentAmount??0,settlementCarryAmount:settlementTotals?settlementTotals.lines.filter(l=>l.kind!=='adjustment'&&l.originalMonth!==month&&!l.exception).reduce((a,l)=>a+l.amount,0):0};
  return {...visible,batches,stores,incomeAmount:visible.payableAmount,settlementAmount:settlementTotals?.payableAmount??null,settlementAdjustmentAmount:settlementTotals?.adjustmentAmount??0,settlementCarryAmount:settlementTotals?settlementTotals.lines.filter(l=>l.kind!=='adjustment'&&l.originalMonth!==month&&!l.exception).reduce((a,l)=>a+l.amount,0):0};
 }
 }
 async function payrollExportData(context,input){const data=input.targetId?await payrollDetailData(context,input,input.targetId):await payrollOverviewData(context,input),filtered=payrollVisibleLines(data.lines,input).filter(l=>!number(input.coachId)||l.coachId===number(input.coachId)),included=filtered.filter(l=>!l.excluded),excluded=filtered.filter(l=>l.excluded),totals=payrollResult(data.month,included,data.sourceStatus||{refreshedAt:new Date().toISOString()},{});return{fileName:'小树陪练工资_'+data.month+'.xlsx',summary:totals,coachItems:totals.coachItems,lines:included.filter(l=>!l.exception),exceptions:included.filter(l=>l.exception),excluded};}
 async function payrollExportData(context,input){const data=input.targetId?await payrollDetailData(context,input,input.targetId):await payrollOverviewData(context,input),filtered=payrollVisibleLines(data.lines,input).filter(l=>!number(input.coachId)||l.coachId===number(input.coachId)),included=filtered.filter(l=>!l.excluded),excluded=filtered.filter(l=>l.excluded),totals=payrollResult(data.month,included,data.sourceStatus||{refreshedAt:new Date().toISOString()},{});return{fileName:'小树陪练工资_'+data.month+'.xlsx',summary:totals,coachItems:totals.coachItems,lines:included.filter(l=>!l.exception),exceptions:included.filter(l=>l.exception),excluded};}
+async function confirmLegacyReviewRewards(context,input,payload){
+ requireRole(context,['ops-manager']);const sync=await payrollSettlementSyncStatus(context,input);if(!sync.settlementReady)fail(503,'旧系统数据尚未追平,暂不能核对复习工资:'+sync.message);
+ const ids=Array.isArray(payload.rewardIds)?[...new Set(payload.rewardIds.map(text).filter(Boolean))]:[];if(!ids.length||ids.length>100)fail(400,'请选择 1 至 100 条待核对凭据');
+ const result=await payrollSql('SELECT xs_review_reward_confirm($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(ids),reasonOf(input)]);return result.result;
+}
 async function payrollExecute(context,input,op,payload={}){
 async function payrollExecute(context,input,op,payload={}){
  requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
  requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
  if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
  if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
- if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;}
+ if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);if(!preview.sourceStatus.sync?.settlementReady)fail(503,'工资来源尚未追平,暂不能生成或审核:'+text(preview.sourceStatus.sync?.message));req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;}
  const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
  const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
  return {...payrollSummary(result.result),version:result.result.version};
  return {...payrollSummary(result.result),version:result.result.version};
 }
 }

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

@@ -37,6 +37,6 @@ async function reviewRewardPage(companyId, input) {
   if(input.exportMode&&input.afterId)add('r."objectId">?',String(input.afterId));
   if(input.exportMode&&input.afterId)add('r."objectId">?',String(input.afterId));
   const where = clauses.join(' AND ');
   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 "ReviewReward" r WHERE '+summaryWhere, summaryValues);
   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 "ReviewReward" r WHERE '+summaryWhere, summaryValues);
-  const rows = await Psql.query('SELECT r.*,l."batchId",b."month" AS "settlementMonth",b."status" AS "batchStatus" FROM "ReviewReward" r 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 { snapshot,nextCursor:input.exportMode&&rows.length===pageSize?rows[rows.length-1].objectId:'',items: rows.map((row)=>({...row,amount:Number(row.amount),completedAt:row.completedAt?new Date(row.completedAt).toISOString():'',statusLabel:row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')})), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
+  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 "ReviewReward" 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 { 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'&&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,statusLabel:row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')};}), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
 }
 }

+ 2 - 1
scripts/deploy-admin-functions.mjs

@@ -3602,7 +3602,7 @@ function requireSuperAdmin(context){if(!context.isSuperAdmin)fail(403,'只有超
 function syncConfig(){const env=typeof process!=='undefined'&&process.env?process.env:{};return{baseUrl:text(env.XIAOSHU_LEGACY_SYNC_BASE_URL).replace(/\/$/,''),keyId:text(env.XIAOSHU_LEGACY_SYNC_KEY_ID),secret:text(env.XIAOSHU_LEGACY_SYNC_SECRET),writesEnabled:/^(1|true|yes)$/i.test(text(env.XIAOSHU_LEGACY_SYNC_WRITES_ENABLED))};}
 function syncConfig(){const env=typeof process!=='undefined'&&process.env?process.env:{};return{baseUrl:text(env.XIAOSHU_LEGACY_SYNC_BASE_URL).replace(/\/$/,''),keyId:text(env.XIAOSHU_LEGACY_SYNC_KEY_ID),secret:text(env.XIAOSHU_LEGACY_SYNC_SECRET),writesEnabled:/^(1|true|yes)$/i.test(text(env.XIAOSHU_LEGACY_SYNC_WRITES_ENABLED))};}
 function syncBridgeConfigured(){const config=syncConfig();return Boolean(config.baseUrl&&config.keyId&&config.secret);}
 function syncBridgeConfigured(){const config=syncConfig();return Boolean(config.baseUrl&&config.keyId&&config.secret);}
 function syncBridgeWritesEnabled(){const config=syncConfig();return Boolean(config.baseUrl&&config.keyId&&config.secret&&config.writesEnabled);}
 function syncBridgeWritesEnabled(){const config=syncConfig();return Boolean(config.baseUrl&&config.keyId&&config.secret&&config.writesEnabled);}
-async function operationalSyncIndicator(context,input){const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret)return{state:'not_configured',label:'尚未启用持续同步',lastSuccessAt:'',lagSeconds:null};const rows=await newClassObjects('LegacySyncCheckpoint',context,input,1000),critical=rows.filter((row)=>['users','appointments','lessons'].includes(text(row.get('dataset')))),lastSuccessAt=critical.map((row)=>dateIso(row.get('lastSuccessAt'))).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=critical.length===3&&critical.every((row)=>text(row.get('state'))==='healthy'||text(row.get('state'))==='catching_up')&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'核心数据刚刚同步':'数据同步延迟,正在追平',lastSuccessAt,lagSeconds};}
+async function operationalSyncIndicator(context,input){const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret)return{state:'not_configured',label:'尚未启用持续同步',lastSuccessAt:'',lagSeconds:null};const rows=await newClassObjects('LegacySyncCheckpoint',context,input,1000),critical=rows.filter((row)=>['appointments','lessons','memory-records'].includes(text(row.get('dataset')))),lastSuccessAt=critical.map((row)=>dateIso(row.get('lastSuccessAt'))).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=critical.length===3&&critical.every((row)=>text(row.get('state'))==='healthy')&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'工资相关数据刚刚同步':'数据同步延迟,正在追平',lastSuccessAt,lagSeconds};}
 async function syncBridgeRequest(path,options={}){
 async function syncBridgeRequest(path,options={}){
   const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret)fail(503,'旧系统同步桥尚未配置');
   const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret)fail(503,'旧系统同步桥尚未配置');
   let url;try{url=new URL(config.baseUrl+path);}catch(_){fail(503,'旧系统同步桥地址无效');}if(url.protocol!=='https:'&&!['localhost','127.0.0.1','::1'].includes(url.hostname))fail(503,'旧系统同步桥必须使用 HTTPS');
   let url;try{url=new URL(config.baseUrl+path);}catch(_){fail(503,'旧系统同步桥地址无效');}if(url.protocol!=='https:'&&!['localhost','127.0.0.1','::1'].includes(url.hostname))fail(503,'旧系统同步桥必须使用 HTTPS');
@@ -3737,6 +3737,7 @@ async function handler(request,response) {
     if(operation==='ops/lesson-feedback/detail') return response.json({success:true,data:await lessonFeedbackDetail(context,input)});
     if(operation==='ops/lesson-feedback/detail') return response.json({success:true,data:await lessonFeedbackDetail(context,input)});
     if(operation==='ops/pay-rates/list') return response.json({success:true,data:pageRows(await payRateRows(context,input),input)});
     if(operation==='ops/pay-rates/list') return response.json({success:true,data:pageRows(await payRateRows(context,input),input)});
     if(operation==='ops/pay-rates/save') return response.json({success:true,data:await savePayRateUpsert(context,input,payload)});
     if(operation==='ops/pay-rates/save') return response.json({success:true,data:await savePayRateUpsert(context,input,payload)});
+    if(operation==='ops/payroll/review-rewards/confirm') return response.json({success:true,data:await confirmLegacyReviewRewards(context,input,payload)});
     if(operation==='ops/payroll/review-rewards') return response.json({success:true,data:await reviewRewardPage(companyIdOf(context,input),input)});
     if(operation==='ops/payroll/review-rewards') return response.json({success:true,data:await reviewRewardPage(companyIdOf(context,input),input)});
     if(operation==='ops/payroll/overview') return response.json({success:true,data:await payrollOverviewData(context,input)});
     if(operation==='ops/payroll/overview') return response.json({success:true,data:await payrollOverviewData(context,input)});
     if(operation==='ops/payroll/preview') return response.json({success:true,data:await payrollPreviewData(context,input,text(input.month||payload.month))});
     if(operation==='ops/payroll/preview') return response.json({success:true,data:await payrollPreviewData(context,input,text(input.month||payload.month))});

+ 1 - 1
scripts/deploy-payroll-integrity.mjs

@@ -19,7 +19,7 @@ for(const name of ['xiaoshu.ops.gateway','xiaoshu.ops.gateway.v3','xiaoshu.admin
   if(!code.includes("context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);"))code=code.replace('context=await auth(request);',"context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);");
   if(!code.includes("context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);"))code=code.replace('context=await auth(request);',"context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);");
   code=code.replace("requireRole(context,['ops-manager','ops-staff','ops-auditor']);await releaseExpiredReservations(context,input);","requireRole(context,['ops-manager','ops-staff','ops-auditor']);");
   code=code.replace("requireRole(context,['ops-manager','ops-staff','ops-auditor']);await releaseExpiredReservations(context,input);","requireRole(context,['ops-manager','ops-staff','ops-auditor']);");
   for(const op of ['export','exclude-line']){const re=new RegExp("^    if\\(operation==='ops/payroll/"+op+"'\\).*?$",'m');if(!re.test(code))throw Error('工资路由版本不兼容 '+op);code=code.replace(re,raw.match(re)[0]);}
   for(const op of ['export','exclude-line']){const re=new RegExp("^    if\\(operation==='ops/payroll/"+op+"'\\).*?$",'m');if(!re.test(code))throw Error('工资路由版本不兼容 '+op);code=code.replace(re,raw.match(re)[0]);}
-  const extra=["ops/payroll/coaches","ops/course-mappings/students","ops/course-mappings/student-commit"].filter(op=>!code.includes("operation==='"+op+"'" )).map(op=>raw.split('\n').find(line=>line.includes("operation==='"+op+"'"))).join('\n');
+  const extra=["ops/payroll/coaches","ops/payroll/review-rewards/confirm","ops/course-mappings/students","ops/course-mappings/student-commit"].filter(op=>!code.includes("operation==='"+op+"'" )).map(op=>raw.split('\n').find(line=>line.includes("operation==='"+op+"'"))).join('\n');
   if(extra)code=code.replace("    if(operation==='ops/payroll/overview')",extra+"\n    if(operation==='ops/payroll/overview')");
   if(extra)code=code.replace("    if(operation==='ops/payroll/overview')",extra+"\n    if(operation==='ops/payroll/overview')");
   code+=marker+fs.readFileSync(new URL('./cloud/review-rewards.js',import.meta.url),'utf8')+fs.readFileSync(new URL('./cloud/payroll-integrity.js',import.meta.url),'utf8');
   code+=marker+fs.readFileSync(new URL('./cloud/review-rewards.js',import.meta.url),'utf8')+fs.readFileSync(new URL('./cloud/payroll-integrity.js',import.meta.url),'utf8');
  }
  }

+ 22 - 5
scripts/run-legacy-sync-worker.mjs

@@ -9,10 +9,12 @@ const INTERVAL_MS = Math.max(5_000, Number(process.env.XIAOSHU_SYNC_INTERVAL_MS
 const HEALTH_PORT = Math.max(0, Number(process.env.XIAOSHU_SYNC_HEALTH_PORT || 9087));
 const HEALTH_PORT = Math.max(0, Number(process.env.XIAOSHU_SYNC_HEALTH_PORT || 9087));
 const DATASETS = String(process.env.XIAOSHU_SYNC_DATASETS || 'learning-records,practice-records,memory-records,assessments,course-bindings,appointments,lessons')
 const DATASETS = String(process.env.XIAOSHU_SYNC_DATASETS || 'learning-records,practice-records,memory-records,assessments,course-bindings,appointments,lessons')
   .split(',').map((value) => value.trim()).filter(Boolean);
   .split(',').map((value) => value.trim()).filter(Boolean);
+const DEPENDENCIES = { 'memory-records': 'learning-records', lessons: 'appointments' };
 const ONCE = process.argv.includes('--once');
 const ONCE = process.argv.includes('--once');
 
 
 if (!SESSION_TOKEN) throw new Error('缺少 XIAOSHU_SYNC_OPERATOR_TOKEN;同步运行器必须使用超级管理员会话');
 if (!SESSION_TOKEN) throw new Error('缺少 XIAOSHU_SYNC_OPERATOR_TOKEN;同步运行器必须使用超级管理员会话');
 if (!DATASETS.length) throw new Error('XIAOSHU_SYNC_DATASETS 不能为空');
 if (!DATASETS.length) throw new Error('XIAOSHU_SYNC_DATASETS 不能为空');
+for (const [dataset,dependency] of Object.entries(DEPENDENCIES)) if (DATASETS.includes(dataset) && !DATASETS.includes(dependency)) throw new Error(`${dataset} 同步必须同时包含前置数据集 ${dependency}`);
 
 
 const status = {
 const status = {
   startedAt: new Date().toISOString(),
   startedAt: new Date().toISOString(),
@@ -21,6 +23,7 @@ const status = {
   lastFinishedAt: '',
   lastFinishedAt: '',
   lastSuccessAt: '',
   lastSuccessAt: '',
   consecutiveFailures: 0,
   consecutiveFailures: 0,
+  catchingUp: false,
   slowTicks: 0,
   slowTicks: 0,
   datasets: {},
   datasets: {},
 };
 };
@@ -41,7 +44,7 @@ async function syncDataset(dataset) {
   const startedAt = Date.now();
   const startedAt = Date.now();
   try {
   try {
     const result = await call({ operation: 'ops/sync/run', dataset, maxPages: 2, pageSize: 500 });
     const result = await call({ operation: 'ops/sync/run', dataset, maxPages: 2, pageSize: 500 });
-    status.datasets[dataset] = { ok: true, elapsedMs: Date.now() - startedAt, ...result };
+    status.datasets[dataset] = { ok: !result.failures && !result.hasMore, state: result.failures ? 'error' : result.hasMore ? 'catching_up' : 'healthy', elapsedMs: Date.now() - startedAt, ...result };
     return result;
     return result;
   } catch (error) {
   } catch (error) {
     status.datasets[dataset] = { ok: false, elapsedMs: Date.now() - startedAt, error: String(error?.message || error).slice(0, 500) };
     status.datasets[dataset] = { ok: false, elapsedMs: Date.now() - startedAt, error: String(error?.message || error).slice(0, 500) };
@@ -54,17 +57,31 @@ async function tick() {
   const tickStartedAt = Date.now();
   const tickStartedAt = Date.now();
   status.running = true;
   status.running = true;
   status.lastStartedAt = new Date().toISOString();
   status.lastStartedAt = new Date().toISOString();
-  const results = await Promise.allSettled(DATASETS.map(syncDataset));
+  const independent = DATASETS.filter((dataset) => !DEPENDENCIES[dataset]);
+  const results = await Promise.allSettled(independent.map(syncDataset));
+  const byDataset = new Map(independent.map((dataset,index) => [dataset,results[index]]));
+  const dependents = DATASETS.filter((dataset) => DEPENDENCIES[dataset]);
+  const dependentResults = await Promise.allSettled(dependents.map(async (dataset) => {
+    const dependency = DEPENDENCIES[dataset],prerequisite = byDataset.get(dependency);
+    if (prerequisite?.status !== 'fulfilled' || prerequisite.value.hasMore || prerequisite.value.failures) {
+      status.datasets[dataset] = { ok:false,state:'waiting_for_dependency',dependency };
+      return { hasMore:true,skipped:true,dependency };
+    }
+    return syncDataset(dataset);
+  }));
+  results.push(...dependentResults);
   const failures = results.filter((result) => result.status === 'rejected');
   const failures = results.filter((result) => result.status === 'rejected');
+  const catchingUp = results.some((result) => result.status === 'fulfilled' && (result.value.hasMore || result.value.failures));
   status.lastFinishedAt = new Date().toISOString();
   status.lastFinishedAt = new Date().toISOString();
   status.running = false;
   status.running = false;
+  status.catchingUp = catchingUp;
   if (failures.length) {
   if (failures.length) {
     status.consecutiveFailures += 1;
     status.consecutiveFailures += 1;
     console.error(`[legacy-sync] ${status.lastFinishedAt} ${failures.length}/${DATASETS.length} 个数据集失败`);
     console.error(`[legacy-sync] ${status.lastFinishedAt} ${failures.length}/${DATASETS.length} 个数据集失败`);
     process.exitCode = ONCE ? 1 : 0;
     process.exitCode = ONCE ? 1 : 0;
   } else {
   } else {
     status.consecutiveFailures = 0;
     status.consecutiveFailures = 0;
-    status.lastSuccessAt = status.lastFinishedAt;
+    if (!catchingUp) status.lastSuccessAt = status.lastFinishedAt;
     const elapsedMs = Date.now() - tickStartedAt;
     const elapsedMs = Date.now() - tickStartedAt;
     if (elapsedMs > 10_000) {
     if (elapsedMs > 10_000) {
       status.slowTicks += 1;
       status.slowTicks += 1;
@@ -72,7 +89,7 @@ async function tick() {
     } else {
     } else {
       status.slowTicks = 0;
       status.slowTicks = 0;
     }
     }
-    console.log(`[legacy-sync] ${status.lastFinishedAt} ${DATASETS.length} 个数据集同步完成,耗时 ${elapsedMs}ms`);
+    console.log(`[legacy-sync] ${status.lastFinishedAt} ${DATASETS.length} 个数据集已处理${catchingUp ? '(仍在追赶,尚未追平)' : '并追平'},耗时 ${elapsedMs}ms`);
   }
   }
 }
 }
 
 
@@ -83,7 +100,7 @@ if (!ONCE && HEALTH_PORT) {
       return;
       return;
     }
     }
     const lastSuccessAge = status.lastSuccessAt ? Math.floor((Date.now() - new Date(status.lastSuccessAt).getTime()) / 1000) : null;
     const lastSuccessAge = status.lastSuccessAt ? Math.floor((Date.now() - new Date(status.lastSuccessAt).getTime()) / 1000) : null;
-    const healthy = lastSuccessAge !== null && lastSuccessAge <= 15 && status.consecutiveFailures === 0;
+    const healthy = lastSuccessAge !== null && lastSuccessAge <= 15 && status.consecutiveFailures === 0 && !status.catchingUp;
     response.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
     response.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
     response.end(JSON.stringify({ healthy, lastSuccessAge, intervalMs: INTERVAL_MS, ...status }));
     response.end(JSON.stringify({ healthy, lastSuccessAge, intervalMs: INTERVAL_MS, ...status }));
   }).listen(HEALTH_PORT, '127.0.0.1', () => console.log(`[legacy-sync] 健康检查监听 http://127.0.0.1:${HEALTH_PORT}/health`));
   }).listen(HEALTH_PORT, '127.0.0.1', () => console.log(`[legacy-sync] 健康检查监听 http://127.0.0.1:${HEALTH_PORT}/health`));

+ 25 - 1
scripts/sql/payroll-integrity.sql

@@ -62,7 +62,8 @@ CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGU
  SELECT md5(COALESCE((SELECT string_agg(s::text,'|' ORDER BY s::text) FROM xs_payroll_sources(co) s),'')||
  SELECT md5(COALESCE((SELECT string_agg(s::text,'|' ORDER BY s::text) FROM xs_payroll_sources(co) s),'')||
  COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
  COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
  COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||
  COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||
- COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CoachPayRate" p WHERE p."company"=co),''))
+ COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CoachPayRate" p WHERE p."company"=co),'')||
+ COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "ReviewReward" r WHERE r."company"=co AND r."status"='earned'),''))
 $$;
 $$;
 CREATE OR REPLACE FUNCTION xs_payroll_audit(co text,actor text,action text,target text,reason text,before_data jsonb,after_data jsonb) RETURNS void LANGUAGE plpgsql AS $$
 CREATE OR REPLACE FUNCTION xs_payroll_audit(co text,actor text,action text,target text,reason text,before_data jsonb,after_data jsonb) RETURNS void LANGUAGE plpgsql AS $$
 BEGIN
 BEGIN
@@ -72,6 +73,29 @@ BEGIN
  'detail',jsonb_build_object('action',action,'target',target,'actor',actor,'reason',reason,'before',before_data,'after',after_data)::text,'content1',reason,'content2',actor));
  'detail',jsonb_build_object('action',action,'target',target,'actor',actor,'reason',reason,'before',before_data,'after',after_data)::text,'content1',reason,'content2',actor));
 END $$;
 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;
+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 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"=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 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;
+  PERFORM set_config('xiaoshu.review_reward_confirm','yes',true);
+  UPDATE "ReviewReward" SET "status"='earned',"amount"=1 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',1));
+  confirmed:=confirmed+1;
+ END LOOP;
+ RETURN jsonb_build_object('confirmed',confirmed,'alreadyConfirmed',already);
+END $$;
+
 DROP TRIGGER IF EXISTS payroll_reward_guard ON "PayrollLine";
 DROP TRIGGER IF EXISTS payroll_reward_guard ON "PayrollLine";
 UPDATE "PayrollLine" SET "standardLessonKey"=CASE WHEN COALESCE("legacyGeneralId",0)>0 THEN 'legacy:'||"legacyGeneralId"::text ELSE 'lesson:'||"lessonId" END
 UPDATE "PayrollLine" SET "standardLessonKey"=CASE WHEN COALESCE("legacyGeneralId",0)>0 THEN 'legacy:'||"legacyGeneralId"::text ELSE 'lesson:'||"lessonId" END
  WHERE "kind"='lesson' AND "standardLessonKey" IS NULL;
  WHERE "kind"='lesson' AND "standardLessonKey" IS NULL;

+ 6 - 3
scripts/sql/review-rewards.sql

@@ -144,7 +144,7 @@ BEGIN
      NEW."fxzt" := OLD."fxzt"; NEW."wcsj" := OLD."wcsj"; NEW."plid" := OLD."plid";
      NEW."fxzt" := OLD."fxzt"; NEW."wcsj" := OLD."wcsj"; NEW."plid" := OLD."plid";
      RETURN NEW;
      RETURN NEW;
    END IF;
    END IF;
-   IF NEW."fxzt"::text='1' AND NOT (COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL) THEN
+   IF NEW."fxzt"::text='1' AND NOT ((COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%') AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL) THEN
      NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
      NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
    END IF;
    END IF;
  ELSE
  ELSE
@@ -156,7 +156,7 @@ CREATE OR REPLACE FUNCTION xiaoshu_review_after_write() RETURNS trigger LANGUAGE
 BEGIN
 BEGIN
  IF NEW."fxzt"::text='1' THEN
  IF NEW."fxzt"::text='1' THEN
    IF TG_OP='INSERT' THEN PERFORM xiaoshu_record_review_reward(NEW,true);
    IF TG_OP='INSERT' THEN PERFORM xiaoshu_record_review_reward(NEW,true);
-   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%'); END IF;
+   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%'); END IF;
  END IF;
  END IF;
  RETURN NEW;
  RETURN NEW;
 END $$;
 END $$;
@@ -172,7 +172,10 @@ BEGIN
  IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
  IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
 END $$;
 END $$;
 CREATE OR REPLACE FUNCTION xiaoshu_reward_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
 CREATE OR REPLACE FUNCTION xiaoshu_reward_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
-BEGIN RAISE EXCEPTION '抗遗忘工资凭据不可改写或删除,请使用工资调整'; END $$;
+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"=1 AND (to_jsonb(NEW)-'status'-'amount'-'updatedAt')=(to_jsonb(OLD)-'status'-'amount'-'updatedAt') THEN RETURN NEW; END IF;
+ RAISE EXCEPTION '抗遗忘工资凭据不可改写或删除,请使用工资调整';
+END $$;
 DROP TRIGGER IF EXISTS review_reward_immutable ON "ReviewReward";
 DROP TRIGGER IF EXISTS review_reward_immutable ON "ReviewReward";
 CREATE TRIGGER review_reward_immutable BEFORE UPDATE OR DELETE ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION xiaoshu_reward_immutable();
 CREATE TRIGGER review_reward_immutable BEFORE UPDATE OR DELETE ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION xiaoshu_reward_immutable();
 
 

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

@@ -15,7 +15,7 @@ before(async()=>{
  CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
  CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
  CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);
  CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);
  CREATE TABLE "DailyStudyRecord" ("objectId" text);
  CREATE TABLE "DailyStudyRecord" ("objectId" text);
- CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,company text,id numeric,plid text,yhid text,xxjlid text,kywrq text,kywsj text,kcid numeric,fxzt text,wcsj text,"updatedAt" timestamptz);
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,id numeric,plid text,yhid text,xxjlid text,kywrq text,kywsj text,kcid numeric,fxzt text,wcsj text,"updatedAt" timestamptz);
  CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
  CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
  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);
  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);
  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;
  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;
@@ -46,3 +46,26 @@ test('历史课次门店名称跟随保存的门店编号而不是教师现门
 test('陪练默认按未来排课由近到远,再按历史排课由近到远',()=>{const rows=[{userId:1,displayName:'无记录',nextAppointmentAt:'',lastAppointmentAt:''},{userId:2,displayName:'较早历史',nextAppointmentAt:'',lastAppointmentAt:'2026-08-01T10:00:00.000Z'},{userId:3,displayName:'较晚未来',nextAppointmentAt:'2026-09-20T10:00:00.000Z',lastAppointmentAt:'2026-09-01T10:00:00.000Z'},{userId:4,displayName:'较近未来',nextAppointmentAt:'2026-09-12T10:00:00.000Z',lastAppointmentAt:'2025-01-01T10:00:00.000Z'},{userId:5,displayName:'最近历史',nextAppointmentAt:'',lastAppointmentAt:'2026-09-09T10:00:00.000Z'}];rows.sort(context.coachActivityCompare);assert.deepEqual(rows.map(row=>row.userId),[4,3,5,2,1]);});
 test('陪练默认按未来排课由近到远,再按历史排课由近到远',()=>{const rows=[{userId:1,displayName:'无记录',nextAppointmentAt:'',lastAppointmentAt:''},{userId:2,displayName:'较早历史',nextAppointmentAt:'',lastAppointmentAt:'2026-08-01T10:00:00.000Z'},{userId:3,displayName:'较晚未来',nextAppointmentAt:'2026-09-20T10:00:00.000Z',lastAppointmentAt:'2026-09-01T10:00:00.000Z'},{userId:4,displayName:'较近未来',nextAppointmentAt:'2026-09-12T10:00:00.000Z',lastAppointmentAt:'2025-01-01T10:00:00.000Z'},{userId:5,displayName:'最近历史',nextAppointmentAt:'',lastAppointmentAt:'2026-09-09T10:00:00.000Z'}];rows.sort(context.coachActivityCompare);assert.deepEqual(rows.map(row=>row.userId),[4,3,5,2,1]);});
 test('陪练活跃记录忽略已取消预约和已结束的未来异常记录',()=>{const result=context.appointmentHistory([{coachId:1,startsAt:'2099-01-01T10:00:00.000Z',statusCode:0,cancelled:true},{coachId:1,startsAt:'2099-01-02T10:00:00.000Z',statusCode:30,cancelled:false},{coachId:1,startsAt:'2099-01-03T10:00:00.000Z',statusCode:10,cancelled:false}]);assert.equal(result.totalAppointments,2);assert.equal(result.byCoach.get(1).nextAppointmentAt,'2099-01-03T10:00:00.000Z');});
 test('陪练活跃记录忽略已取消预约和已结束的未来异常记录',()=>{const result=context.appointmentHistory([{coachId:1,startsAt:'2099-01-01T10:00:00.000Z',statusCode:0,cancelled:true},{coachId:1,startsAt:'2099-01-02T10:00:00.000Z',statusCode:30,cancelled:false},{coachId:1,startsAt:'2099-01-03T10:00:00.000Z',statusCode:10,cancelled:false}]);assert.equal(result.totalAppointments,2);assert.equal(result.byCoach.get(1).nextAppointmentAt,'2099-01-03T10:00:00.000Z');});
 test('旧单价接口也保留修订记录且审计在同一事务中',async()=>{const req={classType:2,amount:40,durationHours:1,effectiveFrom:'2026-08-01',active:true,reason:'历史工资规则修订'};const first=(await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const v=await version();await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,amount:99})]);assert.notEqual(await version(),v);assert.equal((await one(`SELECT amount FROM "CoachPayRate" WHERE "objectId"=$1`,[first.objectId])).amount,40);await assert.rejects(q(`UPDATE "CoachPayRate" SET amount=1 WHERE "objectId"=$1`,[first.objectId]),/不可改写/);assert.equal((await one(`SELECT count(*)::int n FROM "SysLog" WHERE type2='save-legacy-rate'`)).n,2);});
 test('旧单价接口也保留修订记录且审计在同一事务中',async()=>{const req={classType:2,amount:40,durationHours:1,effectiveFrom:'2026-08-01',active:true,reason:'历史工资规则修订'};const first=(await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const v=await version();await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,amount:99})]);assert.notEqual(await version(),v);assert.equal((await one(`SELECT amount FROM "CoachPayRate" WHERE "objectId"=$1`,[first.objectId])).amount,40);await assert.rejects(q(`UPDATE "CoachPayRate" SET amount=1 WHERE "objectId"=$1`,[first.objectId]),/不可改写/);assert.equal((await one(`SELECT count(*)::int n FROM "SysLog" WHERE type2='save-legacy-rate'`)).n,2);});
+test('旧系统复习凭据仅人工确认后计薪,旧月份拒绝且重复确认幂等',async()=>{
+ await db.exec(`INSERT INTO "CommonModel" ("objectId",company,"modelId","generalId") VALUES ('legacy-study','co',56,6001);
+ INSERT INTO "MemoryPracticeRecord" ("objectId",company,"sourceKey",id,plid,yhid,xxjlid,kywrq,kywsj,kcid,fxzt,wcsj) VALUES
+ ('legacy-review-new','co','legacy-sync:memory-records:addon:991',991,'30','20','6001','2026-09-05','2026-09-05',1,'1','2026-09-05 10:00:00'),
+ ('legacy-review-old','co','legacy-sync:memory-records:addon:992',992,'30','20','6001','2026-08-05','2026-08-05',1,'1','2026-08-05 10:00:00');`);
+ const current=await one('SELECT "objectId",status,amount FROM "ReviewReward" WHERE "taskId"=$1',['legacy-review-new']);assert.equal(current.status,'historical');assert.equal(Number(current.amount),0);
+ const initialVersion=await version();const invoke=async(id)=>(await one('SELECT xs_review_reward_confirm(\'co\',\'teacher\',$1::jsonb,\'已核对旧系统未发放\') AS result',[JSON.stringify([id])])).result;
+ await assert.rejects(invoke((await one('SELECT "objectId" FROM "ReviewReward" WHERE "taskId"=$1',['legacy-review-old'])).objectId),/未通过/);
+ const confirmed=await invoke(current.objectId);assert.equal(confirmed.confirmed,1);assert.notEqual(await version(),initialVersion);
+ assert.equal((await invoke(current.objectId)).alreadyConfirmed,1);assert.equal(Number((await one('SELECT amount FROM "ReviewReward" WHERE "objectId"=$1',[current.objectId])).amount),1);
+ await assert.rejects(q('UPDATE "ReviewReward" SET amount=2 WHERE "objectId"=$1',[current.objectId]),/不可改写/);
+});
+test('工资结算仅在三个数据集追平、两分钟内同步且无失败时开放',async()=>{
+ const keys=['appointments','lessons','memory-records'],countByModel={54:[1,2],59:[3,4,5],60:[6,7,8,9]};
+ let checkpoints=keys.map((dataset,index)=>({dataset,state:'healthy',cursor:'v2',lastSuccessAt:new Date().toISOString(),lastWatermark:index+10})),issues=[];
+ const ctx=vm.createContext({Date,process:{env:{XIAOSHU_LEGACY_SYNC_BASE_URL:'https://example.test',XIAOSHU_LEGACY_SYNC_KEY_ID:'key',XIAOSHU_LEGACY_SYNC_SECRET:'secret'}},Psql:{query:async(sql)=>sql.includes('LegacySyncCheckpoint')?checkpoints:sql.includes('LegacySyncDeadLetter')?issues:Object.entries(countByModel).map(([model,keys])=>({model,keys}))}});
+ vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);
+ ctx.companyIdOf=()=> 'co';ctx.sha256=async(value)=>'digest-'+value.replaceAll('\n',',');ctx.syncBridgeRequest=async()=>({consistent:true,datasets:keys.map((key,index)=>({key,count:[2,3,4][index],watermark:index+10,checksum:'digest-'+countByModel[[54,59,60][index]].join(',')+','}))});
+ const healthy=await ctx.payrollSettlementSyncStatus(manager,{});assert.equal(healthy.settlementReady,true,JSON.stringify(healthy));
+ checkpoints=checkpoints.map(row=>row.dataset==='lessons'?{...row,state:'catching_up'}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
+ checkpoints=checkpoints.map(row=>({...row,state:'healthy'}));issues=[{dataset:'appointments',kind:'failure',total:1}];assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
+ issues=[];checkpoints=checkpoints.map(row=>row.dataset==='memory-records'?{...row,lastSuccessAt:new Date(Date.now()-121000).toISOString()}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
+});

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