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

Reconcile proven duplicate and overdue review rewards with immutable evidence

彭峰 3 часов назад
Родитель
Сommit
40b9e39833

+ 28 - 0
docs/operations/review-reconciliation-2026-10-20.md

@@ -0,0 +1,28 @@
+# 2026-10-20 历史抗遗忘工资核定
+
+用户要求尽量核定可确定的历史记录,沿用 30 分钟 0.5 元、60 分钟 1 元,学生自习和逾期补做不计薪,同一复习不重复计薪的规则。
+
+## 已执行
+
+核查 2026-09-01 起的待核对凭据,以及月份缺失的异常凭据。发现 5,372 条待核对:9 月历史 4,510 条、重复异常 9 条;10 月历史 783 条、重复异常 69 条;日期和来源缺失 1 条。
+
+- 9 条历史凭据的完成日期晚于计划日期,确定不计薪。
+- 78 条重复异常涉及 48 组来源相同的任务,师生归属一致。优先保留已经计薪的凭据,其次已有历史凭据,最后保留最早完成的一份;65 条重复副本确定不重复计薪。另 13 组只有重复副本完成,保留一份待核定,不能把唯一实际完成记录也排除。
+- 合计 74 条已在生产核定为不计薪,原始 6,663 条范围内凭据、现有 PayrollBatch 和 PayrollLine 回读完全一致。
+- 10 月页面:有效计薪 1,286 条、¥1,196.50;历史待核对 783 条;异常待核对从 70 降至 14(13 份保留的完成凭据和 1 条日期来源缺失记录)。
+
+## 仍需明确的历史身份口径
+
+旧系统完成操作只更新完成状态和时间,未保存实际完成账号;录入人是创建者,不能当作完成者。5,284 条按时完成历史记录和 13 份保留的重复组完成记录不能凭绑定老师直接断言“老师账号操作”。用户先前要求“身份不明的保留待核对”。已询问是否对这批历史记录采用“来源、日期、时长核实后按绑定老师计薪,有明确学生自习证据则剔除”的历史核算口径,尚未收到答复,因此本次没有新增这部分工资。
+
+4 节本地缺失的来源课已通过旧系统 `content_get` 核实,关联的 20 条复习可确定课时时长。完整来源快照仅保存在受限的本地审计目录。假如历史身份口径获得确认,候选共 5,297 条,另查出其中 76 条涉及系统标记为身份冲突的老师账号,须单独解决身份归属;其余 5,221 条按来源时长试算为 ¥4,764.50,仍须执行服务端逐条验证。以上金额均尚未入账。
+
+## 实现及验证
+
+`ReviewRewardResolution` 追加核定依据,不改写原始凭据、历史完成账号或已存在工资单。`ReviewRewardIncome` 投影核定后的状态和金额。核定事务要求管理身份、明确凭据列表及原因,对来源、师生、完成日期、重复关系和已有结算进行复核。历史绑定老师口径必须显式启用,并记录实际完成身份未留存,不伪造老师操作证据。
+
+重复副本必须指向同一师生、同一来源学习记录、同一计划日期的保留凭据。已经核定计薪的任务即使原始重复任务再次完成,也不能重复记工资。
+
+数据库规则 10 项、回归 47 项、移动端 43 项、管理端 6 项测试通过,双端生产构建及 11 个云函数定义校验通过;双端显示分别区分学生自习、逾期和重复不计薪。生产迁移和批处理前检查同步租约空闲,保存快照,事务执行,随后比对原始凭据和工资单。三个网关仅选择性替换 `reviewRewardPage`,保留线上其余差异并逐个回读代码哈希。
+
+私有审计目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-20-review-reconciliation/`。内含执行前后快照、74 条核定清单、旧课次来源证据和未执行的历史核定候选清单;不提交账号数据和生产明细。

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

@@ -13,7 +13,7 @@
     <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+' 条,按来源课时时长计薪'}}</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.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>}
+      @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.ineligibleReason==='duplicate_review'?'重复复习,不重复计薪':'逾期补做,不计薪'):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>

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

@@ -1894,6 +1894,8 @@ describe('home learning modules', () => {
     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('逾期补做');
+    component.reviewCompletion.set({amount:0,rewardStatus:'ineligible',ineligibleReason:'duplicate_review',unchanged:true});harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).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('收入');

+ 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.ineligibleReason==='self_study'?'学生自习,不计薪':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.ineligibleReason==='duplicate_review'?'重复复习,不重复计薪':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>

+ 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),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)} };
+  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.ineligibleReason==='duplicate_review'?'重复复习,不重复计薪':'逾期补做,不计薪'):row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')};}), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
 }

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

@@ -12,7 +12,7 @@ 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','ReviewRewardRateCorrection','PayrollBatch','PayrollLine','PayrollCommand'];
+const tables=['CourseAppointment','LessonRecord','LessonCompletionReport','MemoryPracticeRecord','ReviewReward','ReviewRewardCorrection','ReviewRewardRateCorrection','ReviewRewardResolution','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('夜间同步正在运行,暂缓迁移');

+ 106 - 0
scripts/sql/review-reconciliation.sql

@@ -0,0 +1,106 @@
+-- Apply after review-pay-policy.sql. Explicit, audited historical determinations;
+-- original receipts and completed-by fields are never rewritten by reconciliation.
+BEGIN;
+SET LOCAL lock_timeout='10s';
+CREATE INDEX IF NOT EXISTS memory_review_source_day ON "MemoryPracticeRecord"(company,yhid,xxjlid,(left(kywrq::text,10)));
+CREATE TABLE IF NOT EXISTS "ReviewRewardResolution" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, "rewardId" text NOT NULL REFERENCES "ReviewReward"("objectId"),
+ "taskKey" text NOT NULL, status text NOT NULL CHECK(status IN ('earned','ineligible')),
+ amount numeric(12,2) NOT NULL, "reasonCode" text NOT NULL, reason text NOT NULL CHECK(length(trim(reason))>=2),
+ actor text NOT NULL, evidence jsonb NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(),
+ UNIQUE(company,"rewardId"), CHECK((status='earned' AND amount IN (0.5,1)) OR(status='ineligible' AND amount=0))
+);
+CREATE UNIQUE INDEX IF NOT EXISTS review_resolution_once ON "ReviewRewardResolution"(company,"taskKey") WHERE status='earned';
+CREATE OR REPLACE FUNCTION xs_review_resolution_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP<>'INSERT' OR current_setting('xiaoshu.review_resolution',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '复习核定凭据只能由核定事务新增'; END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS review_resolution_immutable ON "ReviewRewardResolution";
+CREATE TRIGGER review_resolution_immutable BEFORE INSERT OR UPDATE OR DELETE ON "ReviewRewardResolution" FOR EACH ROW EXECUTE FUNCTION xs_review_resolution_guard();
+REVOKE ALL ON "ReviewRewardResolution" FROM PUBLIC;
+CREATE OR REPLACE VIEW "ReviewRewardIncome" AS
+SELECT r."objectId",r.company,r."taskId",COALESCE(d."taskKey",r."taskKey") AS "taskKey",r."coachId",r."coachName",r."studentId",r."studentName",r."courseName",r."storeId",r."storeName",r."plannedDate",r."completedAt",r."incomeMonth",
+ (COALESCE(d.amount,r.amount)+COALESCE(c.amount,0)+COALESCE(rc.amount,0))::numeric(12,2) AS amount,
+ CASE WHEN c."objectId" IS NOT NULL THEN 'ineligible' ELSE COALESCE(d.status,r.status) END AS status,
+ CASE WHEN d."objectId" IS NULL THEN r.exception ELSE '' END AS exception,r."createdAt",
+ CASE WHEN c."objectId" IS NOT NULL THEN 'corrected_overdue' WHEN d.status='ineligible' THEN d."reasonCode" ELSE r."ineligibleReason" END AS "ineligibleReason",
+ r.amount AS "originalAmount",COALESCE(c."objectId",rc."objectId") AS "correctionId",
+ NULLIF(concat_ws(';',rc.reason,c.reason,d.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"
+LEFT JOIN "ReviewRewardResolution" d ON d.company=r.company AND d."rewardId"=r."objectId";
+
+CREATE OR REPLACE FUNCTION xs_review_resolve(co text, actor text, items jsonb, reason text, historical_assignment boolean DEFAULT false) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE item jsonb; r "ReviewReward"; m "MemoryPracticeRecord"; canonical record; minutes integer; k text; outcome text; code text; snapshot jsonb; source jsonb; n integer:=0; existing integer:=0; prior text:=COALESCE(current_setting('xiaoshu.review_resolution',true),'');
+BEGIN
+ PERFORM xs_review_manager(co,actor);
+ IF length(trim(COALESCE(reason,'')))<2 OR jsonb_typeof(items) IS DISTINCT FROM 'array' OR jsonb_array_length(items) NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION '需要明确核定依据和1至100条凭据'; END IF;
+ LOCK TABLE "MemoryPracticeRecord", "ReviewReward" IN SHARE ROW EXCLUSIVE MODE;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ FOR item IN SELECT value FROM jsonb_array_elements(items) LOOP
+  SELECT * INTO r FROM "ReviewReward" WHERE company=co AND "objectId"=item->>'rewardId' FOR UPDATE;
+  IF NOT FOUND THEN RAISE EXCEPTION '凭据不存在'; END IF;
+  IF EXISTS(SELECT 1 FROM "ReviewRewardResolution" WHERE company=co AND "rewardId"=r."objectId") THEN existing:=existing+1;CONTINUE;END IF;
+  IF r.status NOT IN ('historical','exception') OR r.amount<>0 OR EXISTS(SELECT 1 FROM "PayrollLine" WHERE company=co AND "reviewRewardId"=r."objectId" AND NOT COALESCE(excluded,false)) THEN RAISE EXCEPTION '仅能核定未计薪、未入工资单的待核对凭据'; END IF;
+  SELECT * INTO m FROM "MemoryPracticeRecord" WHERE company=co AND "objectId"=r."taskId" FOR UPDATE;
+  k:=xiaoshu_review_key(m.yhid::text,m.xxjlid::text,left(m.kywrq::text,10));
+  IF m."objectId" IS NULL OR m.fxzt::text<>'1' OR k IS NULL OR r."studentId"::text<>m.yhid::text OR r."coachId"::text<>m.plid::text OR r."plannedDate"<>left(m.kywrq::text,10) OR r."completedAt" IS DISTINCT FROM xiaoshu_safe_shanghai_timestamp(m.wcsj::text) THEN RAISE EXCEPTION '任务、身份或完成时间与原凭据不一致'; END IF;
+  code:=item->>'reasonCode';outcome:='ineligible';minutes:=NULL;
+  snapshot:=jsonb_build_object('receipt',to_jsonb(r),'task',to_jsonb(m),'policy','duration-actor-v2');
+  IF code='overdue' THEN
+   IF xiaoshu_review_eligibility(r."plannedDate",r."completedAt")<>'overdue' THEN RAISE EXCEPTION '不能认定为逾期'; END IF;
+  ELSIF code='duplicate_review' THEN
+   SELECT v.* INTO canonical FROM "ReviewRewardIncome" v JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId"
+    WHERE v.company=co AND v."objectId"=item->>'canonicalRewardId' AND v.status IN ('earned','historical','exception') AND v."objectId"<>r."objectId" AND v."coachId"=r."coachId" AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10);
+   IF NOT FOUND THEN RAISE EXCEPTION '重复记录必须关联同一老师、同一任务的有效计薪凭据或保留凭据'; END IF;
+   IF canonical."objectId" IS DISTINCT FROM (SELECT v."objectId" FROM "ReviewRewardIncome" v JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId" WHERE v.company=co AND v.status IN ('earned','historical','exception') AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10) ORDER BY (v.status='earned') DESC,(v.status='historical') DESC,v."completedAt",v."objectId" LIMIT 1) THEN RAISE EXCEPTION '必须保留已有计薪凭据或唯一优先完成凭据'; END IF;
+   snapshot:=snapshot||jsonb_build_object('canonicalRewardId',canonical."objectId");
+  ELSIF code='legacy_assignment_policy' THEN
+   IF NOT historical_assignment THEN RAISE EXCEPTION '历史绑定老师核算口径未确认'; END IF;
+   IF COALESCE(r."completionRole",'') IN ('student','unknown','coach') OR NULLIF(r."completedBy",'') IS NOT NULL OR r."ineligibleReason"='self_study' THEN RAISE EXCEPTION '已有实际身份凭据不能按历史口径覆盖'; END IF;
+   IF COALESCE(m."sourceKey",'') NOT LIKE 'legacy:model:60:%' OR r."incomeMonth"<'2026-09' OR r."completedAt">now() OR xiaoshu_review_eligibility(r."plannedDate",r."completedAt")<>'eligible' OR r.exception NOT IN ('','来源复习任务重复') THEN RAISE EXCEPTION '历史来源或计划当天完成条件不满足'; END IF;
+   IF NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."coachId" AND COALESCE(to_jsonb(u)->>'identityType','coach')='coach' AND COALESCE(to_jsonb(u)->>'legacyGroupId',u."legacyUserData"->>'GroupID')='3') OR NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."studentId") OR r."coachId"=r."studentId" THEN RAISE EXCEPTION '历史师生身份无效'; END IF;
+   -- Existing earned receipts win; no second payout even if a duplicate has a null taskKey.
+   IF EXISTS(SELECT 1 FROM "ReviewRewardIncome" v JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId" WHERE v.company=co AND v.status='earned' AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10)) THEN RAISE EXCEPTION '同一复习已经计薪'; END IF;
+   IF EXISTS(SELECT 1 FROM "MemoryPracticeRecord" t WHERE t.company=co AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10) AND t.plid<>m.plid) THEN RAISE EXCEPTION '重复任务的老师归属冲突'; END IF;
+   minutes:=xs_review_duration(co,m."objectId");
+   IF minutes IS NULL THEN
+    -- A separately fetched old-system lesson may bridge an absent local appointment only.
+    source:=item->'legacyLesson';
+    IF source IS NULL OR source->>'modelId' IS DISTINCT FROM '54' OR source->>'generalId' IS DISTINCT FROM m."orderId"::text OR source->>'szyh' IS DISTINCT FROM m.yhid::text OR NOT EXISTS(SELECT 1 FROM xs_review_task_link l WHERE l.company=co AND l."taskId"=m."objectId" AND l."appointmentId"=source->>'generalId') OR EXISTS(SELECT 1 FROM "CommonModel" c WHERE c.company=co AND c."modelId"=54 AND c."generalId"::text=source->>'generalId') THEN RAISE EXCEPTION '来源课次时长仍不明确'; END IF;
+    minutes:=CASE source->>'dslx' WHEN '1' THEN 30 WHEN '2' THEN 60 WHEN '3' THEN 60 END;
+    snapshot:=snapshot||jsonb_build_object('legacyLesson',source);
+   END IF;
+   IF minutes IS NULL THEN RAISE EXCEPTION '不能推测课时时长'; END IF;
+   outcome:='earned';snapshot:=snapshot||jsonb_build_object('durationMinutes',minutes,'completionIdentity','not_recorded','historicalAssignmentPolicy',true);
+  ELSE RAISE EXCEPTION '不支持的核定原因'; END IF;
+  PERFORM set_config('xiaoshu.review_resolution','yes',true);
+  INSERT INTO "ReviewRewardResolution"("objectId",company,"rewardId","taskKey",status,amount,"reasonCode",reason,actor,evidence)
+   VALUES(md5(co||':review-resolution:'||r."objectId"),co,r."objectId",k,outcome,CASE WHEN outcome='earned' THEN minutes::numeric/60 ELSE 0 END,code,reason,actor,snapshot);
+  PERFORM xs_payroll_audit(co,actor,'resolve-review-reward',r."objectId",reason,to_jsonb(r),jsonb_build_object('status',outcome,'amount',CASE WHEN outcome='earned' THEN minutes::numeric/60 ELSE 0 END,'reasonCode',code));
+  n:=n+1;
+ END LOOP;
+ PERFORM set_config('xiaoshu.review_resolution',prior,true);
+ RETURN jsonb_build_object('resolved',n,'alreadyResolved',existing);
+EXCEPTION WHEN OTHERS THEN PERFORM set_config('xiaoshu.review_resolution',prior,true);RAISE;
+END $$;
+REVOKE ALL ON FUNCTION xs_review_resolve(text,text,jsonb,text,boolean) FROM PUBLIC;
+-- Prevent a later completion of the original duplicate task from paying again.
+CREATE OR REPLACE FUNCTION xs_review_resolved_once() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE k text;
+BEGIN
+ IF NEW.status<>'earned' THEN RETURN NEW; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||NEW.company));
+ SELECT xiaoshu_review_key(t.yhid::text,t.xxjlid::text,left(t.kywrq::text,10)) INTO k FROM "MemoryPracticeRecord" t WHERE t.company=NEW.company AND t."objectId"=NEW."taskId";
+ IF EXISTS(SELECT 1 FROM "ReviewRewardResolution" d WHERE d.company=NEW.company AND (d."rewardId"=NEW."objectId" OR(d."taskKey"=k AND d.status='earned'))) THEN
+  IF TG_OP='UPDATE' THEN RAISE EXCEPTION '复习已有核定结果,不能再次确认计薪'; END IF;
+  NEW.status:='ineligible';NEW.amount:=0;NEW."ineligibleReason":='duplicate_review';
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS a_review_resolved_once ON "ReviewReward";
+CREATE TRIGGER a_review_resolved_once BEFORE INSERT OR UPDATE ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION xs_review_resolved_once();
+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','review-pay-policy'];
+ const files=['review-rewards','lesson-workflow','lesson-records','payroll-integrity','review-pay-policy','review-reconciliation'];
  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;';
 }

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

@@ -111,3 +111,52 @@ test('时长差额纠正后仍能纠正逾期余额,累计扣减不超过原
  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);
 });
+
+test('历史核定需要明确口径,保留原凭据、实际完成身份未知,净额按时长计算',async()=>{
+ await db.exec(sql('review-reconciliation'));
+ await task('resolve-history',{minutes:30,day:'2026-09-05',source:'legacy:model:60:general:1001',done:true});
+ const r=await receipt('resolve-history'),original=await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]);
+ const items=JSON.stringify([{rewardId:r.objectId,reasonCode:'legacy_assignment_policy'}]);
+ await assert.rejects(q("SELECT xs_review_resolve('c','manager',$1::jsonb,'历史按绑定老师核算',false)",[items]),/口径未确认/);
+ const result=await one("SELECT xs_review_resolve('c','manager',$1::jsonb,'历史按绑定老师核算',true) result",[items]);assert.equal(result.result.resolved,1);
+ assert.equal(Number((await receipt('resolve-history')).amount),.5);
+ assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]),original);
+ const d=await one('SELECT * FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[r.objectId]);assert.equal(d.evidence.completionIdentity,'not_recorded');
+ assert.equal((await one("SELECT xs_review_resolve('c','manager',$1::jsonb,'历史按绑定老师核算',true) result",[items])).result.alreadyResolved,1);
+ await assert.rejects(q('DELETE FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[r.objectId]),/只能/);
+ await assert.rejects(q("SELECT xs_review_reward_confirm('c','manager',$1::jsonb,'再次确认')",[JSON.stringify([r.objectId])]),/已有核定/);
+});
+test('确定逾期无需身份假设;重复副本只能关联同一任务的有效计薪凭据',async()=>{
+ await task('resolve-late',{minutes:30,day:'2026-09-05',source:'legacy:model:60:general:1002'});
+ // Historical import triggers preserve its source completion timestamp.
+ await q(`UPDATE "MemoryPracticeRecord" SET fxzt='1',wcsj='2026-09-06 10:00' WHERE "objectId"='resolve-late'`);
+ const r=await receipt('resolve-late');
+ await q("SELECT xs_review_resolve('c','manager',$1::jsonb,'确认逾期')",[JSON.stringify([{rewardId:r.objectId,reasonCode:'overdue'}])]);
+ assert.equal((await receipt('resolve-late')).status,'ineligible');assert.equal((await receipt('resolve-late')).ineligibleReason,'overdue');
+ const source=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'resolve-history\'');
+ await db.exec("SET session_replication_role='replica'");
+ await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey") VALUES('resolve-copy','c',9001,10,20,$1,$1,'2026-09-05','2026-09-05',30,'1','2026-09-05 10:01','legacy:model:60:general:1003')`,[source.xxjlid]);
+ await db.exec("SET session_replication_role='origin'");
+ await q(`SELECT xiaoshu_record_review_reward(t,true) FROM "MemoryPracticeRecord" t WHERE "objectId"='resolve-copy'`);
+ const copy=await receipt('resolve-copy'),canonical=await receipt('resolve-history');
+ await assert.rejects(q("SELECT xs_review_resolve('c','manager',$1::jsonb,'确认重复')",[JSON.stringify([{rewardId:copy.objectId,reasonCode:'duplicate_review',canonicalRewardId:r.objectId}])]),/有效计薪凭据/);
+ await q("SELECT xs_review_resolve('c','manager',$1::jsonb,'确认重复')",[JSON.stringify([{rewardId:copy.objectId,reasonCode:'duplicate_review',canonicalRewardId:canonical.objectId}])]);
+ assert.equal((await receipt('resolve-copy')).ineligibleReason,'duplicate_review');
+ // Another live completion of the same key cannot pay a second time.
+ await db.exec("SET session_replication_role='replica'");
+ await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey") VALUES('resolve-later','c',9002,10,20,$1,$1,'2026-09-05','2026-09-05',30,'0','','legacy:model:60:general:1004')`,[source.xxjlid]);
+ await db.exec("SET session_replication_role='origin'");
+ await q(`INSERT INTO "ReviewReward"("objectId",company,"taskId","coachId","studentId","plannedDate","completedAt","incomeMonth",amount,status) VALUES('late-receipt','c','resolve-later',10,20,'2026-09-05','2026-09-05 10:30+08','2026-09',1,'earned')`);
+ assert.equal((await receipt('resolve-later')).ineligibleReason,'duplicate_review');
+});
+test('缺失来源不猜时长;旧课次证据必须匹配学生和学习记录,不可覆盖已知课次',async()=>{
+ await task('source-evidence',{minutes:60,day:'2026-09-05',source:'legacy:model:60:general:1005',done:true});
+ const m=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'source-evidence\'');
+ await q('DELETE FROM "CommonModel" WHERE "modelId"=54 AND "generalId"=$1',[m.orderId]);
+ const r=await receipt('source-evidence');
+ const confirm=lesson=>q("SELECT xs_review_resolve('c','manager',$1::jsonb,'旧系统原课次核查',true)",[JSON.stringify([{rewardId:r.objectId,reasonCode:'legacy_assignment_policy',legacyLesson:lesson}])]);
+ await assert.rejects(confirm(null),/仍不明确/);
+ await assert.rejects(confirm({modelId:54,generalId:m.orderId,szyh:21,dslx:'1'}),/仍不明确/);
+ await confirm({modelId:54,generalId:m.orderId,szyh:20,dslx:'1'});assert.equal(Number((await receipt('source-evidence')).amount),.5);
+ await db.exec(sql('review-reconciliation'));assert.equal(Number((await receipt('source-evidence')).amount),.5);
+});