Przeglądaj źródła

perf(admin): verify payroll from transactional digests and load details on demand

彭峰 2 godzin temu
rodzic
commit
e72a405025
36 zmienionych plików z 978 dodań i 12 usunięć
  1. 7 0
      perf/admin-speed-round2-2026-11-10/BRIEF.md
  2. 24 0
      perf/admin-speed-round2-2026-11-10/README.md
  3. 91 0
      perf/admin-speed-round2-2026-11-10/browser-case.mjs
  4. 13 0
      perf/admin-speed-round2-2026-11-10/browser-paired.mjs
  5. 9 0
      perf/admin-speed-round2-2026-11-10/concurrency-result.json
  6. 29 0
      perf/admin-speed-round2-2026-11-10/concurrency.mjs
  7. 13 0
      perf/admin-speed-round2-2026-11-10/diagnose.mjs
  8. 16 0
      perf/admin-speed-round2-2026-11-10/paired-api.mjs
  9. 15 0
      perf/admin-speed-round2-2026-11-10/prepare-infrastructure.mjs
  10. 26 0
      perf/admin-speed-round2-2026-11-10/ratchet.json
  11. 29 0
      perf/admin-speed-round2-2026-11-10/release.mjs
  12. 6 0
      perf/admin-speed-round2-2026-11-10/rollback-rehearsal.json
  13. 20 0
      perf/admin-speed-round2-2026-11-10/rollback-web.py
  14. 6 0
      perf/admin-speed-round2-2026-11-10/server-util.mjs
  15. 10 0
      perf/admin-speed-round2-2026-11-10/summarize.mjs
  16. 122 0
      perf/admin-speed-round2-2026-11-10/summary.json
  17. 9 0
      perf/admin-speed-round2-2026-11-10/upgrade-infrastructure.mjs
  18. 55 0
      perf/admin-speed-round2-2026-11-10/verify-live.mjs
  19. 10 0
      perf/admin-speed-round2-2026-11-10/warm-live.mjs
  20. 17 0
      perf/admin-speed-round2-2026-11-10/实验日志.md
  21. 35 0
      perf/admin-speed-round2-2026-11-10/验收报告.md
  22. 2 2
      projects/xiaoshu-admin/src/app/operations.service.ts
  23. 1 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html
  24. 24 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts
  25. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  26. 5 0
      scripts/admin-speed-round2-patch.mjs
  27. 21 9
      scripts/cloud/payroll-integrity.js
  28. 31 0
      scripts/sql/payroll-cutoff-fast.sql
  29. 88 0
      scripts/sql/payroll-incremental-reads.sql
  30. 29 0
      scripts/sql/payroll-read-rollback.sql
  31. 94 0
      scripts/sql/payroll-source-fast.sql
  32. 39 0
      scripts/sql/payroll-verified-preview.sql
  33. 3 1
      scripts/tests/launch-fixes.test.mjs
  34. 41 0
      scripts/tests/payroll-incremental-reads.test.mjs
  35. 17 0
      scripts/tests/payroll-integrity.test.mjs
  36. 21 0
      scripts/tests/payroll-preview-cache.test.mjs

+ 7 - 0
perf/admin-speed-round2-2026-11-10/BRIEF.md

@@ -0,0 +1,7 @@
+# 第二轮:完整核验接近 1 秒
+
+用户要求在保证准确性前提下,将约 4 秒的慢页和约 16 秒的费用完整核验尽量压到 1 秒,并授权优化验证后上线。本轮基线为已发布 7b8eaba,仓库 HEAD 3b02ba0。
+
+终点为真实金额明细与完整来源核验结果均可操作,不能只测 displayOnly;不把过期缓存、跳过冲突或绕过结算检查当提速。保持源数据哈希、冲突指纹、月份与租户隔离、生成/审核的事务检查。先拆解 SQL,再验证优化前后同一快照等价、相关变更即失效、竞态及异常路径;至少 10 轮交替测量。截图中其他慢页复用已验证的查询优化。
+
+已获得本批上线授权。仅提交任务文件;保留生产原件和精确回滚脚本,正式环境回读与只读浏览器验收后交付。

+ 24 - 0
perf/admin-speed-round2-2026-11-10/README.md

@@ -0,0 +1,24 @@
+# 复测与发布
+
+需要已有运营会话配置以及环境变量 `XIAOSHU_MASTER_KEY`,不得将密钥或原始业务响应提交。`server-util.mjs` 的临时入口只绑定指定运营账号且一小时过期,finally 删除。原始 JSON/截图保存在 git 忽略的 tmp 目录;公开统计只包含耗时、等价性和检查结果。
+
+```sh
+node --test scripts/tests/payroll-integrity.test.mjs scripts/tests/payroll-incremental-reads.test.mjs scripts/tests/launch-fixes.test.mjs scripts/tests/payroll-preview-cache.test.mjs scripts/tests/admin-read-performance.test.mjs scripts/tests/store-payroll.test.mjs
+npm run test:admin:ci
+npm run cloud:validate
+npm run build:admin
+node perf/admin-speed-round2-2026-11-10/concurrency.mjs
+# 不要与重负载测试同时运行;后台版本须与已准备的候选一致。
+PERF_RUNS=10 node perf/admin-speed-round2-2026-11-10/browser-paired.mjs
+PERF_COLD=1 PERF_PAGES=payroll-complete PERF_RUNS=10 PERF_OUTPUT=api-cold-paired.json node perf/admin-speed-round2-2026-11-10/paired-api.mjs
+```
+
+浏览器比较已上线的上一版(基线 3b02ba0 / 前端产物 7b8eaba)与当前候选,AB 顺序逐轮倒转,各 10 轮。两版均生产构建、相同 gzip、20 ms / 4 Mbps 下行 / 3 Mbps 上行、每次新上下文。费用测到完整核验;旧版金额先出现的时刻另记。对象键排序后比较所有汇总业务值,仅排除刷新时间、缓存命中与延迟秒数;老师明细在两版都实际打开,逐行比较与同一老师的原完整响应一致。API 完整响应另做全量等价比较。
+
+`PERF_COLD=1` 只清空测试帐套的派生缓存,不改变业务记录/财务状态。此模式须在公开切换前、没有其他测试并行时使用;模拟所有结果均需重建的保守情况。正常版本复用仍逐次检查当前依赖,真实数据提交后失效。
+
+数据库部署只增加派生对象及快速读 helper,原始 sources/source_version/cutoff_snapshot/commands 不变。当前发布过程由 prepare-infrastructure(初装)、upgrade-infrastructure(已验证派生 helper 升级)和 release(精确网关补丁)完成。每个阶段有私有原件、哈希及回执。第二次安装不得覆盖原件;源码变化须重新验证、准备。
+
+回滚先恢复原网关,再在定义无漂移时执行 payroll-read-rollback.sql 删除派生触发器/函数/表。`release.mjs rehearse` 实际执行删除后强制事务回滚,核对恢复与原始财务函数未变;`release.mjs rollback` 执行正式回退。静态站保留原版本所有哈希资源,通过 `python3 perf/admin-speed-round2-2026-11-10/rollback-web.py --apply` 恢复私有发布目录 admin/baseline 的 deployment-version.json 和 index.html,再刷新 CDN,回读版本与深路由。不得清空静态桶。
+
+变更日志是追加数据,不能凭时间或最大 ID 随意清理:必须同时使旧缓存及在途构建失效,否则会破坏乱序提交保护。现阶段未增加定时任务或未经验证的清理流程。

+ 91 - 0
perf/admin-speed-round2-2026-11-10/browser-case.mjs

@@ -0,0 +1,91 @@
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import fs from 'node:fs';
+import {resolve,extname} from 'node:path';
+import {gzipSync} from 'node:zlib';
+import assert from 'node:assert/strict';
+export async function browserHarness({functions,token,me,base,out}){
+ const servers=[],origins={},requests={};
+ for(const fn of functions){
+  const root=resolve(fn.variant==='before'?out+'/baseline-browser':'dist/xiaoshu-admin/browser');
+  const server=createServer(async(req,res)=>{
+   const path=new URL(req.url,'http://local').pathname;
+   try{
+    let body,contentType='application/json';
+    if(path.startsWith('/cloud-functions/')){
+     let raw='';for await(const chunk of req)raw+=chunk;const input=JSON.parse(raw),op=input.params?.operation;
+     if(op==='ops/coaches/list')input.params.refresh=true;
+     const primary=['ops/dashboard/summary','ops/users/list','ops/stores/list','ops/schedule/calendar','ops/coaches/list','ops/payroll/overview','ops/lessons/list','ops/learning-reports/list','ops/support-requests/list','ops/vocabulary/tree','ops/vocabulary/list','ops/special-training/list'].includes(op);
+     // This harness cannot send business mutations, even if a test accidentally clicks one.
+     if(!primary&&!['ops/live/versions','ops/payroll/list','ops/pay-rates/list','ops/teaching-rules/list','ops/payroll/coaches'].includes(op)){res.writeHead(403);res.end('{}');return;}
+     const r=await fetch(base+'/api/functions/'+(primary?fn.path:path.slice('/cloud-functions/'.length)),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(input),signal:AbortSignal.timeout(60000)});
+     body=Buffer.from(await r.arrayBuffer());res.statusCode=r.status;
+    }else{
+     let file=resolve(root,'.'+decodeURIComponent(path));if(!file.startsWith(root+'/')||!fs.existsSync(file)||!extname(file))file=root+'/index.html';
+     body=fs.readFileSync(file);contentType=({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.json':'application/json'})[extname(file)]||'application/octet-stream';
+    }
+    res.setHeader('Content-Type',contentType);res.setHeader('Content-Encoding','gzip');res.setHeader('Cache-Control','no-store');res.end(gzipSync(body));
+   }catch{res.writeHead(502);res.end('{}');}
+  });await new Promise(r=>server.listen(0,'127.0.0.1',r));servers.push(server);origins[fn.variant]='http://127.0.0.1:'+server.address().port;
+ }
+ const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+ const selectors={dashboard:'.overview-metrics',members:'app-operations-users tbody tr',stores:'app-operations-stores tbody tr',schedule:'.content-card[aria-busy="false"]',coaches:'app-operations-coaches tbody tr',payroll:'.payroll-summary-table tbody tr',lessons:'app-lesson-records tbody tr','learning-reports':'app-operations-learning-reports tbody tr','support-requests':'.request-list',vocabulary:'app-operations-vocabulary tbody tr','special-training':'.special-card'};
+ return {
+  async measure(fn,item,round){
+   const ctx=await browser.newContext({viewport:{width:1440,height:1000}}),page=await ctx.newPage(),records=[],errors=[];
+   await ctx.addInitScript(auth=>{localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));window.__perfLong=[];new PerformanceObserver(list=>window.__perfLong.push(...list.getEntries().map(e=>e.duration))).observe({type:'longtask',buffered:true});},{objectId:me.objectId,sessionToken:token,username:'性能验收',roles:me.roles,company:me.company,operationsRole:'ops-manager',isSuperAdmin:true});
+   page.on('pageerror',e=>errors.push(e.message));
+   const requestStarts=new Map();page.on('request',r=>requestStarts.set(r,performance.now()));const responseJobs=[];page.on('response',response=>{if(!response.url().includes('/cloud-functions/'))return;responseJobs.push((async()=>{const request=response.request(),p=request.postDataJSON()?.params||{};try{const data=await response.json();records.push({operation:p.operation,displayOnly:p.displayOnly===true,lineCoachId:p.lineCoachId,data:data.data,success:response.ok()&&data.success===true,ms:Math.round(performance.now()-start),requestMs:Math.round(performance.now()-(requestStarts.get(request)||start))});}catch{errors.push('Invalid response');}})());});
+   const cdp=await ctx.newCDPSession(page);await cdp.send('Network.enable');await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:20,downloadThroughput:4*1024*1024/8,uploadThroughput:3*1024*1024/8});
+   const start=performance.now();
+   const primaryResponse=page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation===item.params.operation,{timeout:60000});
+   const wordResponse=item.page==='vocabulary'?page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation==='ops/vocabulary/list',{timeout:60000}):null;
+   try{
+    await page.goto(origins[fn.variant]+'/admin/'+item.page,{waitUntil:'domcontentloaded'});
+    await (await primaryResponse).finished();if(wordResponse)await (await wordResponse).finished();await Promise.all(responseJobs);
+    await page.locator(selectors[item.page]).first().waitFor({state:'visible',timeout:60000});
+    // Vocabulary must have BOTH the tree and the current word page available.
+    await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
+    const readyMs=Math.round(performance.now()-start);
+    const input=page.locator('main input:not([type="file"]):not([disabled])').first();let interactive=true;
+    if(await input.count()){const previous=await input.inputValue();await input.fill('测速');interactive=(await input.inputValue())==='测速';await input.fill(previous);}
+    if(item.page==='payroll'){
+     // Baseline first shows displayOnly amounts; the finish line is full verification.
+     if(fn.variant==='before')assert.equal(await page.locator('.create-payroll-action').isDisabled(),true);
+     await page.waitForFunction(()=>!document.querySelector('.payroll-source-row')?.textContent?.includes('正在核验结算资格'),null,{timeout:60000});
+    }
+    const completeMs=Math.round(performance.now()-start);
+    await Promise.all(responseJobs);
+    assert.equal(interactive,true);assert.deepEqual(errors,[]);
+    const primary=records.filter(r=>r.operation===item.params.operation);assert.ok(primary.length&&primary.every(r=>r.success),'primary data must succeed');
+    if(round===0){
+     await page.mouse.move(0,0);for(const width of [1440,1000]){await page.setViewportSize({width,height:1000});await page.waitForTimeout(150);await page.screenshot({path:out+'/'+item.page+'-'+fn.variant+'-'+width+'.png',animations:'disabled'});}await page.setViewportSize({width:1440,height:1000});
+    }
+    let data=primary.at(-1).data;let coachDetailMs;
+    if(item.page==='payroll'){
+     assert.notEqual(data.sourceStatus.sync.state,'not_checked');
+     assert.equal(await page.locator('.payroll-summary-table tbody tr').count(),Math.max(data.coachItems.length,1));
+     const coachId=data.coachItems[0]?.coachId;
+     if(coachId){
+      const detailStart=performance.now(),detailResponse=fn.variant==='after'?page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.lineCoachId===coachId,{timeout:60000}):null;
+      await page.locator('.view-action').first().click();await page.locator('.payroll-drawer').waitFor();
+      let lines=data.lines.filter(l=>l.coachId===coachId);
+      if(detailResponse){const body=await (await detailResponse).json();assert.equal(body.success,true);lines=body.data.lines;assert.ok(lines.every(l=>l.coachId===coachId));}
+      await page.waitForFunction(()=>!document.querySelector('.payroll-drawer')?.textContent?.includes('正在加载明细'),null,{timeout:60000});
+      assert.equal(await page.locator('.payroll-drawer tbody tr').count(),Math.max(lines.length,1));
+      coachDetailMs=Math.round(performance.now()-detailStart);
+      data={...data,lines:[],exceptions:[],verifiedCoachLines:lines};
+     }
+    }
+    let menuReturn;
+    if(['lessons','support-requests'].includes(item.page)){
+     await page.locator('#admin-sidebar a[href="/admin/special-training"]').last().click();await page.locator('.special-card').first().waitFor({timeout:30000});
+     const count=records.filter(r=>r.operation===item.params.operation).length,at=performance.now();await page.locator('#admin-sidebar a[href="/admin/'+item.page+'"]').last().click();await page.locator(selectors[item.page]).first().waitFor({timeout:30000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await Promise.all(responseJobs);
+     menuReturn={ms:Math.round(performance.now()-at),requests:records.filter(r=>r.operation===item.params.operation).length-count};
+    }
+    return{data,coachDetailMs,readyMs:item.page==='payroll'?completeMs:readyMs,displayMs:readyMs,completeMs,menuReturn,primaryRequests:primary.length,operations:records.map(({operation,ms,requestMs})=>({operation,ms,requestMs})),errors,longTaskMs:Math.round(await page.evaluate(()=>window.__perfLong.reduce((a,b)=>a+b,0))),resources:await page.evaluate(()=>performance.getEntriesByType('resource').reduce((s,r)=>({requests:s.requests+1,bytes:s.bytes+r.transferSize}),{requests:0,bytes:0}))};
+   }finally{await ctx.close();}
+  },
+  async close(){await browser.close();for(const server of servers)await new Promise(r=>server.close(r));}
+ };
+}

+ 13 - 0
perf/admin-speed-round2-2026-11-10/browser-paired.mjs

@@ -0,0 +1,13 @@
+import fs from 'node:fs';import {createHash,randomBytes} from 'node:crypto';import {parse,operator,base,token} from './server-util.mjs';import {patchRound2} from '../../scripts/admin-speed-round2-patch.mjs';import {browserHarness} from './browser-case.mjs';
+const out='tmp/admin-speed-round2-2026-11-10',root='/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed-round2',original=JSON.parse(fs.readFileSync(root+'/original-gateway.json')).code,me=await operator();
+const cases=[{page:'payroll',params:{operation:'ops/payroll/overview'}},{page:'dashboard',params:{operation:'ops/dashboard/summary'}},{page:'coaches',params:{operation:'ops/coaches/list'}}].filter(c=>!process.env.PERF_PAGES||process.env.PERF_PAGES.split(',').includes(c.page));
+function clean(x){if(Array.isArray(x))return x.map(clean);if(x&&typeof x==='object')return Object.fromEntries(Object.entries(x).filter(([k])=>!['refreshedAt','payrollRefreshedAt','lagSeconds','ageMs','cacheHit'].includes(k)).sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>[k,clean(v)]));return x;}
+const hash=x=>createHash('sha256').update(JSON.stringify(clean(x))).digest('hex');const functions=[],results=[];let browser;
+try{
+ for(const [variant,source] of Object.entries({before:original,after:patchRound2(original)})){
+ const path='xiaoshu/system/speed2-browser-'+randomBytes(10).toString('hex'),code=source+`\nrealtimeCacheWrite=async()=>{};const actual=handler;handler=async function(request,response){const p=request.body?.params||{};if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+3600000}||!${JSON.stringify(cases.map(c=>c.params.operation))}.includes(p.operation))return response.status(403).json({success:false});return actual(request,response);};`;
+ const row=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});functions.push({...row,path,variant});
+ }
+ browser=await browserHarness({functions,token,me,base,out});
+ for(let round=0;round<Number(process.env.PERF_RUNS||10);round++)for(const item of cases){const pair=[];for(const fn of round%2?[...functions].reverse():functions){const measured=await browser.measure(fn,item,round);fs.writeFileSync(out+'/'+item.page+'-'+fn.variant+'-browser.json',JSON.stringify(measured.data),{mode:0o600});const {data,...metrics}=measured;const result={round,page:item.page,variant:fn.variant,hash:hash(data),...metrics};results.push(result);pair.push(result);}console.log(JSON.stringify({round,page:item.page,equal:pair[0].hash===pair[1].hash,times:pair.map(({variant,readyMs,displayMs})=>({variant,readyMs,displayMs}))}));fs.writeFileSync(out+'/'+(process.env.PERF_OUTPUT||'browser-paired.json'),JSON.stringify(results,null,2),{mode:0o600});}
+}finally{await browser?.close();for(const fn of functions)await parse('/classes/Function/'+fn.objectId,'DELETE');}

+ 9 - 0
perf/admin-speed-round2-2026-11-10/concurrency-result.json

@@ -0,0 +1,9 @@
+{
+  "at": "2026-11-10T05:47:10.824Z",
+  "isolatedSchemaDropped": true,
+  "outOfOrderCommit": true,
+  "commitVisibility": true,
+  "racingPublishRejected": true,
+  "rollback": true,
+  "scopedInvalidation": true
+}

+ 29 - 0
perf/admin-speed-round2-2026-11-10/concurrency.mjs

@@ -0,0 +1,29 @@
+import fs from 'node:fs';import assert from 'node:assert/strict';import {randomBytes} from 'node:crypto';import {withGateway} from './server-util.mjs';
+const schema='speed_fixture_'+randomBytes(8).toString('hex'),tables=['CommonModel','CourseAppointment','LessonRecord','MemoryPracticeRecord','LessonRecordFact','ReviewReward','ReviewRewardCorrection','TeachingCompensationRule','CourseTeachingProfile','CoachPayRate','TeachingCourseCategory','PayrollLine','PayrollBatch','LegacyDirectRun','LegacyDirectEvidence','LegacySyncConflict','PayrollCutoffDecision','PayrollCutoffApproval','_User','Node','CourseBinding'];
+let setup=`CREATE SCHEMA ${schema};SET LOCAL search_path=${schema},public;SET LOCAL TimeZone='Etc/GMT-8';SET LOCAL extra_float_digits=1;`;
+for(const tab of tables)setup+=`CREATE TABLE "${tab}"("objectId" text PRIMARY KEY,company text,value text,"modelId" numeric DEFAULT 54);INSERT INTO "${tab}"("objectId",company,value) VALUES('a','a','old'),('b','b','other');`;
+setup+=`INSERT INTO "CommonModel"("objectId",company,value,"modelId") VALUES('r60','a','old',60.0),('r59','a','old',59.0);CREATE TABLE "Function"(path text,code text);INSERT INTO "Function" VALUES('xiaoshu/ops/gateway-v3','fixture');CREATE VIEW "ReviewRewardIncome" AS SELECT * FROM "ReviewReward";`;
+setup+=['payroll-incremental-reads.sql','payroll-verified-preview.sql'].map(f=>fs.readFileSync('scripts/sql/'+f,'utf8').replaceAll('public.',schema+'.').replaceAll("'public'",String.fromCharCode(39)+schema+String.fromCharCode(39))).join('\n');
+setup+=fs.readFileSync('scripts/sql/payroll-source-fast.sql','utf8').match(/CREATE OR REPLACE FUNCTION xs_payroll_sources_current[\s\S]*?\n\$\$;/)[0];
+async function run(p){
+ if(p.action==='setup')return Psql.tx(async tx=>{await tx.none(setup);return true;});
+ if(p.action==='drop'){await Psql.none('DROP SCHEMA '+schema+' CASCADE');return true;}
+ return Psql.tx(async tx=>{
+  await tx.none(`SET LOCAL search_path=${schema},public;SET LOCAL TimeZone='Etc/GMT-8';SET LOCAL extra_float_digits=1;`);
+  if(p.action==='read')return tx.one("SELECT xs_payroll_data_version('a') AS digest,xs_payroll_preview_cache_read('a','month:11') AS cache");
+  if(p.action==='write-cache')return tx.one("SELECT xs_payroll_preview_cache_write('a','month:11',$1,$2::jsonb) AS saved",[p.stamp,JSON.stringify({snapshot:p.snapshot,result:{amount:99}})]);
+  if(p.action==='source-current')return tx.one("SELECT xs_payroll_sources_current('a',$1) AS current",[p.snapshot]);
+  if(p.action==='scope-change'){const tables={review:'MemoryPracticeRecord',appointment:'CourseAppointment',lesson:'LessonRecord'};if(tables[p.scope])await tx.none('UPDATE \"'+tables[p.scope]+'\" SET value=$1 WHERE company=$2',[p.value,'a']);else await tx.none('UPDATE "CommonModel" SET "modelId"=$1::numeric,value=$2 WHERE "objectId"=$3',[p.scope==='common54'?'54.0':p.scope==='common59'?'59.0':'60.0',p.value,p.scope==='common54'?'a':p.scope==='common59'?'r59':'r60']);return true;}
+  if(p.action==='quick'){await tx.none('UPDATE "CourseAppointment" SET value=$1 WHERE company=$2',['later commit','a']);return true;}
+  if(p.action==='mutate'){await tx.none('UPDATE "LessonRecord" SET value=$1 WHERE company=$2',[p.value,'a']);await tx.one('SELECT pg_sleep(2.5)');if(p.rollback)throw Error('intentional rollback');return true;}
+  throw Error('Unknown fixture operation');
+ });
+}
+const source='const schema='+JSON.stringify(schema)+';const setup='+JSON.stringify(setup)+';'+run.toString();
+const results={};await withGateway(source,async call=>{await call({action:'setup'});try{
+ const original=await call({action:'read'});assert.equal((await call({action:'write-cache',stamp:original.cache.stamp,snapshot:original.cache.snapshot})).saved,true);
+ const mutation=call({action:'mutate',value:'committed'});await new Promise(r=>setTimeout(r,300));const during=await call({action:'read'});assert.equal(during.digest,original.digest);assert.equal(during.cache.payload.amount,99);await call({action:'quick'});const later=await call({action:'read'});assert.equal((await call({action:'write-cache',stamp:later.cache.stamp,snapshot:later.cache.snapshot})).saved,true);assert.equal((await call({action:'read'})).cache.payload.amount,99);await mutation;results.outOfOrderCommit=true;
+ const after=await call({action:'read'});assert.notEqual(after.digest,original.digest);assert.equal(after.cache.payload,null);assert.equal((await call({action:'write-cache',stamp:original.cache.stamp,snapshot:original.cache.snapshot})).saved,false);results.commitVisibility=true;results.racingPublishRejected=true;
+ await assert.rejects(call({action:'mutate',value:'rolled back',rollback:true}),/intentional rollback/);assert.equal((await call({action:'read'})).digest,after.digest);results.rollback=true;for(const scope of ['review','common60','common54','common59','appointment','lesson']){const saved=(await call({action:'read'})).cache.snapshot;await call({action:'scope-change',scope,value:'scope-'+scope});assert.equal((await call({action:'source-current',snapshot:saved})).current,['review','common60'].includes(scope));}results.scopedInvalidation=true;
+ }finally{await call({action:'drop'});}});
+fs.writeFileSync('perf/admin-speed-round2-2026-11-10/concurrency-result.json',JSON.stringify({at:new Date().toISOString(),isolatedSchemaDropped:true,...results},null,2)+'\n');console.log(JSON.stringify(results));

+ 13 - 0
perf/admin-speed-round2-2026-11-10/diagnose.mjs

@@ -0,0 +1,13 @@
+// Temporary, operator-bound read-only diagnostics; no persistent application changes.
+import {readFileSync,writeFileSync} from 'node:fs';
+import {randomBytes} from 'node:crypto';
+
+const app=process.env.XIAOSHU_PARSE_APP_ID,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN,master=process.env.XIAOSHU_MASTER_KEY,base='https://server.xiaoshu.pro/parse';
+if(!app||!token||!master)throw Error('Missing authorized config');
+const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master};
+async function parse(path,method='GET',body){const r=await fetch(base+path,{method,headers,body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(20000)});const d=await r.json();if(!r.ok||d.error)throw Error('Diagnostic setup failed HTTP '+r.status);return d;}
+const me=await(await fetch(base+'/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':token}})).json();if(!me.objectId)throw Error('Invalid operator');
+const jobs=JSON.parse(readFileSync(process.argv[2],'utf8'));if(jobs.some(j=>!/^\s*(SELECT|WITH|EXPLAIN)\b/i.test(j.sql)))throw Error('Select jobs required');
+const tempView=readFileSync('scripts/sql/admin-read-performance.sql','utf8').replace('CREATE OR REPLACE VIEW','CREATE TEMP VIEW');
+const path='xiaoshu/system/perf-sql-'+randomBytes(10).toString('hex'),code=`const jobs=${JSON.stringify(jobs)};async function handler(request,response){if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+1200000})return response.status(403).json({success:false});const job=jobs.find(j=>j.name===request.body?.params?.name);if(!job)return response.status(400).json({success:false});const start=Date.now();try{const rows=await Psql.tx(async tx=>{if(job.setup){await tx.none(job.setup);}if(job.tempView){await tx.none('SET LOCAL search_path=pg_temp,public');await tx.none(${JSON.stringify(tempView)});}else if(!job.setup)await tx.none('SET TRANSACTION READ ONLY');await tx.none(\"SET LOCAL statement_timeout='60s'\");if(job.jitOff)await tx.none('SET LOCAL jit=off');const rows=await tx.query(job.sql,job.args||[]);if(job.tempView)await tx.none('DROP VIEW pg_temp.\"ReviewRewardIncome\"');return rows;});return response.json({success:true,ms:Date.now()-start,rows});}catch(e){return response.status(500).json({success:false,ms:Date.now()-start,error:e.message});}}`;
+let fn;try{fn=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});for(const j of jobs){const r=await fetch(base.replace('/parse','/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{name:j.name}}),signal:AbortSignal.timeout(75000)}),data=await r.json();writeFileSync('tmp/admin-speed-round2-2026-11-10/'+j.name+'.json',JSON.stringify(data),{mode:0o600});console.log(JSON.stringify({name:j.name,success:data.success,ms:data.ms,error:data.error,rows:data.rows?.length}));}}finally{if(fn)await parse('/classes/Function/'+fn.objectId,'DELETE');}

+ 16 - 0
perf/admin-speed-round2-2026-11-10/paired-api.mjs

@@ -0,0 +1,16 @@
+import fs from 'node:fs';import {createHash,randomBytes} from 'node:crypto';import {parse,operator,base,token} from './server-util.mjs';import {patchRound2} from '../../scripts/admin-speed-round2-patch.mjs';
+const out='tmp/admin-speed-round2-2026-11-10',root='/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed-round2',original=JSON.parse(fs.readFileSync(root+'/original-gateway.json')).code,me=await operator();
+const cases=[{page:'payroll-complete',operation:'ops/payroll/overview',month:'2026-11',displayOnly:false},{page:'dashboard',operation:'ops/dashboard/summary'},{page:'coaches',operation:'ops/coaches/list',pageNumber:1,pageSize:20}].filter(c=>!process.env.PERF_PAGES||process.env.PERF_PAGES.split(',').includes(c.page));
+function clean(x){if(Array.isArray(x))return x.map(clean);if(x&&typeof x==='object')return Object.fromEntries(Object.entries(x).filter(([k])=>!['refreshedAt','payrollRefreshedAt','lagSeconds','ageMs','cacheHit'].includes(k)).sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>[k,clean(v)]));return x;}
+const hash=x=>createHash('sha256').update(JSON.stringify(clean(x))).digest('hex');const functions=[],results=[];let resetGateway;
+try{
+ if(process.env.PERF_COLD==='1'){
+  const path='xiaoshu/system/speed2-reset-'+randomBytes(10).toString('hex'),code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+3600000})return response.status(403).json({success:false});await Psql.none('DELETE FROM "PayrollVerifiedReadCache" WHERE company=$1',['7pIbDBJmKx']);return response.json({success:true});}`;
+  resetGateway={...await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'}),path};
+ }
+ for(const [variant,source] of Object.entries({before:original,after:patchRound2(original)})){
+ const path='xiaoshu/system/speed2-api-'+randomBytes(10).toString('hex'),code=source+`\nrealtimeCacheWrite=async()=>{};const actual=handler;handler=async function(request,response){const p=request.body?.params||{};if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+3600000}||!${JSON.stringify(cases.map(c=>c.operation))}.includes(p.operation))return response.status(403).json({success:false});return actual(request,response);};`;
+ const row=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});functions.push({...row,path,variant});
+ }
+ for(let round=0;round<Number(process.env.PERF_RUNS||10);round++)for(const item of cases){const pair=[];for(const fn of round%2?[...functions].reverse():functions){if(resetGateway&&fn.variant==='after'){const reset=await fetch(base+'/api/functions/'+resetGateway.path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}})});if(!reset.ok||!(await reset.json()).success)throw Error('Derived-cache reset failed');}const start=performance.now(),params={...item,page:item.pageNumber||1,refresh:true};delete params.pageNumber;const r=await fetch(base+'/api/functions/'+fn.path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)}),body=await r.json();if(!r.ok||!body.success)throw Error(item.page+' '+fn.variant+' failed '+String(body.message||body.error||r.status).slice(0,200));const result={round,page:item.page,variant:fn.variant,ms:Math.round(performance.now()-start),hash:hash(body.data),cacheHit:body.data?.sourceStatus?.cacheHit===true};fs.writeFileSync(out+'/'+item.page+'-'+fn.variant+'-latest.json',JSON.stringify(body),{mode:0o600});results.push(result);pair.push(result);}console.log(JSON.stringify({round,page:item.page,equal:pair[0].hash===pair[1].hash,times:pair.map(({variant,ms,cacheHit})=>({variant,ms,cacheHit}))}));fs.writeFileSync(out+'/'+(process.env.PERF_OUTPUT||'api-paired.json'),JSON.stringify(results,null,2),{mode:0o600});}
+}finally{for(const fn of [...functions,...(resetGateway?[resetGateway]:[])])await parse('/classes/Function/'+fn.objectId,'DELETE');}

+ 15 - 0
perf/admin-speed-round2-2026-11-10/prepare-infrastructure.mjs

@@ -0,0 +1,15 @@
+import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';import {withGateway,parse} from './server-util.mjs';
+const root='/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed-round2',mode=process.argv[2]||'prepare';fs.mkdirSync(root,{recursive:true,mode:0o700});
+const files=['payroll-incremental-reads.sql','payroll-source-fast.sql','payroll-cutoff-fast.sql','payroll-verified-preview.sql'],sql=files.map(f=>fs.readFileSync('scripts/sql/'+f,'utf8')).join('\n');
+const defSql="SELECT proname,pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname IN ('xs_payroll_sources','xs_payroll_source_version','xs_payroll_cutoff_snapshot','xs_payroll_cutoff_conflicts') ORDER BY proname";
+if(mode==='prepare'){
+ if(fs.existsSync(root+'/infrastructure-receipt.json'))throw Error('Already installed; preserve original backup');
+ const gateway=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path:'xiaoshu/ops/gateway-v3'}),limit:'2'}))).results;if(gateway.length!==1)throw Error('Ambiguous gateway');
+ const definitions=await withGateway('async function run(){return Psql.query('+JSON.stringify(defSql)+');}',call=>call({}));
+ fs.writeFileSync(root+'/original-gateway.json',JSON.stringify(gateway[0]),{mode:0o600});fs.writeFileSync(root+'/original-definitions.json',JSON.stringify(definitions),{mode:0o600});fs.writeFileSync(root+'/infrastructure.sql',sql,{mode:0o600});
+ fs.writeFileSync(root+'/infrastructure-manifest.json',JSON.stringify({sqlHash:createHash('sha256').update(sql).digest('hex'),beforeGatewayHash:createHash('sha256').update(gateway[0].code).digest('hex')},null,2),{mode:0o600});console.log(JSON.stringify({prepared:true,definitions:definitions.length,files}));
+}else if(mode==='install'){
+ const expected=JSON.parse(fs.readFileSync(root+'/original-definitions.json')),manifest=JSON.parse(fs.readFileSync(root+'/infrastructure-manifest.json'));if(createHash('sha256').update(sql).digest('hex')!==manifest.sqlHash)throw Error('Prepared SQL changed');
+ async function run(p){if(p.action==='vacuum'){await Psql.none('VACUUM (ANALYZE) "PayrollReadDigest"');return true;}if(p.action!=='install')throw Error('Unknown release action');return Psql.tx(async tx=>{await tx.none("SET LOCAL lock_timeout='5s';SET LOCAL statement_timeout='120s';");const current=await tx.query(defSql);if(JSON.stringify(current)!==JSON.stringify(expected))throw Error('Original financial definitions changed');const running=await tx.one('SELECT count(*)::int AS count FROM "LegacyDirectRun" WHERE status=$1',['running']);if(running.count)throw Error('Synchronization must be idle');if((await tx.one("SELECT to_regclass('\"PayrollReadDigest\"') IS NOT NULL AS present")).present)throw Error('Derived infrastructure already exists');await tx.none(sql);const ready=await tx.one('SELECT xs_payroll_digest_ready() AS ready,(SELECT count(*)::int FROM "PayrollReadDigest") AS rows');if(!ready.ready)throw Error('Digest maintenance is not verified');return ready;});}
+ const receipt=await withGateway('const expected='+JSON.stringify(expected)+';const sql='+JSON.stringify(sql)+';const defSql='+JSON.stringify(defSql)+';'+run.toString(),async call=>{const result=await call({action:'install'});fs.writeFileSync(root+'/infrastructure-receipt.json',JSON.stringify({at:new Date().toISOString(),...result,...manifest}),{mode:0o600});await call({action:'vacuum'});return result;});console.log(JSON.stringify({installed:true,...receipt}));
+}else throw Error('Use prepare or install');

+ 26 - 0
perf/admin-speed-round2-2026-11-10/ratchet.json

@@ -0,0 +1,26 @@
+{
+  "businessEquality": true,
+  "primaryRequests": {
+    "payroll": 1,
+    "dashboard": 1,
+    "coaches": 1
+  },
+  "payrollSummaryLines": 0,
+  "exactCoachDetailChecked": true,
+  "fullRebuildMustNotHitCache": true,
+  "thresholds": [
+    {
+      "page": "payroll",
+      "p75Ms": 1313
+    },
+    {
+      "page": "dashboard",
+      "p75Ms": 1014
+    },
+    {
+      "page": "coaches",
+      "p75Ms": 1853
+    }
+  ],
+  "note": "Counts and business equality are gates. Repeated paired measurements are required for timing regression; a single noisy sample is not a CI failure."
+}

+ 29 - 0
perf/admin-speed-round2-2026-11-10/release.mjs

@@ -0,0 +1,29 @@
+import fs from 'node:fs';import {createHash} from 'node:crypto';import {withGateway,parse,base,token} from './server-util.mjs';import {patchRound2} from '../../scripts/admin-speed-round2-patch.mjs';
+const dir='/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed-round2',mode=process.argv[2]||'prepare',hash=x=>createHash('sha256').update(x).digest('hex');
+const original=JSON.parse(fs.readFileSync(dir+'/original-gateway.json')),rollbackSql=fs.readFileSync('scripts/sql/payroll-read-rollback.sql','utf8'),expectedOriginals=JSON.parse(fs.readFileSync(dir+'/original-definitions.json'));
+const originalSql="SELECT proname,pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname IN ('xs_payroll_sources','xs_payroll_source_version','xs_payroll_cutoff_snapshot','xs_payroll_cutoff_conflicts') ORDER BY proname";
+const helpersSql="SELECT proname,pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname IN ('xs_payroll_preview_cache_write','xs_payroll_preview_cache_read','xs_payroll_snapshot_current','xs_payroll_preview_stamp','xs_payroll_cutoff_snapshot_fast','xs_payroll_source_version_fast','xs_payroll_source_version_stamp','xs_payroll_source_version_uncached_fast','xs_payroll_sources_cached','xs_payroll_source_stamp','xs_payroll_sources_current','xs_payroll_sources_uncached','xs_payroll_data_version','xs_payroll_digest_ready','xs_payroll_digest_row','xs_payroll_digest_truncate','xs_payroll_digest_signature','xs_payroll_authority_ready') ORDER BY proname";
+if(mode==='prepare'){
+ const candidate=patchRound2(original.code),live=await parse('/classes/Function/'+original.objectId);if(hash(live.code)!==hash(original.code))throw Error('Gateway changed since baseline');
+ const helpers=await withGateway('async function run(){return Psql.query('+JSON.stringify(helpersSql)+');}',call=>call({}));if(helpers.length!==18)throw Error('Incomplete acceleration helpers');
+ fs.writeFileSync(dir+'/candidate.js',candidate,{mode:0o600});fs.writeFileSync(dir+'/helpers.json',JSON.stringify(helpers),{mode:0o600});fs.writeFileSync(dir+'/manifest.json',JSON.stringify({beforeHash:hash(original.code),afterHash:hash(candidate),helperHash:hash(JSON.stringify(helpers)),rollbackHash:hash(rollbackSql)},null,2),{mode:0o600});console.log(JSON.stringify({prepared:true,afterHash:hash(candidate),helpers:helpers.length}));
+}else{
+ const manifest=JSON.parse(fs.readFileSync(dir+'/manifest.json')),expectedHelpers=JSON.parse(fs.readFileSync(dir+'/helpers.json')),candidate=fs.readFileSync(dir+'/candidate.js','utf8');if(hash(candidate)!==manifest.afterHash||hash(rollbackSql)!==manifest.rollbackHash)throw Error('Prepared release changed');
+ async function run(p){
+  if(p.action==='check'){if(JSON.stringify(await Psql.query(originalSql))!==JSON.stringify(expectedOriginals)||JSON.stringify(await Psql.query(helpersSql))!==JSON.stringify(expectedHelpers))throw Error('Database code drift');return Psql.one('SELECT xs_payroll_digest_ready() AS digest,xs_payroll_authority_ready() AS authority');}
+  if(!['rehearse','rollback'].includes(p.action))throw Error('Unknown database action');
+  try{return await Psql.tx(async tx=>{await tx.none("SET LOCAL lock_timeout='5s';SET LOCAL statement_timeout='60s';");if(JSON.stringify(await tx.query(originalSql))!==JSON.stringify(expectedOriginals)||JSON.stringify(await tx.query(helpersSql))!==JSON.stringify(expectedHelpers))throw Error('Database code drift');await tx.none(rollbackSql);if(JSON.stringify(await tx.query(originalSql))!==JSON.stringify(expectedOriginals))throw Error('Original financial functions changed');if(p.action==='rehearse')throw Error('ROLLBACK_REHEARSAL_OK');return{rolledBack:true};});}catch(e){if(p.action==='rehearse'&&e.message==='ROLLBACK_REHEARSAL_OK')return{rehearsed:true};throw e;}
+ }
+ const source='const originalSql='+JSON.stringify(originalSql)+';const helpersSql='+JSON.stringify(helpersSql)+';const expectedOriginals='+JSON.stringify(expectedOriginals)+';const expectedHelpers='+JSON.stringify(expectedHelpers)+';const rollbackSql='+JSON.stringify(rollbackSql)+';'+run.toString();
+ await withGateway(source,async call=>{
+  if(mode!=='rollback'){const ready=await call({action:'check'});if(!ready.digest||!ready.authority)throw Error('Fast path is not healthy');}
+  if(mode==='rehearse'){const r=await call({action:'rehearse'}),after=await call({action:'check'});if(!after.digest||!after.authority)throw Error('Rehearsal did not restore infrastructure');fs.writeFileSync('perf/admin-speed-round2-2026-11-10/rollback-rehearsal.json',JSON.stringify({at:new Date().toISOString(),...r,restored:true,originalFinancialFunctionsUnchanged:true},null,2)+'\n');console.log(JSON.stringify(r));return;}
+  if(!['deploy','rollback'].includes(mode))throw Error('Use prepare, rehearse, deploy or rollback');
+  const status=await(await fetch(base+'/api/functions/xiaoshu/sync/direct-v1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{action:'status'}}),signal:AbortSignal.timeout(20000)})).json();if(!Array.isArray(status.data?.runs)||status.data.runs.some(r=>r.status==='running'))throw Error('Synchronization must be idle');
+  const live=await parse('/classes/Function/'+original.objectId),target=mode==='deploy'?candidate:original.code,expected=mode==='deploy'?manifest.beforeHash:manifest.afterHash;if(hash(live.code)!==expected&&hash(live.code)!==hash(target))throw Error('Gateway code drift');
+  if(mode==='deploy'&&candidate!==patchRound2(original.code))throw Error('Working implementation differs from validated candidate');
+  await parse('/classes/Function/'+original.objectId,'PUT',{code:target});if(hash((await parse('/classes/Function/'+original.objectId)).code)!==hash(target))throw Error('Gateway readback mismatch');
+  if(mode==='rollback')await call({action:'rollback'});
+  fs.writeFileSync(dir+'/'+mode+'-receipt.json',JSON.stringify({at:new Date().toISOString(),gatewayHash:hash(target),...manifest}),{mode:0o600});console.log(JSON.stringify({mode,verified:true,gatewayHash:hash(target)}));
+ });
+}

+ 6 - 0
perf/admin-speed-round2-2026-11-10/rollback-rehearsal.json

@@ -0,0 +1,6 @@
+{
+  "at": "2026-11-10T06:13:27.629Z",
+  "rehearsed": true,
+  "restored": true,
+  "originalFinancialFunctionsUnchanged": true
+}

+ 20 - 0
perf/admin-speed-round2-2026-11-10/rollback-web.py

@@ -0,0 +1,20 @@
+"""Restore this release's saved admin entry; preserve all versioned assets."""
+from pathlib import Path
+import hashlib, importlib.util, json, sys, urllib.request
+assert sys.argv[1:] == ['--apply'], 'Use --apply only for the authorized release rollback'
+root=Path('/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed-round2/admin')
+origin='https://xiaoshu-admin.obs.cn-south-1.myhuaweicloud.com/'
+sha=lambda b:hashlib.sha256(b).hexdigest()
+current=json.loads(urllib.request.urlopen(origin+'deployment-version.json',timeout=30).read())
+expected=json.loads((root/'site/deployment-version.json').read_text())
+baseline=json.loads((root/'baseline/deployment-version.json').read_text())
+assert current['releaseId'] in [expected['releaseId'],baseline['releaseId']], 'Unrelated release has replaced this batch'
+for file in baseline['files']:
+    assert sha(urllib.request.urlopen(origin+file['path'],timeout=30).read() if file['path']!='index.html' else (root/'baseline/index.html').read_bytes()) == file['sha256'], 'Baseline asset missing'
+spec=importlib.util.spec_from_file_location('cloud','/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-mobile-web/cloud.py')
+cloud=importlib.util.module_from_spec(spec);spec.loader.exec_module(cloud)
+for name in ['deployment-version.json','index.html']:
+    (root/('rollback-'+name+'.log')).write_text(cloud.run('obs',['cp',str(root/'baseline'/name),'obs://xiaoshu-admin/'+name,'-vmd5','-acl=public-read']))
+    assert sha(urllib.request.urlopen(origin+name,timeout=30).read())==sha((root/'baseline'/name).read_bytes())
+(root/'rollback-cdn.json').write_text(cloud.run('cdn',['CreateRefreshTasks/v2','--refresh_task.type=directory','--refresh_task.mode=all','--refresh_task.urls.1=https://admin.xiaoshu.pro/']))
+print(json.dumps({'restored':baseline['releaseId'],'originVerified':True}))

+ 6 - 0
perf/admin-speed-round2-2026-11-10/server-util.mjs

@@ -0,0 +1,6 @@
+import fs from 'node:fs';import {randomBytes} from 'node:crypto';
+export const env={...process.env};for(const line of fs.readFileSync('/Users/mac/.config/xiaoshu/legacy-sync-nightly.env','utf8').split('\n')){const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m&&!env[m[1]])env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');}
+export const base='https://server.xiaoshu.pro',token=env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+export async function parse(path,method='GET',body){if(!env.XIAOSHU_MASTER_KEY)throw Error('Authorized configuration missing');const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':env.XIAOSHU_PARSE_APP_ID,'X-Parse-Master-Key':env.XIAOSHU_MASTER_KEY},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)});const data=await r.json();if(!r.ok||data.error)throw Error('Parse request failed '+r.status);return data;}
+export async function operator(){const r=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':env.XIAOSHU_PARSE_APP_ID,'X-Parse-Session-Token':token},signal:AbortSignal.timeout(15000)});const user=await r.json();if(!r.ok||!user.objectId)throw Error('Operator session invalid');return user;}
+export async function withGateway(source,task){const me=await operator(),path='xiaoshu/system/speed2-'+randomBytes(10).toString('hex');const code=source+`\nasync function handler(request,response){if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+3600000})return response.status(403).json({success:false});try{return response.json({success:true,data:await run(request.body?.params||{})});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;const row=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});try{return await task(async params=>{const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(180000)});const data=await r.json();if(!r.ok||!data.success)throw Error(String(data.message||'Diagnostic operation failed').slice(0,400));return data.data;},{path,me});}finally{await parse('/classes/Function/'+row.objectId,'DELETE');}}

Plik diff jest za duży
+ 10 - 0
perf/admin-speed-round2-2026-11-10/summarize.mjs


+ 122 - 0
perf/admin-speed-round2-2026-11-10/summary.json

@@ -0,0 +1,122 @@
+{
+  "at": "2026-11-10T06:21:40.128Z",
+  "protocol": "10 alternating paired samples; p75 nearest rank; browser new contexts and identical production assets/gzip/20 ms/4 Mbps down/3 Mbps up; full verified summary plus separately opened exact coach detail",
+  "browser": [
+    {
+      "page": "payroll",
+      "samplesPerVariant": 10,
+      "allBusinessPairsEqual": true,
+      "beforeMs": {
+        "p50": 15380,
+        "p75": 15659,
+        "p95": 15913
+      },
+      "afterMs": {
+        "p50": 1288,
+        "p75": 1313,
+        "p95": 1340
+      },
+      "reductionPercent": 91.6,
+      "coachDetailP75": 453,
+      "primaryRequests": {
+        "before": [
+          2
+        ],
+        "after": [
+          1
+        ]
+      },
+      "resourceRequestsP75": {
+        "before": 45,
+        "after": 29
+      },
+      "resourceBytesP75": {
+        "before": 782250,
+        "after": 411483
+      }
+    },
+    {
+      "page": "dashboard",
+      "samplesPerVariant": 10,
+      "allBusinessPairsEqual": true,
+      "beforeMs": {
+        "p50": 3552,
+        "p75": 3653,
+        "p95": 4199
+      },
+      "afterMs": {
+        "p50": 969,
+        "p75": 1014,
+        "p95": 2886
+      },
+      "reductionPercent": 72.2,
+      "primaryRequests": {
+        "before": [
+          1
+        ],
+        "after": [
+          1
+        ]
+      },
+      "resourceRequestsP75": {
+        "before": 24,
+        "after": 20
+      },
+      "resourceBytesP75": {
+        "before": 385373,
+        "after": 350066
+      }
+    },
+    {
+      "page": "coaches",
+      "samplesPerVariant": 10,
+      "allBusinessPairsEqual": true,
+      "beforeMs": {
+        "p50": 4027,
+        "p75": 4117,
+        "p95": 4503
+      },
+      "afterMs": {
+        "p50": 1822,
+        "p75": 1853,
+        "p95": 2340
+      },
+      "reductionPercent": 55,
+      "primaryRequests": {
+        "before": [
+          1
+        ],
+        "after": [
+          1
+        ]
+      },
+      "resourceRequestsP75": {
+        "before": 40,
+        "after": 22
+      },
+      "resourceBytesP75": {
+        "before": 558457,
+        "after": 365011
+      }
+    }
+  ],
+  "apiFullRebuild": [
+    {
+      "page": "payroll-complete",
+      "samplesPerVariant": 10,
+      "allBusinessPairsEqual": true,
+      "beforeMs": {
+        "p50": 11073,
+        "p75": 11261,
+        "p95": 12373
+      },
+      "afterMs": {
+        "p50": 6509,
+        "p75": 6751,
+        "p95": 7567
+      },
+      "reductionPercent": 40,
+      "afterCacheHitCount": 0
+    }
+  ]
+}

+ 9 - 0
perf/admin-speed-round2-2026-11-10/upgrade-infrastructure.mjs

@@ -0,0 +1,9 @@
+// Upgrade only derived helpers, after isolated rollback/concurrency verification.
+import fs from 'node:fs';import {createHash} from 'node:crypto';import {withGateway} from './server-util.mjs';
+const root='/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed-round2';
+let sql=fs.readFileSync('scripts/sql/payroll-incremental-reads.sql','utf8').split('-- Installation locks source writers')[0];
+sql+=`UPDATE "PayrollReadDigestMeta" m SET signature=xs_payroll_digest_signature(v.tab) FROM (VALUES ('common','CommonModel'),('appointment','CourseAppointment'),('lesson','LessonRecord'),('review','MemoryPracticeRecord')) v(kind,tab) WHERE m.kind=v.kind;`;
+sql+=['payroll-source-fast.sql','payroll-cutoff-fast.sql','payroll-verified-preview.sql'].map(f=>fs.readFileSync('scripts/sql/'+f,'utf8')).join('\n');
+const expected=JSON.parse(fs.readFileSync(root+'/original-definitions.json')),defSql="SELECT proname,pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname IN ('xs_payroll_sources','xs_payroll_source_version','xs_payroll_cutoff_snapshot','xs_payroll_cutoff_conflicts') ORDER BY proname";
+async function run(){return Psql.tx(async tx=>{await tx.none("SET LOCAL lock_timeout='5s';SET LOCAL statement_timeout='60s';LOCK TABLE \"CommonModel\",\"CourseAppointment\",\"LessonRecord\",\"MemoryPracticeRecord\" IN SHARE ROW EXCLUSIVE MODE;");if(!(await tx.one('SELECT xs_payroll_digest_ready() ready')).ready)throw Error('Existing row digest must be healthy before metadata upgrade');if(JSON.stringify(await tx.query(defSql))!==JSON.stringify(expected))throw Error('Authoritative functions changed');await tx.none(sql);if(!(await tx.one('SELECT xs_payroll_digest_ready() ready')).ready)throw Error('Digest metadata upgrade failed');return{ready:true,originalFunctionsUnchanged:true};});}
+const receipt=await withGateway('const sql='+JSON.stringify(sql)+';const expected='+JSON.stringify(expected)+';const defSql='+JSON.stringify(defSql)+';'+run.toString(),call=>call({}));fs.writeFileSync(root+'/infrastructure-v2.sql',sql,{mode:0o600});fs.writeFileSync(root+'/infrastructure-v2-receipt.json',JSON.stringify({at:new Date().toISOString(),sha256:createHash('sha256').update(sql).digest('hex'),...receipt}),{mode:0o600});console.log(JSON.stringify(receipt));

+ 55 - 0
perf/admin-speed-round2-2026-11-10/verify-live.mjs

@@ -0,0 +1,55 @@
+// Public hashes + authenticated read-only browser acceptance. No business mutations.
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+import {createHash} from 'node:crypto';
+import {chromium} from 'playwright';
+const env={};for(const line of fs.readFileSync('/Users/mac/.config/xiaoshu/legacy-sync-nightly.env','utf8').split('\n')){const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m)env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');}
+const origin='https://admin.xiaoshu.pro',base='https://server.xiaoshu.pro',token=env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+const manifest=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));
+const sha=data=>createHash('sha256').update(data).digest('hex');
+async function get(url){const r=await fetch(url,{signal:AbortSignal.timeout(30000)});assert.ok(r.ok,'HTTP '+r.status);return r;}
+const current=await(await get(origin+'/deployment-version.json')).json();assert.equal(current.releaseId,manifest.releaseId);assert.equal(current.commit,manifest.commit);
+for(let i=0;i<manifest.files.length;i+=6)await Promise.all(manifest.files.slice(i,i+6).map(async f=>assert.equal(sha(Buffer.from(await(await get(origin+'/'+f.path)).arrayBuffer())),f.sha256,'Public hash mismatch: '+f.path)));
+const selectors={dashboard:'.overview-metrics',members:'app-operations-users tbody tr',stores:'app-operations-stores tbody tr',schedule:'.content-card[aria-busy="false"]',coaches:'app-operations-coaches tbody tr',payroll:'.payroll-summary-table tbody tr',lessons:'app-lesson-records tbody tr','learning-reports':'app-operations-learning-reports tbody tr','support-requests':'.request-list',vocabulary:'app-operations-vocabulary tbody tr','special-training':'.special-card'};
+const operations={dashboard:'ops/dashboard/summary',members:'ops/users/list',stores:'ops/stores/list',schedule:'ops/schedule/calendar',coaches:'ops/coaches/list',payroll:'ops/payroll/overview',lessons:'ops/lessons/list','learning-reports':'ops/learning-reports/list','support-requests':'ops/support-requests/list',vocabulary:'ops/vocabulary/tree','special-training':'ops/special-training/list'};
+const indexHash=manifest.files.find(f=>f.path==='index.html').sha256;
+for(const page of ['login',...Object.keys(selectors)])assert.equal(sha(Buffer.from(await(await get(origin+'/admin/'+page)).arrayBuffer())),indexHash,'Deep route: '+page);
+console.log(JSON.stringify({publicAssets:manifest.files.length,deepRoutes:12,releaseId:manifest.releaseId}));
+const meResponse=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':env.XIAOSHU_PARSE_APP_ID,'X-Parse-Session-Token':token}}),me=await meResponse.json();assert.ok(me.objectId,'Operator session invalid');
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),pages=[];
+try{
+for(const [name,selector] of Object.entries(selectors)){
+ const ctx=await browser.newContext({viewport:{width:1440,height:1000}}),page=await ctx.newPage(),errors=[],records=[],jobs=[];
+ await ctx.addInitScript(auth=>localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth)),{objectId:me.objectId,sessionToken:token,username:'上线验收',roles:me.roles,company:me.company,operationsRole:'ops-manager',isSuperAdmin:true});
+ const allowed=new Set([...Object.values(operations),'ops/vocabulary/list','ops/live/versions','ops/payroll/list','ops/pay-rates/list','ops/teaching-rules/list','ops/payroll/coaches']);
+ await page.route('**/*',async route=>{const req=route.request();if(req.method()==='POST'&&req.url().includes('/api/functions/')){const op=req.postDataJSON()?.params?.operation;if(!allowed.has(op)){errors.push('Blocked unexpected operation');await route.abort();return;}}await route.continue();});
+ page.on('pageerror',()=>errors.push('Browser runtime error'));
+ const start=performance.now();
+ page.on('response',res=>{const req=res.request();if(req.method()!=='POST'||!req.url().includes('/api/functions/'))return;jobs.push((async()=>{try{const body=await res.json(),params=req.postDataJSON()?.params||{};records.push({operation:params.operation,displayOnly:params.displayOnly===true,summaryOnly:params.summaryOnly===true,lineCoachId:params.lineCoachId,success:res.ok()&&body.success===true,data:body.data});}catch{errors.push('Invalid API response');}})());});
+ const primary=page.waitForResponse(res=>res.request().method()==='POST'&&res.request().postDataJSON()?.params?.operation===operations[name],{timeout:60000});
+ const words=name==='vocabulary'?page.waitForResponse(res=>res.request().method()==='POST'&&res.request().postDataJSON()?.params?.operation==='ops/vocabulary/list',{timeout:60000}):null;
+ await page.goto(origin+'/admin/'+name,{waitUntil:'domcontentloaded'});await(await primary).finished();if(words)await(await words).finished();await page.locator(selector).first().waitFor({state:'visible',timeout:60000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
+ const readyMs=Math.round(performance.now()-start);let verificationDisabled;
+ if(name==='payroll')await page.waitForFunction(()=>!document.querySelector('.payroll-source-row')?.textContent?.includes('正在核验结算资格'),null,{timeout:60000});
+ await Promise.all(jobs);assert.deepEqual(errors,[]);assert.ok(records.length&&records.every(r=>r.success),'Unsuccessful API: '+name);
+ const selected=records.filter(r=>r.operation===operations[name]);assert.ok(selected.length);
+ let coachDetailMs,exportVerified;
+ if(name==='payroll'){
+  const summary=selected.at(-1);assert.ok(summary.summaryOnly&&!summary.displayOnly);assert.notEqual(summary.data.sourceStatus.sync.state,'not_checked');assert.equal(summary.data.lines.length,0);
+  const exported=await(await fetch(base+'/api/functions/xiaoshu/ops/gateway-v3',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/payroll/export',month:summary.data.month,summaryOnly:true,lineCoachId:summary.data.coachItems[0]?.coachId}}),signal:AbortSignal.timeout(60000)})).json();assert.equal(exported.success,true);assert.equal(exported.data.summary.payableAmount,summary.data.payableAmount);assert.equal(exported.data.coachItems.length,summary.data.coachItems.length);assert.ok(exported.data.lines.length>0);exportVerified=true;
+  verificationDisabled=await page.locator('.create-payroll-action').isDisabled();if(!summary.data.sourceStatus.sync.settlementReady)assert.equal(verificationDisabled,true);
+  const coachId=summary.data.coachItems[0]?.coachId;if(coachId){
+   const startDetail=performance.now(),response=page.waitForResponse(r=>r.request().method()==='POST'&&r.request().postDataJSON()?.params?.lineCoachId===coachId,{timeout:60000});
+   await page.locator('.view-action').first().click();const result=await(await response).json();assert.equal(result.success,true);assert.ok(result.data.lines.every(l=>l.coachId===coachId));assert.notEqual(result.data.sourceStatus.sync.state,'not_checked');
+   await page.waitForFunction(()=>!document.querySelector('.payroll-drawer')?.textContent?.includes('正在加载明细'),null,{timeout:60000});assert.equal(await page.locator('.payroll-drawer tbody tr').count(),Math.max(result.data.lines.length,1));coachDetailMs=Math.round(performance.now()-startDetail);
+   await page.locator('.payroll-drawer button[aria-label="关闭"]').click();assert.equal(await page.locator('.payroll-drawer').count(),0);
+  }
+ }
+
+ let menuReturn;
+ if(['lessons','support-requests'].includes(name)){await page.locator('#admin-sidebar a[href="/admin/special-training"]').last().click();await page.locator('.special-card').first().waitFor({timeout:30000});await Promise.all(jobs);const count=records.filter(r=>r.operation===operations[name]).length,at=performance.now();await page.locator('#admin-sidebar a[href="/admin/'+name+'"]').last().click();await page.locator(selector).first().waitFor({timeout:30000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await Promise.all(jobs);menuReturn={ms:Math.round(performance.now()-at),primaryRequests:records.filter(r=>r.operation===operations[name]).length-count};assert.equal(menuReturn.primaryRequests,0);}
+ const result={page:name,readyMs,passed:true,primaryRequests:selected.length,verificationDisabled,coachDetailMs,exportVerified,menuReturn};pages.push(result);console.log(JSON.stringify(result));await ctx.close();
+}
+}finally{await browser.close();}
+const receipt={at:new Date().toISOString(),origin,releaseId:manifest.releaseId,commit:manifest.commit,filesVerified:manifest.files.length,deepRoutesVerified:12,readOnly:true,measurement:'One production smoke sample per page; not comparable to controlled p75',pages};
+fs.writeFileSync('perf/admin-speed-round2-2026-11-10/release-verification.json',JSON.stringify(receipt,null,2)+'\n');

+ 10 - 0
perf/admin-speed-round2-2026-11-10/warm-live.mjs

@@ -0,0 +1,10 @@
+// Recompute under the just-published gateway signature; never publish stale warm data.
+import assert from 'node:assert/strict';import fs from 'node:fs';import {base,token,parse} from './server-util.mjs';
+const checks=[];
+for(const params of [{operation:'ops/payroll/overview',month:'2026-11',summaryOnly:true},{operation:'ops/dashboard/summary',refresh:true},{operation:'ops/coaches/list',page:1,pageSize:20,refresh:true}]){
+ const at=performance.now(),r=await fetch(base+'/api/functions/xiaoshu/ops/gateway-v3',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)}),result=await r.json();assert.ok(r.ok&&result.success);
+ if(params.summaryOnly){assert.equal(result.data.lines.length,0);assert.notEqual(result.data.sourceStatus.sync.state,'not_checked');}
+ checks.push({operation:params.operation,ms:Math.round(performance.now()-at),verified:true});
+}
+const temporary=await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path:{$regex:'^xiaoshu/system/speed2-'}}),keys:'objectId,path',limit:'1000'}));assert.equal(temporary.results.length,0,'Temporary diagnostic gateways remain');
+const receipt={at:new Date().toISOString(),checks,temporaryGatewaysRemoved:true};fs.writeFileSync('perf/admin-speed-round2-2026-11-10/live-warm-verification.json',JSON.stringify(receipt,null,2)+'\n');console.log(JSON.stringify(receipt));

+ 17 - 0
perf/admin-speed-round2-2026-11-10/实验日志.md

@@ -0,0 +1,17 @@
+# 实验和准确性护栏
+
+1. 分段计时:原始完整截止快照约 10.4 秒,全量原始行哈希约 4.5 秒,工资来源版本约 2.3 秒,冲突解析约 0.6 秒。原始核验每次遍历约 51 万条记录;凭据匹配有约 20 万次索引查找。
+2. 凭据查询改成集合关联,完整快照约 10.4→8.3 秒,原始 JSON 完全一致。保留重复编号、缺失附表、来源数量、ID 清单排序校验、人工冲突指纹、整月及运行中同步限制。
+3. 淘汰两种方案:用全表 MVCC 指纹仍需约 2.1 秒(按物理地址排序降到约 0.7 秒);查询时再将全表与摘要逐行关联仍约 4.6 秒。最终采用事务内维护准确行摘要,读时直接聚合摘要,实际约 144 ms。摘要结果与旧 `md5(row::text)` 按原次序拼接完全一致;不是改用更弱字段,也没有舍弃大字段。
+4. 摘要采用独立派生表,四张来源表的 ALWAYS 触发器随原事务维护。租户移动、删除、作废、TRUNCATE、回滚均有等价测试。环境、列结构、触发器状态/定义不匹配时回到原始全量路径。恢复维护后用代次指纹使中断前缓存失效。
+5. 按 PostgreSQL 事务快照复用已验证结果。使用 xid8 + pg_current_snapshot + pg_visible_in_snapshot 检查提交可见性,避免最大流水号、时间戳漏掉迟提交事务。日志键包含事务 ID,不增加全帐套写锁;金额来源与完整核验分别追踪依赖。真实隔离 schema 验证了未提交不可见、乱序提交、拒绝旧请求写缓存、回滚及来源范围区分,之后删除 schema。
+6. 金额来源、来源版本和预览分层复用。工资来源版本只计算一次,由完整核验传给金额读取;并发缺失时采用非阻塞派生缓存锁,避免缓存填充互相等待。所有命中都重新核对当前依赖;无效时重算,不返回旧快照冒充新结果。
+7. 原始工资 SQL 定义单独登记且不在重装时自动覆盖。若日后来源/版本/截止政策改变,旧加速路径停用,直接调用当前原始函数。生成、重算、审核等 `includeUsed=false`、requireLive 或带 targetId 的调用跳过预览缓存,继续走原来的财务检查。
+8. 费用前端恢复单次完整读取:首屏终点是完整核验结果,不再以 displayOnly 金额先出现作为性能成绩。工作台与陪练列表复用相同准确金额结果。
+
+独立代码审查发现并推动修复:维护中断后的旧缓存复活、原始 SQL 政策变化后的旧投影复用、回滚被健康状态阻挡、并发测试遗漏快照参数。修复后审查无剩余 Critical/Important 问题;这不代替最终性能及上线验收。
+
+性能分别记录正常版本复用和清空所有派生结果后的完全重建,不能混称“所有场景 1 秒”。静态首次加载仍使用同样的新浏览器上下文和网络限速。
+
+9. 全量已核验结果继续在服务端计算,首屏只传输汇总(28 位老师),不再下发 1,484 条暂未展示的来源行(原始约 1.20 MB,gzip 106.5 KB)。点击老师时按当前权限范围读取对应明细,同时更新该次完整核验的汇总。默认接口与导出仍返回完整数据。
+10. 明细加载有显式状态、错误与重试;两次交错请求测试覆盖旧明细覆盖新老师、关闭后迟返回、页面刷新后汇总覆盖明细,以及批次加载被老师请求取消。批次加载采用独立状态,后台刷新保留批次编辑。新增传输改动经第二次独立审查,无剩余 Critical/Important 问题。

+ 35 - 0
perf/admin-speed-round2-2026-11-10/验收报告.md

@@ -0,0 +1,35 @@
+# 管理后台第二轮提速验收
+
+同条件生产构建、每组 10 轮 AB/BA 交替;费用终点为真实汇总和完整来源核验均可用,老师明细另行实际打开。不是金额占位、跳过核验或旧值秒开。
+
+| 操作 | 优化前 p75 | 优化后 p75 | 降幅 |
+|---|---:|---:|---:|
+| 费用页完整核验 | 15.66 秒 | 1.31 秒 | 91.6% |
+| 工作台 | 3.65 秒 | 1.01 秒 | 72.2% |
+| 陪练老师 | 4.12 秒 | 1.85 秒 | 55% |
+
+费用页按需打开老师明细 p75:0.45 秒。所有 30 组浏览器业务比较一致;费用比较保留全部汇总/核验字段,并将真正点击取得的老师明细与旧版对应原始行逐条比较。API 全量响应另有 10 组完全重建比较,全部一致。
+
+## 速度边界
+
+上述为来源版本未变、可复用已核验结果的情况;每次都检查当前事务变更和依赖。清空本帐套所有结果缓存后,完整重算接口 p75 为 11.26 → 6.75 秒,10 次均未命中缓存。原始来源/政策变动导致失效后的首次查询仍可能需要数秒,不能声称所有情况都已达到 1 秒。
+
+浏览器为新上下文、相同 gzip、20 ms 延迟、4 Mbps 下行/3 Mbps 上行。原始响应及截图只保留在被 Git 忽略的 tmp 目录。没有公开 CrUX/真实用户分位统计;线上逐页检查属于生产冒烟,不能与受控 p75 混为一谈。
+
+## 实现与准确性
+
+- 事务内维护准确行摘要,将约 51 万条来源记录的重复全文哈希移出读路径;内容、排序和原校验值保持一致。
+- 用数据库事务快照验证派生结果,检测新增、修改、删除、作废、回滚、跨租户移动和乱序提交;不是仅靠有效期或最大更新时间。
+- 费率、规则、人工调整、冲突、核验凭据、用户与门店等依赖变化即失效。触发器停用、结构/函数/环境变化会回退原始计算;修复维护时更换代次避免旧缓存复活。
+- 首屏传完整已核验的 28 位老师汇总;1,484 条明细改为点击后按老师读取。明细请求成功时同时更新对应的当前汇总,避免不同版本混用。导出强制读取完整数据,不受展示裁剪参数影响。
+- 生成、重算、审核、发放仍采用原始财务检查。未确认的差异仍会阻止结算,本次不修改财务业务记录。
+
+## 验证
+
+- 后端定向测试 46 项、管理端 361 项通过;11 个云函数定义校验、管理端生产构建通过。构建仅有已有样式预算和 html2pdf 提示。
+- 真实数据库隔离 schema 验证乱序提交、提交可见性、拒绝过期发布、回滚和来源范围失效;结束后已删除隔离 schema。
+- 原始 sources/source_version/cutoff_snapshot/cutoff_conflicts 定义未变。派生设施卸载已实际事务回滚演练。
+- 两次独立代码审查无剩余 Critical/Important 项;页面两种宽度截图与基线布局一致。窄桌面下既有筛选栏溢出未在本轮调整。
+- 最终发布版本及公网校验见 release-verification.json;完整回滚方式见 README.md。
+
+性能回归门禁见 ratchet.json:真实业务等价、每页首屏主要读取仅 1 次、首屏无明细传输、明细准确性以及完整重算不得伪命中缓存。时间回归使用重复配对测量,不用一次随机样本直接判错。

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

@@ -164,8 +164,8 @@ export class OperationsService {
     return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
   }
 
-  payrollOverview(month: string, storeId = 0, search = '', refresh = false, displayOnly = false): Promise<PayrollOverview> {
-    return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh, ...(displayOnly ? { displayOnly: true } : {}) });
+  payrollOverview(month: string, storeId = 0, search = '', refresh = false, displayOnly = false, detail: {summaryOnly?: boolean; lineCoachId?: number} = {}): Promise<PayrollOverview> {
+    return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh, ...detail, ...(displayOnly ? { displayOnly: true } : {}) });
   }
 
   payrollDetail(targetId: string): Promise<PayrollDetail> {

Plik diff jest za duży
+ 1 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html


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

@@ -56,6 +56,30 @@ describe('payroll interactions', () => {
   TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:service},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});
   component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
  });
+ it('loads exact coach details and ignores a late response after switching teacher',async()=>{
+  let old!:(v:PayrollOverview)=>void;const current={...overview('2026-09'),payableAmount:70,lines:[{coachId:2,amount:70}]} as PayrollOverview;
+  service.payrollOverview.and.returnValues(new Promise(r=>old=r),Promise.resolve(current));
+  const first=component.openCoach({coachId:1,coachName:'one'});expect(component.coachLoading()).toBeTrue();await component.openCoach({coachId:2,coachName:'two'});old(overview('2026-08'));await first;
+  expect(component.overview()).toBe(current);expect(component.coachLines().length).toBe(1);expect(component.coachLoading()).toBeFalse();expect(service.payrollOverview.calls.mostRecent().args[5]).toEqual({lineCoachId:2});
+ });
+ it('keeps failed details explicit and never replaces overview after closing drawer',async()=>{
+  service.payrollOverview.and.rejectWith(new Error('明细服务暂不可用'));await component.openCoach({coachId:1,coachName:'one'});expect(component.coachError()).toContain('暂不可用');expect(component.coachLoading()).toBeFalse();
+  let finish!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValue(new Promise(r=>finish=r));const request=component.openCoach({coachId:1,coachName:'one'});component.closeDrawer();finish(overview('2026-08'));await request;expect(component.overview()).toBeNull();expect(component.selectedCoachId()).toBe(0);
+ });
+ it('refetches an open coach when an older in-flight page load applies its summary',async()=>{
+  let finish!:(v:PayrollOverview)=>void;const current={...overview('2026-09'),lines:[{coachId:2,amount:70}]} as PayrollOverview;
+  service.payrollOverview.and.returnValues(new Promise(r=>finish=r),Promise.resolve(current),Promise.resolve(current));
+  const load=component.load();await component.openCoach({coachId:2,coachName:'two'});finish(overview('2026-09'));await load;await Promise.resolve();
+  expect(component.coachLines().length).toBe(1);expect(service.payrollOverview.calls.count()).toBe(3);
+ });
+ it('preserves the active batch drawer on background refresh',async()=>{
+  const batch={objectId:'batch',lines:[]} as never;component.detail.set(batch);await component.load(false,true);expect(component.detail()).toBe(batch);
+ });
+ it('releases batch loading when a coach supersedes the pending batch request',async()=>{
+  component.loading.set(false);let finish!:(v:any)=>void;service.payrollDetail.and.returnValue(new Promise(r=>finish=r));
+  const request=component.open({objectId:'batch'} as PayrollSummary);expect(component.batchLoading()).toBeTrue();await component.openCoach({coachId:2,coachName:'two'});finish({objectId:'batch',lines:[]});await request;
+  expect(component.loading()).toBeFalse();expect(component.batchLoading()).toBeFalse();expect(component.detail()).toBeNull();
+ });
  it('ignores an older query response after month changes',async()=>{
   let resolveOld!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValues(new Promise(r=>resolveOld=r),Promise.resolve(overview('2026-10')));
   const old=component.load();component.month.set('2026-10');await component.load();resolveOld(overview('2026-09'));await old;expect(component.overview()?.month).toBe('2026-10');

Plik diff jest za duży
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


+ 5 - 0
scripts/admin-speed-round2-patch.mjs

@@ -0,0 +1,5 @@
+import ts from 'typescript';import {readFileSync} from 'node:fs';
+const source=readFileSync(new URL('./cloud/payroll-integrity.js',import.meta.url),'utf8');
+export const round2Helpers=['payrollSettlementSyncStatus','payrollCanonicalSnapshot','payrollPreviewData','payrollOverviewData','payrollExportData'];
+function functions(code){const file=ts.createSourceFile('gateway.js',code,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);return new Map(file.statements.filter(ts.isFunctionDeclaration).map(node=>[node.name?.text,{start:node.getStart(file),end:node.end,text:node.getText(file)}]));}
+export function patchRound2(original){const current=functions(original),next=functions(source);const edits=round2Helpers.map(name=>{if(!current.has(name)||!next.has(name))throw Error('Missing helper '+name);return{...current.get(name),text:next.get(name).text};}).sort((a,b)=>b.start-a.start);let result=original;for(const edit of edits)result=result.slice(0,edit.start)+edit.text+result.slice(edit.end);if(current.has('payrollPreviewUncached'))throw Error('Unexpected already-patched gateway');result+='\n'+next.get('payrollPreviewUncached').text;new Function(result);return result;}

+ 21 - 9
scripts/cloud/payroll-integrity.js

@@ -15,10 +15,10 @@ function payrollTenant(context,input){requireRole(context,['ops-manager','ops-st
 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);}
 const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
-async function payrollSettlementSyncStatus(context,input){
+async function payrollSettlementSyncStatus(context,input,readAcceleration=false){
  const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7);
- try {const s=(await payrollSql('SELECT xs_payroll_cutoff_snapshot($1,$2) AS result',[co,month])).result;
- return {state:s.approved?'cutoff_verified':'batch_verified',settlementReady:s.approved,cutoffVersion:s.version,monthClosed:s.monthClosed,lastSuccessAt:s.cutoffAt||'',lagSeconds:s.cutoffAt?Math.max(0,Math.floor((Date.now()-new Date(s.cutoffAt).getTime())/1000)):null,datasets:s.datasets,message:s.approved?(s.monthClosed?'该月份来源已核验;源数据变化后须重新确认':'已核验截至同步时间的来源,可生成草稿;整月同步后才能审核'):'旧接口来源截止核验待完成:'+(!s.complete?'凭据缺失或业务编号不一致':s.pending+' 条计薪差异待核对')};
+ try {const s=(await payrollSql('SELECT '+(readAcceleration?'xs_payroll_cutoff_snapshot_fast':'xs_payroll_cutoff_snapshot')+'($1,$2) AS result',[co,month])).result;
+ return {...(readAcceleration?{readSourceVersion:s.sourceVersion}:{}),state:s.approved?'cutoff_verified':'batch_verified',settlementReady:s.approved,cutoffVersion:s.version,monthClosed:s.monthClosed,lastSuccessAt:s.cutoffAt||'',lagSeconds:s.cutoffAt?Math.max(0,Math.floor((Date.now()-new Date(s.cutoffAt).getTime())/1000)):null,datasets:s.datasets,message:s.approved?(s.monthClosed?'该月份来源已核验;源数据变化后须重新确认':'已核验截至同步时间的来源,可生成草稿;整月同步后才能审核'):'旧接口来源截止核验待完成:'+(!s.complete?'凭据缺失或业务编号不一致':s.pending+' 条计薪差异待核对')};
  }catch(error){return {state:'error',settlementReady:false,message:'工资来源核验暂不可用:'+text(error.message).slice(0,140),datasets:[]};}
 }
 async function payrollCutoff(context,input,payload,confirm=false){
@@ -31,8 +31,9 @@ async function payrollCanonicalSnapshot(context,input,options={}) {
   const co=payrollTenant(context,input);
   // A display-only estimate cannot authorize settlement. Financial reads and all
   // commands keep the full source version and cutoff checks by default.
-  const forDashboard=options.forDashboard===true;
-  const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,'+((forDashboard||options.omitSourceVersion===true)?"''::text":'xs_payroll_source_version($1)')+' AS version FROM xs_payroll_sources($1) s',[co]),forDashboard?Promise.resolve({state:'not_checked',settlementReady:false,datasets:[],message:'金额已按实时完课记录计算,结算资格仍需完整核验'}):payrollSettlementSyncStatus(context,input)]);
+  const forDashboard=options.forDashboard===true,readAcceleration=options.readAcceleration===true;
+  const [snapshot,syncResult]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,'+((forDashboard||readAcceleration||options.omitSourceVersion===true)?"''::text":(readAcceleration?'xs_payroll_source_version_fast($1)':'xs_payroll_source_version($1)'))+' AS version FROM '+(readAcceleration?'xs_payroll_sources_cached':'xs_payroll_sources')+'($1) s',[co]),forDashboard?Promise.resolve({state:'not_checked',settlementReady:false,datasets:[],message:'金额已按实时完课记录计算,结算资格仍需完整核验'}):payrollSettlementSyncStatus(context,input,readAcceleration)]);
+  const {readSourceVersion,...sync}=syncResult;
   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 items=(snapshot.rows||[]).map(row=>{
@@ -42,7 +43,7 @@ async function payrollCanonicalSnapshot(context,input,options={}) {
       studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
       teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
   });
-  const scopedItems=context.storeId?items.filter(i=>i.storeId===context.storeId):items;return {items:scopedItems,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:scopedItems.length,refreshedAt:new Date().toISOString(),complete:sync.settlementReady,stale:!sync.settlementReady,cacheHit:false,sync}};
+  const scopedItems=context.storeId?items.filter(i=>i.storeId===context.storeId):items;return {items:scopedItems,version:readAcceleration&&!forDashboard&&options.omitSourceVersion!==true?text(readSourceVersion):snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:scopedItems.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)};}
 function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
@@ -51,9 +52,19 @@ function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
  return {...item,kind:'lesson',standardLessonKey:payrollIdentity(item),lessonId:item.objectId,lessonAt:payrollDateIso(item.lessonAt),originalMonth:date.slice(0,7),settlementMonth,classType:type,classTypeLabel:categoryName||classType(type),courseCategoryKey:categoryKey,courseCategoryName:categoryName,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),durationMinutes:minutes,durationHours:configured?minutes/60:type===4?0:number(rule?.durationHours,minutes/60),teachingRuleId:text(item.teachingRuleId||rule?.objectId),rateAmount:amount,amount:exception?0:amount,description:snapshot?'按已保存的完课/预约工资快照':'按上课日期匹配历史工资规则',exception};
 }
 async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false,options={}){
+ // Generation, refresh, review and forced-live callers retain uncached financial checks.
+ if(!includeUsed||requireLive||input.targetId)return payrollPreviewUncached(context,input,month,includeUsed,requireLive,options);
+ const co=payrollTenant(context,input),key='preview-v1:'+JSON.stringify([month,context.storeId||0,number(input.storeId||(input.filters||{}).storeId),text(input.search),options.forDashboard===true,options.omitSourceVersion===true]);
+ let cached;try{cached=(await Psql.one('SELECT xs_payroll_preview_cache_read($1,$2) AS result',[co,key])).result;}catch{ /* Missing/failed cache always falls back to the authoritative path. */ }
+ if(cached?.payload){const result=cached.payload,refreshedAt=new Date().toISOString(),sync={...result.sourceStatus.sync};if(sync.lastSuccessAt)sync.lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(sync.lastSuccessAt).getTime())/1000));return {...result,refreshedAt,sourceStatus:{...result.sourceStatus,refreshedAt,cacheHit:true,sync}};}
+ const result=await payrollPreviewUncached(context,input,month,includeUsed,requireLive,{...options,readAcceleration:Boolean(cached?.stamp)});
+ if(cached?.stamp&&result.sourceStatus?.sync?.state!=='error')try{await Psql.one('SELECT xs_payroll_preview_cache_write($1,$2,$3,$4::jsonb) AS saved',[co,key,cached.stamp,JSON.stringify({snapshot:cached.snapshot,result})]);}catch{ /* Cache availability must not change financial results. */ }
+ return result;
+}
+async function payrollPreviewUncached(context,input,month,includeUsed=false,requireLive=false,options={}){
  if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');
  if(context.storeId)input={...input,storeId:context.storeId};
- const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,{...input,month},{forDashboard:options.forDashboard===true&&includeUsed&&!requireLive,omitSourceVersion:options.omitSourceVersion===true&&includeUsed&&!requireLive}),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
+ const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,{...input,month},{forDashboard:options.forDashboard===true&&includeUsed&&!requireLive,omitSourceVersion:options.omitSourceVersion===true&&includeUsed&&!requireLive,readAcceleration:options.readAcceleration===true&&includeUsed&&!requireLive&&!input.targetId}),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
  const other=new Set(used.filter(r=>r.batchId!==input.targetId).map(r=>r.standardLessonKey)),mappingMap=new Map(mappings.filter(r=>r.confirmed).map(r=>[r.courseId,r]));
  const previous=month.endsWith('-01')?(Number(month.slice(0,4))-1)+'-12':month.slice(0,5)+String(Number(month.slice(5))-1).padStart(2,'0');
  const allowLate=!includeUsed&&Boolean((await Psql.one('SELECT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE "company"=$1 AND "month"=$2 AND COALESCE("scopeStoreId",0)=$3 AND "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous,context.storeId||0])).value);
@@ -72,9 +83,10 @@ async function payrollOverviewData(context,input){
  const batch=batches[0],settlement=batch?await payrollDetailData(context,{},batch.objectId):null;
  const visible=payrollResult(month,income.lines,income.sourceStatus,input),settlementTotals=settlement?payrollResult(month,settlement.lines,income.sourceStatus,input):null;
  const stores=context.storeId?[{storeId:context.storeId,storeName:storeNameOf((context.store||context.current).toJSON())}]:await Psql.query('SELECT "legacyUserId" AS "storeId",COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"username") AS "storeName" FROM "_User" WHERE "company"=$1 AND ("identityType"=\'store\' OR "legacyGroupId"=2) ORDER BY "legacyUserId"',[co]);
- 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};
+ const detailLines=input.summaryOnly===true?[]:number(input.lineCoachId)>0?visible.lines.filter(line=>line.coachId===number(input.lineCoachId)):visible.lines;
+ return {...visible,lines:detailLines,exceptions:detailLines.filter(line=>line.exception&&!line.excluded),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,summaryOnly:false,lineCoachId:0}),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){
  // Confirmation validates the selected immutable receipts in SQL; bridge configuration
  // is unrelated to whether these particular legacy tasks were completed and unpaid.

+ 31 - 0
scripts/sql/payroll-cutoff-fast.sql

@@ -0,0 +1,31 @@
+-- Same cutoff semantics and hashes; bulk membership join and incremental row digest.
+CREATE OR REPLACE FUNCTION public.xs_payroll_cutoff_snapshot_fast(co text, mon text)
+ RETURNS jsonb
+ LANGUAGE plpgsql
+ SET jit TO 'off'
+ SET work_mem TO '64MB'
+ SET max_parallel_workers_per_gather TO '2'
+AS $function$
+DECLARE run "LegacyDirectRun"; ev record; table_name text; matches integer; complete boolean:=true; conflicts jsonb; version text; approval "PayrollCutoffApproval"; data_version text; source_version text; pending integer; datasets jsonb:='[]'; ready boolean;
+BEGIN
+ IF NOT xs_payroll_authority_ready() THEN RETURN xs_payroll_cutoff_snapshot(co,mon);END IF;
+ IF mon !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
+ SELECT * INTO run FROM "LegacyDirectRun" WHERE company=co AND status IN ('completed','completed-with-preserved-conflicts') ORDER BY "finishedAt" DESC LIMIT 1;
+ IF NOT FOUND THEN complete:=false; END IF;
+ IF (SELECT count(*) FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60))<>3 THEN complete:=false; END IF;
+ FOR ev IN SELECT * FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60) LOOP
+  table_name:=CASE ev.model WHEN 54 THEN 'CourseAppointment' WHEN 59 THEN 'LessonRecord' ELSE 'MemoryPracticeRecord' END;
+  EXECUTE format('WITH local_rows AS MATERIALIZED (SELECT c."generalId" FROM "CommonModel" c JOIN %I a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2), wanted AS MATERIALIZED (SELECT DISTINCT jsonb_array_elements_text($3) AS id) SELECT count(*)::int FROM (SELECT l."generalId" FROM local_rows l JOIN wanted w ON w.id=l."generalId"::text GROUP BY l."generalId" HAVING count(*)=1) x',table_name) INTO matches USING co,ev.model,ev.ids;
+  complete:=complete AND matches=ev."sourceCount" AND ev."sourceChecksum"=ev."targetChecksum" AND jsonb_array_length(ev.ids)=ev."sourceCount" AND ev."sourceChecksum"=md5((SELECT COALESCE(string_agg(v,E'\n' ORDER BY v::numeric),'') FROM jsonb_array_elements_text(ev.ids) v));
+  datasets:=datasets||jsonb_build_array(jsonb_build_object('model',ev.model,'source',ev."sourceCount",'matched',matches,'checksum',ev."sourceChecksum"));
+ END LOOP;
+ SELECT COALESCE(jsonb_agg(c ORDER BY c->>'id'),'[]') INTO conflicts FROM xs_payroll_cutoff_conflicts(co,mon) c;
+ SELECT count(*) INTO pending FROM jsonb_array_elements(conflicts) c WHERE NOT (c->>'resolved')::boolean;
+ -- Native row serialization covers every column without constructing a JSON object per row.
+ data_version:=xs_payroll_data_version(co);
+ source_version:=xs_payroll_source_version_fast(co);
+ version:=md5(jsonb_build_array(data_version,source_version,run."runId",run."finishedAt",datasets,conflicts)::text);
+ SELECT * INTO approval FROM "PayrollCutoffApproval" WHERE company=co AND month=mon;
+ ready:=complete AND pending=0 AND approval.version=version AND NOT EXISTS(SELECT 1 FROM "LegacyDirectRun" WHERE company=co AND status='running');
+ RETURN jsonb_build_object('month',mon,'version',version,'sourceVersion',source_version,'runId',run."runId",'cutoffAt',run."finishedAt",'complete',complete,'pending',pending,'conflicts',conflicts,'datasets',datasets,'approved',COALESCE(ready,false),'monthClosed',COALESCE(run."finishedAt">=((mon||'-01')::date+interval '1 month') AT TIME ZONE 'Asia/Shanghai',false),'approval',to_jsonb(approval));
+END $function$;

+ 88 - 0
scripts/sql/payroll-incremental-reads.sql

@@ -0,0 +1,88 @@
+-- Derived, transactionally maintained fingerprints. No financial source is changed.
+-- Install atomically after verifying original definitions; rollback restores them first.
+CREATE TABLE IF NOT EXISTS "PayrollReadDigest" (
+ kind text NOT NULL, company text NOT NULL, "objectId" text NOT NULL,
+ model numeric, hash text NOT NULL, PRIMARY KEY(kind,company,"objectId")
+);
+CREATE INDEX IF NOT EXISTS payroll_read_digest_order ON "PayrollReadDigest" (company,kind COLLATE "C",hash COLLATE "C");
+CREATE TABLE IF NOT EXISTS "PayrollReadAuthority" (name text PRIMARY KEY, hash text);
+INSERT INTO "PayrollReadAuthority" SELECT name,md5(pg_get_functiondef(to_regprocedure(name))) FROM (VALUES ('xs_payroll_sources(text)'),('xs_payroll_source_version(text)'),('xs_payroll_cutoff_snapshot(text,text)')) v(name) ON CONFLICT(name) DO NOTHING;
+CREATE TABLE IF NOT EXISTS "PayrollReadDigestMeta" (kind text PRIMARY KEY, signature text NOT NULL);
+CREATE TABLE IF NOT EXISTS "PayrollReadChangeScope" (company text NOT NULL, scope text NOT NULL, xid xid8 NOT NULL, PRIMARY KEY(company,scope,xid));
+CREATE TABLE IF NOT EXISTS "PayrollReadChange" (company text NOT NULL, xid xid8 NOT NULL, PRIMARY KEY(company,xid));
+CREATE TABLE IF NOT EXISTS "PayrollVerifiedReadCache" (
+ company text NOT NULL, key text NOT NULL, stamp text NOT NULL, payload jsonb NOT NULL,
+ "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,key)
+);
+REVOKE ALL ON "PayrollReadDigest","PayrollReadDigestMeta","PayrollVerifiedReadCache","PayrollReadChange","PayrollReadChangeScope","PayrollReadAuthority" FROM PUBLIC;
+
+CREATE OR REPLACE FUNCTION xs_payroll_authority_ready() RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT count(*)=3 AND bool_and(hash IS NOT DISTINCT FROM md5(pg_get_functiondef(to_regprocedure(name)))) FROM "PayrollReadAuthority";
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_digest_signature(tab text) RETURNS text LANGUAGE sql STABLE AS $$
+ SELECT md5(COALESCE((SELECT string_agg(a.attnum||':'||a.attname||':'||a.atttypid||':'||a.atttypmod,'|' ORDER BY a.attnum) FROM pg_attribute a WHERE a.attrelid=to_regclass(format('public.%I',tab)) AND a.attnum>0 AND NOT a.attisdropped),'')||'|'||
+ COALESCE((SELECT string_agg(t.tgname||':'||t.xmin||':'||t.ctid||':'||t.tgenabled::text||':'||pg_get_triggerdef(t.oid),'|' ORDER BY t.tgname) FROM pg_trigger t WHERE t.tgrelid=to_regclass(format('public.%I',tab)) AND t.tgname IN ('xs_payroll_digest_row','xs_payroll_digest_truncate')),'')||'|'||
+ COALESCE(pg_get_functiondef(to_regprocedure('xs_payroll_digest_row()')),'')||COALESCE(pg_get_functiondef(to_regprocedure('xs_payroll_digest_truncate()')),''));
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_digest_row() RETURNS trigger LANGUAGE plpgsql
+ SET TimeZone='Etc/GMT-8' SET DateStyle='ISO, MDY' SET IntervalStyle='postgres' SET extra_float_digits=1 SET bytea_output='hex' AS $$
+DECLARE k text:=TG_ARGV[0];
+BEGIN
+ IF TG_OP<>'INSERT' AND OLD.company IS NOT NULL AND (k<>'common' OR (to_jsonb(OLD)->>'modelId')::numeric IN (54,59,60)) THEN
+  INSERT INTO "PayrollReadChange" VALUES(OLD.company,pg_current_xact_id()) ON CONFLICT DO NOTHING;
+  INSERT INTO "PayrollReadChangeScope" VALUES(OLD.company,CASE WHEN k='common' THEN k||':'||((to_jsonb(OLD)->>'modelId')::numeric)::int::text ELSE k END,pg_current_xact_id()) ON CONFLICT DO NOTHING;
+ END IF;
+ IF TG_OP<>'DELETE' AND NEW.company IS NOT NULL AND (k<>'common' OR (to_jsonb(NEW)->>'modelId')::numeric IN (54,59,60)) THEN
+  INSERT INTO "PayrollReadChange" VALUES(NEW.company,pg_current_xact_id()) ON CONFLICT DO NOTHING;
+  INSERT INTO "PayrollReadChangeScope" VALUES(NEW.company,CASE WHEN k='common' THEN k||':'||((to_jsonb(NEW)->>'modelId')::numeric)::int::text ELSE k END,pg_current_xact_id()) ON CONFLICT DO NOTHING;
+ END IF;
+ IF TG_OP<>'INSERT' THEN DELETE FROM "PayrollReadDigest" WHERE kind=k AND company=OLD.company AND "objectId"=OLD."objectId"; END IF;
+ IF TG_OP<>'DELETE' AND NEW.company IS NOT NULL AND (k<>'common' OR (to_jsonb(NEW)->>'modelId')::numeric IN (54,59,60)) THEN
+  INSERT INTO "PayrollReadDigest"(kind,company,"objectId",model,hash) VALUES(k,NEW.company,NEW."objectId",CASE WHEN k='common' THEN (to_jsonb(NEW)->>'modelId')::numeric END,md5(NEW::text));
+ END IF;
+ RETURN NULL;
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_digest_truncate() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN INSERT INTO "PayrollReadChangeScope" SELECT DISTINCT company,CASE WHEN kind='common' THEN kind||':'||model::int::text ELSE kind END,pg_current_xact_id() FROM "PayrollReadDigest" WHERE kind=TG_ARGV[0] ON CONFLICT DO NOTHING;INSERT INTO "PayrollReadChange" SELECT DISTINCT company,pg_current_xact_id() FROM "PayrollReadDigest" WHERE kind=TG_ARGV[0] ON CONFLICT DO NOTHING;DELETE FROM "PayrollReadDigest" WHERE kind=TG_ARGV[0];RETURN NULL;END $$;
+
+-- Matching environment and unchanged, ALWAYS-enabled triggers are prerequisites.
+-- DDL (including disable/re-enable) changes catalog tuple versions and fails closed.
+CREATE OR REPLACE FUNCTION xs_payroll_digest_ready() RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT current_setting('TimeZone')='Etc/GMT-8' AND current_setting('DateStyle')='ISO, MDY'
+ AND current_setting('IntervalStyle')='postgres' AND current_setting('extra_float_digits')='1' AND current_setting('bytea_output')='hex'
+ AND NOT EXISTS(SELECT 1 FROM (VALUES ('common','CommonModel'),('appointment','CourseAppointment'),('lesson','LessonRecord'),('review','MemoryPracticeRecord')) v(kind,tab)
+ LEFT JOIN "PayrollReadDigestMeta" m ON m.kind=v.kind
+ WHERE m.signature IS DISTINCT FROM xs_payroll_digest_signature(v.tab)
+ OR (SELECT count(*) FROM pg_trigger t WHERE t.tgrelid=to_regclass(format('public.%I',v.tab)) AND t.tgname IN ('xs_payroll_digest_row','xs_payroll_digest_truncate') AND t.tgenabled='A')<>2);
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_data_version(co text) RETURNS text LANGUAGE plpgsql SET jit=off SET work_mem='64MB' AS $$
+DECLARE v text;
+BEGIN
+ IF xs_payroll_digest_ready() THEN
+  SELECT md5(COALESCE(string_agg(hash,'' ORDER BY kind COLLATE "C",hash COLLATE "C"),'')) INTO v FROM "PayrollReadDigest" WHERE company=co;
+ ELSE
+  SELECT md5(COALESCE(string_agg(hash,'' ORDER BY kind COLLATE "C",hash COLLATE "C"),'')) INTO v FROM (
+   SELECT 'common' kind,md5(c::text) hash FROM "CommonModel" c WHERE company=co AND "modelId" IN (54,59,60)
+   UNION ALL SELECT 'appointment',md5(c::text) FROM "CourseAppointment" c WHERE company=co
+   UNION ALL SELECT 'lesson',md5(c::text) FROM "LessonRecord" c WHERE company=co
+   UNION ALL SELECT 'review',md5(c::text) FROM "MemoryPracticeRecord" c WHERE company=co) s;
+ END IF;
+ RETURN v;
+END $$;
+
+-- Installation locks source writers until triggers and the initial digest agree.
+DO $$ DECLARE r record;BEGIN
+ FOR r IN SELECT * FROM (VALUES ('common','CommonModel'),('appointment','CourseAppointment'),('lesson','LessonRecord'),('review','MemoryPracticeRecord')) v(kind,tab) LOOP
+  EXECUTE format('LOCK TABLE %I IN SHARE ROW EXCLUSIVE MODE',r.tab);
+  EXECUTE format('DROP TRIGGER IF EXISTS xs_payroll_digest_row ON %I',r.tab);
+  EXECUTE format('DROP TRIGGER IF EXISTS xs_payroll_digest_truncate ON %I',r.tab);
+  EXECUTE format('CREATE TRIGGER xs_payroll_digest_row AFTER INSERT OR UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION xs_payroll_digest_row(%L)',r.tab,r.kind);
+  EXECUTE format('CREATE TRIGGER xs_payroll_digest_truncate AFTER TRUNCATE ON %I FOR EACH STATEMENT EXECUTE FUNCTION xs_payroll_digest_truncate(%L)',r.tab,r.kind);
+  EXECUTE format('ALTER TABLE %I ENABLE ALWAYS TRIGGER xs_payroll_digest_row',r.tab);
+  EXECUTE format('ALTER TABLE %I ENABLE ALWAYS TRIGGER xs_payroll_digest_truncate',r.tab);
+  DELETE FROM "PayrollReadDigest" WHERE kind=r.kind;
+  -- Cast the actual source row, exactly as in the previous cutoff hash.
+  EXECUTE format('INSERT INTO "PayrollReadDigest"(kind,company,"objectId",model,hash) SELECT %L,c.company,c."objectId",%s,md5(c::text) FROM %I c WHERE company IS NOT NULL %s',r.kind,CASE WHEN r.kind='common' THEN 'c."modelId"' ELSE 'NULL::numeric' END,r.tab,CASE WHEN r.kind='common' THEN 'AND c."modelId" IN (54,59,60)' ELSE '' END);
+  INSERT INTO "PayrollReadDigestMeta" VALUES(r.kind,xs_payroll_digest_signature(r.tab)) ON CONFLICT(kind) DO UPDATE SET signature=EXCLUDED.signature;
+ END LOOP;
+END $$;

+ 29 - 0
scripts/sql/payroll-read-rollback.sql

@@ -0,0 +1,29 @@
+-- Gateway must be restored first. These are derived objects only.
+-- Execute atomically after verifying helper definitions have not drifted.
+DROP TRIGGER xs_payroll_digest_row ON "CommonModel";
+DROP TRIGGER xs_payroll_digest_truncate ON "CommonModel";
+DROP TRIGGER xs_payroll_digest_row ON "CourseAppointment";
+DROP TRIGGER xs_payroll_digest_truncate ON "CourseAppointment";
+DROP TRIGGER xs_payroll_digest_row ON "LessonRecord";
+DROP TRIGGER xs_payroll_digest_truncate ON "LessonRecord";
+DROP TRIGGER xs_payroll_digest_row ON "MemoryPracticeRecord";
+DROP TRIGGER xs_payroll_digest_truncate ON "MemoryPracticeRecord";
+DROP FUNCTION xs_payroll_preview_cache_write(text,text,text,jsonb);
+DROP FUNCTION xs_payroll_preview_cache_read(text,text);
+DROP FUNCTION xs_payroll_snapshot_current(text,text);
+DROP FUNCTION xs_payroll_preview_stamp(text);
+DROP FUNCTION xs_payroll_cutoff_snapshot_fast(text,text);
+DROP FUNCTION xs_payroll_source_version_fast(text);
+DROP FUNCTION xs_payroll_source_version_stamp(text);
+DROP FUNCTION xs_payroll_source_version_uncached_fast(text);
+DROP FUNCTION xs_payroll_sources_cached(text);
+DROP FUNCTION xs_payroll_source_stamp(text);
+DROP FUNCTION xs_payroll_sources_current(text,text);
+DROP FUNCTION xs_payroll_sources_uncached(text);
+DROP FUNCTION xs_payroll_data_version(text);
+DROP FUNCTION xs_payroll_digest_ready();
+DROP FUNCTION xs_payroll_digest_row();
+DROP FUNCTION xs_payroll_digest_truncate();
+DROP FUNCTION xs_payroll_digest_signature(text);
+DROP FUNCTION xs_payroll_authority_ready();
+DROP TABLE "PayrollVerifiedReadCache","PayrollReadChangeScope","PayrollReadChange","PayrollReadDigest","PayrollReadDigestMeta","PayrollReadAuthority";

+ 94 - 0
scripts/sql/payroll-source-fast.sql

@@ -0,0 +1,94 @@
+-- Original source projection retained verbatim as the authoritative fallback.
+CREATE OR REPLACE FUNCTION public.xs_payroll_sources_uncached(co text)
+ RETURNS SETOF jsonb
+ LANGUAGE sql
+ STABLE
+ SET jit TO 'off'
+AS $function$
+ WITH canonical AS (
+ SELECT (to_jsonb(l)-'kzsj')||jsonb_build_object(
+  'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
+  'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
+  'source','canonical','commonStatus',COALESCE(c."status",99),
+  'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
+  'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
+  'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
+  'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
+  'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
+  'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
+  'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
+ ) AS row_data
+ FROM "LessonRecord" l
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c."company"=co AND c."modelId"=59 AND c."itemId"=l."id" ORDER BY c."objectId" LIMIT 1) c ON true
+ LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=l."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true
+ WHERE l."company"=co
+ ), fallback AS (
+ SELECT (to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
+  'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END) AS row_data
+ FROM "LessonRecordFact" f LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=f."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true WHERE f."company"=co AND NOT EXISTS(SELECT 1 FROM canonical c WHERE c.row_data->>'legacyGeneralId'=f."legacyGeneralId"::text AND COALESCE(f."legacyGeneralId",0)>0)
+ ) SELECT row_data FROM canonical UNION ALL SELECT row_data FROM fallback
+$function$;
+
+CREATE OR REPLACE FUNCTION xs_payroll_sources_current(co text,saved text) RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT saved IS NOT NULL AND NOT EXISTS(SELECT 1 FROM "PayrollReadChangeScope" c WHERE c.company=co AND c.scope IN ('common:54','common:59','appointment','lesson') AND c.xid>=pg_snapshot_xmin(saved::pg_snapshot) AND NOT pg_visible_in_snapshot(c.xid,saved::pg_snapshot));
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_source_stamp(co text) RETURNS text LANGUAGE plpgsql SET jit=off AS $$
+DECLARE facts text; meta text; generation text;
+BEGIN
+ IF NOT xs_payroll_digest_ready() OR NOT xs_payroll_authority_ready() THEN RETURN NULL;END IF;
+ SELECT md5(string_agg(kind||':'||signature,'|' ORDER BY kind)) INTO generation FROM "PayrollReadDigestMeta";
+ SELECT md5(COALESCE(string_agg(xmin::text||':'||ctid::text,',' ORDER BY ctid),'')) INTO facts FROM "LessonRecordFact" WHERE company=co;
+ SELECT md5(COALESCE(string_agg(c.oid||':'||c.relfilenode||':'||a.attnum||':'||a.attname||':'||a.atttypid||':'||a.atttypmod,'|' ORDER BY c.oid,a.attnum),'')) INTO meta FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_attribute a ON a.attrelid=c.oid WHERE n.nspname='public' AND a.attnum>0 AND NOT a.attisdropped AND c.relname IN ('CommonModel','CourseAppointment','LessonRecord','LessonRecordFact','PayrollReadChangeScope');
+ RETURN md5(generation||'|'||facts||'|'||meta||'|'||pg_get_functiondef('xs_payroll_sources_uncached(text)'::regprocedure)||'|'||pg_get_functiondef('xs_payroll_time(text)'::regprocedure)||'|'||pg_get_functiondef('xs_payroll_sources_current(text,text)'::regprocedure)||'|'||pg_get_functiondef('xs_payroll_source_stamp(text)'::regprocedure)||'|'||current_setting('TimeZone')||'|'||current_setting('DateStyle')||'|'||current_setting('extra_float_digits'));
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_sources_cached(co text) RETURNS SETOF jsonb LANGUAGE plpgsql SET jit=off AS $$
+DECLARE signature text; rows_json jsonb; may_publish boolean; read_snapshot text:=pg_current_snapshot()::text;
+BEGIN
+ IF NOT xs_payroll_authority_ready() THEN RETURN QUERY SELECT s FROM xs_payroll_sources(co) s;RETURN;END IF;
+ signature:=xs_payroll_source_stamp(co);
+ SELECT payload->'rows' INTO rows_json FROM "PayrollVerifiedReadCache" WHERE company=co AND key='canonical-sources-v1' AND stamp=signature AND xs_payroll_sources_current(co,payload->>'snapshot');
+ IF rows_json IS NULL THEN
+  may_publish:=pg_try_advisory_xact_lock(hashtext('xs-payroll-source-cache:'||co));
+  SELECT COALESCE(jsonb_agg(s),'[]'::jsonb) INTO rows_json FROM xs_payroll_sources_uncached(co) s;
+  IF may_publish AND signature=xs_payroll_source_stamp(co) AND xs_payroll_sources_current(co,read_snapshot) THEN
+   INSERT INTO "PayrollVerifiedReadCache" VALUES(co,'canonical-sources-v1',signature,jsonb_build_object('snapshot',read_snapshot,'rows',rows_json),now()) ON CONFLICT(company,key) DO UPDATE SET stamp=EXCLUDED.stamp,payload=EXCLUDED.payload,"createdAt"=EXCLUDED."createdAt";
+  END IF;
+ END IF;
+ RETURN QUERY SELECT value FROM jsonb_array_elements(rows_json);
+END $$;
+CREATE OR REPLACE FUNCTION public.xs_payroll_source_version_uncached_fast(co text)
+ RETURNS text
+ LANGUAGE sql
+ VOLATILE
+ SET jit TO 'off'
+AS $function$
+ SELECT md5(COALESCE((SELECT string_agg(s::text,'|' ORDER BY s::text) FROM xs_payroll_sources_cached(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(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(r)::text,'|' ORDER BY r."objectId") FROM "ReviewRewardIncome" r WHERE r."company"=co AND r."status"='earned'),''))
+$function$;
+
+CREATE OR REPLACE FUNCTION xs_payroll_source_version_stamp(co text) RETURNS text LANGUAGE plpgsql SET jit=off AS $$
+DECLARE signature text:=xs_payroll_source_stamp(co); tab text; v text; meta text;
+BEGIN
+ IF signature IS NULL THEN RETURN NULL;END IF;
+ FOREACH tab IN ARRAY ARRAY['TeachingCompensationRule','CourseTeachingProfile','CoachPayRate','ReviewReward','ReviewRewardCorrection'] LOOP
+  EXECUTE format('SELECT md5(COALESCE(string_agg(xmin::text||'':''||ctid::text,'','' ORDER BY ctid),'''')) FROM %I WHERE company=$1',tab) INTO v USING co;
+  SELECT md5(COALESCE(string_agg(a.attnum||':'||a.attname||':'||a.atttypid||':'||a.atttypmod,'|' ORDER BY a.attnum),'')) INTO meta FROM pg_attribute a WHERE a.attrelid=to_regclass(format('public.%I',tab)) AND a.attnum>0 AND NOT a.attisdropped;
+  signature:=signature||'|'||tab||':'||v||':'||meta;
+ END LOOP;
+ RETURN md5(signature||'|'||pg_get_viewdef('"ReviewRewardIncome"'::regclass,true)||'|'||pg_get_functiondef('xs_payroll_source_version_uncached_fast(text)'::regprocedure)||'|'||pg_get_functiondef('xs_payroll_source_version_stamp(text)'::regprocedure)||'|'||current_setting('IntervalStyle')||'|'||current_setting('bytea_output'));
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_source_version_fast(co text) RETURNS text LANGUAGE plpgsql SET jit=off AS $$
+DECLARE signature text; result text; may_publish boolean; read_snapshot text:=pg_current_snapshot()::text;
+BEGIN
+ IF NOT xs_payroll_authority_ready() THEN RETURN xs_payroll_source_version(co);END IF;
+ signature:=xs_payroll_source_version_stamp(co);
+ SELECT payload->>'version' INTO result FROM "PayrollVerifiedReadCache" WHERE company=co AND key='source-version-v2' AND stamp=signature AND xs_payroll_sources_current(co,payload->>'snapshot');
+ IF result IS NOT NULL THEN RETURN result;END IF;
+ may_publish:=pg_try_advisory_xact_lock(hashtext('xs-payroll-version-cache:'||co));
+ result:=xs_payroll_source_version_uncached_fast(co);
+ IF may_publish AND signature=xs_payroll_source_version_stamp(co) AND xs_payroll_sources_current(co,read_snapshot) THEN INSERT INTO "PayrollVerifiedReadCache" VALUES(co,'source-version-v2',signature,jsonb_build_object('snapshot',read_snapshot,'version',result),now()) ON CONFLICT(company,key) DO UPDATE SET stamp=EXCLUDED.stamp,payload=EXCLUDED.payload,"createdAt"=EXCLUDED."createdAt";END IF;
+ RETURN result;
+END $$;

+ 39 - 0
scripts/sql/payroll-verified-preview.sql

@@ -0,0 +1,39 @@
+-- Cache is accepted only after checking every dependency in the current DB snapshot.
+-- Financial commands never use this preview cache.
+CREATE OR REPLACE FUNCTION xs_payroll_preview_stamp(co text) RETURNS text LANGUAGE plpgsql SET jit=off SET work_mem='64MB' AS $$
+DECLARE tab text; parts text:='v2'; v text; meta text;
+BEGIN
+ IF NOT xs_payroll_digest_ready() OR NOT xs_payroll_authority_ready() THEN RETURN NULL;END IF;
+ SELECT md5(string_agg(kind||':'||signature,'|' ORDER BY kind)) INTO v FROM "PayrollReadDigestMeta";parts:=parts||'|'||v;
+ FOREACH tab IN ARRAY ARRAY['LessonRecordFact','ReviewReward','ReviewRewardCorrection','TeachingCompensationRule','CourseTeachingProfile','CoachPayRate','TeachingCourseCategory','PayrollLine','PayrollBatch','LegacyDirectRun','LegacyDirectEvidence','LegacySyncConflict','PayrollCutoffDecision','PayrollCutoffApproval','_User','Node','CourseBinding'] LOOP
+  -- xmin + physical tuple address detect updates even when updatedAt was not touched.
+  -- Deletion, insertion, same-transaction rewrites, and rollback retain MVCC semantics.
+  EXECUTE format('SELECT md5(COALESCE(string_agg(xmin::text||'':''||ctid::text,'','' ORDER BY ctid),'''')) FROM %I WHERE company=$1',tab) INTO v USING co;
+  parts:=parts||'|'||tab||':'||v;
+ END LOOP;
+ SELECT md5(COALESCE(string_agg(c.oid||':'||c.relfilenode||':'||a.attnum||':'||a.attname||':'||a.atttypid||':'||a.atttypmod,'|' ORDER BY c.oid,a.attnum),'')) INTO meta
+ FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_attribute a ON a.attrelid=c.oid
+ WHERE n.nspname='public' AND a.attnum>0 AND NOT a.attisdropped AND c.relname IN ('CommonModel','CourseAppointment','LessonRecord','MemoryPracticeRecord','LessonRecordFact','ReviewReward','ReviewRewardCorrection','TeachingCompensationRule','CourseTeachingProfile','CoachPayRate','TeachingCourseCategory','PayrollLine','PayrollBatch','LegacyDirectRun','LegacyDirectEvidence','LegacySyncConflict','PayrollCutoffDecision','PayrollCutoffApproval','_User','Node','CourseBinding','PayrollReadChange');
+ parts:=parts||'|'||meta||'|'||COALESCE(pg_get_viewdef(to_regclass('"ReviewRewardIncome"'),true),'');
+ SELECT md5(COALESCE(string_agg(pg_get_functiondef(p.oid),'|' ORDER BY p.oid),'')) INTO meta FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND p.proname LIKE 'xs_%' AND p.prokind='f';
+ parts:=parts||'|'||meta||'|'||COALESCE((SELECT md5(code) FROM "Function" WHERE path='xiaoshu/ops/gateway-v3'),'')||'|'||current_setting('TimeZone')||'|'||current_setting('DateStyle')||'|'||current_setting('extra_float_digits');
+ RETURN md5(parts);
+END $$;
+-- Top-level transaction IDs are checked against the build's original MVCC snapshot.
+-- This handles commits arriving out of order; max(id) and timestamps are insufficient.
+CREATE OR REPLACE FUNCTION xs_payroll_snapshot_current(co text, saved text) RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT saved IS NOT NULL AND NOT EXISTS(SELECT 1 FROM "PayrollReadChange" c WHERE c.company=co AND c.xid>=pg_snapshot_xmin(saved::pg_snapshot) AND NOT pg_visible_in_snapshot(c.xid,saved::pg_snapshot));
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_preview_cache_read(co text, cache_key text) RETURNS jsonb LANGUAGE plpgsql SET jit=off AS $$
+DECLARE signature text; result jsonb; read_snapshot text:=pg_current_snapshot()::text;
+BEGIN
+ signature:=xs_payroll_preview_stamp(co);
+ SELECT payload->'result' INTO result FROM "PayrollVerifiedReadCache" WHERE company=co AND key=cache_key AND stamp=signature AND "createdAt">now()-interval '1 day' AND xs_payroll_snapshot_current(co,payload->>'snapshot');
+ RETURN jsonb_build_object('stamp',signature,'snapshot',read_snapshot,'payload',result);
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_preview_cache_write(co text,cache_key text,expected text,result jsonb) RETURNS boolean LANGUAGE plpgsql SET jit=off AS $$
+BEGIN
+ IF expected IS NULL OR expected IS DISTINCT FROM xs_payroll_preview_stamp(co) OR NOT xs_payroll_snapshot_current(co,result->>'snapshot') THEN RETURN false;END IF;
+ INSERT INTO "PayrollVerifiedReadCache" VALUES(co,cache_key,expected,result,now()) ON CONFLICT(company,key) DO UPDATE SET stamp=EXCLUDED.stamp,payload=EXCLUDED.payload,"createdAt"=EXCLUDED."createdAt";
+ RETURN true;
+END $$;

+ 3 - 1
scripts/tests/launch-fixes.test.mjs

@@ -5,8 +5,10 @@ test('工资截止核验:区分文字差异与金额差异、版本改变失
  await db.exec(`CREATE TABLE "CommonModel"("objectId" text,company text,"generalId" numeric,"modelId" numeric,"itemId" numeric,title text,subtitle text);CREATE TABLE "CourseAppointment"(company text,id numeric,yysj text);CREATE TABLE "LessonRecord"(company text,id numeric,"lessonAt" text,"teacherPaySnapshot" numeric);CREATE TABLE "MemoryPracticeRecord"(company text,id numeric,wcsj text);CREATE TABLE "LegacySyncConflict"("objectId" text,company text,dataset text,"legacyKey" text,message text,resolved boolean);CREATE TABLE "ReviewReward"(id text);CREATE TABLE "TeachingCompensationRule"(id text);CREATE TABLE "CourseTeachingProfile"(id text);CREATE TABLE "CoachPayRate"(id text);CREATE TABLE "LessonRecordFact"(id text);CREATE FUNCTION xs_payroll_time(v text) RETURNS timestamptz LANGUAGE plpgsql AS $$BEGIN RETURN v::timestamptz;EXCEPTION WHEN others THEN RETURN NULL;END$$;CREATE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql AS $$SELECT md5(COALESCE(jsonb_agg(to_jsonb(l))::text,'')) FROM "LessonRecord" l WHERE company=co$$;`);
  await db.exec(await readFile(new URL('../sql/legacy-direct-sync.sql',import.meta.url),'utf8'));await db.exec(await readFile(new URL('../sql/payroll-cutoff.sql',import.meta.url),'utf8'));
  await db.exec(`INSERT INTO "LegacyDirectRun"(company,"businessDate","runId",owner,"leaseUntil",status,"finishedAt") VALUES('co','2026-10-13','run','owner',now(),'completed','2026-10-13T15:00:00Z');INSERT INTO "CourseAppointment" VALUES('co',54,'2026-10-02');INSERT INTO "LessonRecord" VALUES('co',59,'2026-10-02',20);INSERT INTO "MemoryPracticeRecord" VALUES('co',60,'2026-10-02');INSERT INTO "CommonModel" SELECT 'c'||i,'co',i,i,i,'学员','单词' FROM unnest(ARRAY[54,59,60]) i;INSERT INTO "LegacyDirectEvidence" SELECT 'co','run',i,to_jsonb(ARRAY[i]),1,md5(i::text),md5(i::text),0,'content' FROM unnest(ARRAY[54,59,60]) i;INSERT INTO "LegacySyncConflict" VALUES('cosmetic','co','54','54','{"fields":["title"]}',false),('pay','co','59','59','{"fields":["teacherPaySnapshot"]}',false);`);
+ await db.exec(`SET TimeZone='Etc/GMT-8';SET extra_float_digits=1;ALTER TABLE "CourseAppointment" ADD COLUMN "objectId" text DEFAULT 'a';ALTER TABLE "LessonRecord" ADD COLUMN "objectId" text DEFAULT 'l';ALTER TABLE "MemoryPracticeRecord" ADD COLUMN "objectId" text DEFAULT 'r';CREATE FUNCTION xs_payroll_source_version_fast(co text) RETURNS text LANGUAGE sql AS $$ SELECT xs_payroll_source_version(co) $$;`);
+ await db.exec(await readFile(new URL('../sql/payroll-incremental-reads.sql',import.meta.url),'utf8'));await db.exec(await readFile(new URL('../sql/payroll-cutoff-fast.sql',import.meta.url),'utf8'));
  const one=async(sql,args=[])=>(await db.query(sql,args)).rows[0].r;
- const snapshot=()=>one(`SELECT xs_payroll_cutoff_snapshot('co','2026-10') r`);
+ const snapshot=async()=>{const original=await one(`SELECT xs_payroll_cutoff_snapshot('co','2026-10') r`);assert.deepEqual(await one(`SELECT xs_payroll_cutoff_snapshot_fast('co','2026-10') r`),original);return original;};
  let s=await snapshot();assert.equal(s.pending,1);assert.equal(s.complete,true);assert.equal(s.approved,false);
  const confirm=(v,cid='',fp='')=>one(`SELECT xs_payroll_cutoff_confirm('co','manager','2026-10',$1,'人工核对记录',$2,$3) r`,[v,cid,fp]);
  await assert.rejects(confirm(s.version),/仍有差异/);const diff=s.conflicts.find(x=>x.id==='pay');s=await confirm(s.version,diff.id,diff.fingerprint);assert.equal(s.pending,0);s=await confirm(s.version);assert.equal(s.approved,true);await db.query(`SELECT xs_payroll_assert_cutoff('co','2026-10','create',$1)`,[s.version]);await assert.rejects(db.query(`SELECT xs_payroll_assert_cutoff('co','2026-10','review',$1)`,[s.version]),/整月/);

+ 41 - 0
scripts/tests/payroll-incremental-reads.test.mjs

@@ -0,0 +1,41 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {PGlite} from '@electric-sql/pglite';
+const sql=fs.readFileSync(new URL('../sql/payroll-incremental-reads.sql',import.meta.url),'utf8');
+test('incremental cutoff hashes equal full rows after writes, rollback, moves, tombstones, truncate and schema changes',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`SET TimeZone='Etc/GMT-8';SET DateStyle='ISO, MDY';SET extra_float_digits=1;CREATE TABLE "CommonModel"("objectId" text PRIMARY KEY,company text,"modelId" numeric,"updatedAt" timestamptz,details jsonb);`);
+ for(const t of ['CourseAppointment','LessonRecord','MemoryPracticeRecord'])await db.exec(`CREATE TABLE "${t}"("objectId" text PRIMARY KEY,company text,"updatedAt" timestamptz,amount numeric,details jsonb)`);
+ await db.exec(`INSERT INTO "CommonModel" VALUES('a','a',54,'2026-11-10T12:00:00+08','{"nested":[1,2]}'),('b','a',99,now(),'{}');INSERT INTO "LessonRecord" VALUES('l','a',now(),1.25,'{"large":"original"}');`);
+ await db.exec(sql);
+ const one=async(q,args=[])=>(await db.query(q,args)).rows[0];
+ const raw=`SELECT md5(COALESCE(string_agg(hash,'' ORDER BY kind COLLATE "C",hash COLLATE "C"),'')) AS hash FROM (SELECT 'common' kind,md5(c::text) hash FROM "CommonModel" c WHERE company=$1 AND "modelId" IN (54,59,60) UNION ALL SELECT 'appointment',md5(c::text) FROM "CourseAppointment" c WHERE company=$1 UNION ALL SELECT 'lesson',md5(c::text) FROM "LessonRecord" c WHERE company=$1 UNION ALL SELECT 'review',md5(c::text) FROM "MemoryPracticeRecord" c WHERE company=$1) s`;
+ async function equal(){for(const co of ['a','b','empty'])assert.equal((await one('SELECT xs_payroll_data_version($1) AS hash',[co])).hash,(await one(raw,[co])).hash);}
+ assert.equal((await one('SELECT xs_payroll_digest_ready() ready')).ready,true);await equal();
+ for(const change of [`UPDATE "LessonRecord" SET amount=2.5,details='{"large":"changed"}'`,`UPDATE "LessonRecord" SET company='b',"objectId"='moved'`,`UPDATE "CommonModel" SET "modelId"=59 WHERE "objectId"='b'`,`UPDATE "CommonModel" SET "modelId"=99 WHERE "objectId"='a'`,`DELETE FROM "LessonRecord"`,`INSERT INTO "MemoryPracticeRecord" VALUES('r','a',now(),0,'{"deleted":true}')`,`TRUNCATE "MemoryPracticeRecord"`]){await db.exec(change);await equal();}
+ await db.exec(`BEGIN;UPDATE "CommonModel" SET details='{"inside":true}';`);await equal();await db.exec('ROLLBACK');await equal();
+ await db.exec(`ALTER TABLE "CommonModel" DISABLE TRIGGER xs_payroll_digest_row;UPDATE "CommonModel" SET details='{"missed":true}';ALTER TABLE "CommonModel" ENABLE ALWAYS TRIGGER xs_payroll_digest_row;`);assert.equal((await one('SELECT xs_payroll_digest_ready() ready')).ready,false);await equal();
+ await db.exec(sql);assert.equal((await one('SELECT xs_payroll_digest_ready() ready')).ready,true);await equal();
+ await db.exec('ALTER TABLE "LessonRecord" ADD COLUMN extra text DEFAULT \'new column\'');assert.equal((await one('SELECT xs_payroll_digest_ready() ready')).ready,false);await equal();
+ await db.exec("SET TimeZone='UTC'");await equal();
+ }finally{await db.close();}
+});
+test('verified preview cache invalidates every dependency, code/schema changes and failed publish races',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`SET TimeZone='Etc/GMT-8';SET DateStyle='ISO, MDY';SET extra_float_digits=1;`);
+ const tables=['CommonModel','CourseAppointment','LessonRecord','MemoryPracticeRecord','LessonRecordFact','ReviewReward','ReviewRewardCorrection','TeachingCompensationRule','CourseTeachingProfile','CoachPayRate','TeachingCourseCategory','PayrollLine','PayrollBatch','LegacyDirectRun','LegacyDirectEvidence','LegacySyncConflict','PayrollCutoffDecision','PayrollCutoffApproval','_User','Node','CourseBinding'];
+ for(const tab of tables){await db.exec(`CREATE TABLE "${tab}"("objectId" text PRIMARY KEY,company text,value text,"modelId" numeric DEFAULT 54);INSERT INTO "${tab}"("objectId",company,value) VALUES('a','a','old'),('b','b','other')`);}
+ await db.exec(`CREATE TABLE "Function"(path text,code text);INSERT INTO "Function" VALUES('xiaoshu/ops/gateway-v3','original code');CREATE VIEW "ReviewRewardIncome" AS SELECT * FROM "ReviewReward";`);
+ await db.exec(sql);await db.exec(fs.readFileSync(new URL('../sql/payroll-verified-preview.sql',import.meta.url),'utf8'));
+ const one=async(q,args=[])=>(await db.query(q,args)).rows[0];
+ const read=async(co='a',key='month:11')=>(await one('SELECT xs_payroll_preview_cache_read($1,$2) r',[co,key])).r;
+ let buildSnapshot;
+ const write=async(stamp)=>(await one('SELECT xs_payroll_preview_cache_write($1,$2,$3,$4::jsonb) saved',['a','month:11',stamp,JSON.stringify({snapshot:buildSnapshot,result:{amount:99,settlementReady:true}})])).saved;
+ for(const tab of tables){const before=await read();buildSnapshot=before.snapshot;assert.equal(await write(before.stamp),true);assert.equal((await read()).payload.amount,99);await db.exec(`UPDATE "${tab}" SET value=value||'changed' WHERE company='a'`);assert.equal((await read()).payload,null,tab);assert.equal(await write(before.stamp),false,tab+' race');}
+ let before=await read();buildSnapshot=before.snapshot;await write(before.stamp);assert.equal((await read('b')).payload,null);assert.equal((await read('a','month:12')).payload,null);
+ await db.exec(`UPDATE "LessonRecord" SET value='other tenant' WHERE company='b'`);assert.equal((await read()).payload.amount,99);
+ await db.exec(`BEGIN;UPDATE "ReviewReward" SET value='uncommitted' WHERE company='a'`);assert.equal((await read()).payload,null);await db.exec('ROLLBACK');assert.equal((await read()).payload.amount,99);
+ const aba=await read();await db.exec(`UPDATE "LessonRecord" SET value='temporary' WHERE company='a';UPDATE "LessonRecord" SET value='oldchanged' WHERE company='a';`);assert.equal((await read()).payload,null);assert.equal(await write(aba.stamp),false);
+ await db.exec(`UPDATE "Function" SET code='new deployment'`);assert.equal((await read()).payload,null);before=await read();buildSnapshot=before.snapshot;await write(before.stamp);
+ before=await read();buildSnapshot=before.snapshot;await write(before.stamp);await db.exec(`ALTER TABLE "LessonRecord" DISABLE TRIGGER xs_payroll_digest_row;UPDATE "LessonRecord" SET value='missed during outage' WHERE company='a';ALTER TABLE "LessonRecord" ENABLE ALWAYS TRIGGER xs_payroll_digest_row;`);assert.equal((await read()).payload,null);await db.exec(sql);assert.equal((await read()).payload,null,'rebuild must invalidate pre-outage caches');
+ await db.exec(`ALTER TABLE "ReviewReward" ADD COLUMN new_column text DEFAULT 'new';`);assert.equal((await read()).payload,null);
+ }finally{await db.close();}
+});

+ 17 - 0
scripts/tests/payroll-integrity.test.mjs

@@ -133,3 +133,20 @@ test('移动端真实数据库查询与后台同源:课酬合计、历史规
  const lines=await mobile.teacherLessonEarnings('co',30);const [rates,rules]=await Promise.all([context.payRateRows(manager,{}),context.teachingRuleRows(manager,{})]);
  for(const line of lines.lines){const expected=context.payrollCalculatedLine(line,rates,'',rules,new Map());assert.equal(expected.amount,line.amount);assert.equal(expected.exception,line.exception);}
 });
+test('incremental sources and versions remain identical after corrections, deletion, transaction rollback and schema changes',async()=>{
+ await db.exec("SET TimeZone='Etc/GMT-8';SET extra_float_digits=1;");
+ await db.exec(readFileSync(new URL('../sql/payroll-incremental-reads.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('../sql/payroll-source-fast.sql',import.meta.url),'utf8'));
+ async function same(){for(const co of ['co','other']){
+  const original=await q('SELECT s FROM xs_payroll_sources($1) s ORDER BY s::text',[co]);
+  assert.deepEqual(await q('SELECT s FROM xs_payroll_sources_cached($1) s ORDER BY s::text',[co]),original);
+  assert.equal((await one('SELECT xs_payroll_source_version($1) v',[co])).v,(await one('SELECT xs_payroll_source_version_fast($1) v',[co])).v);
+ }}
+ await same();await same();
+ await db.exec(`BEGIN;UPDATE "LessonRecord" SET "teacherPaySnapshot"=0;`);await same();await db.exec('ROLLBACK');await same();
+ await db.exec(`BEGIN;UPDATE "CourseAppointment" SET "courseCategoryKey"='trial';UPDATE "CommonModel" SET status=-2 WHERE "modelId"=59;`);await same();await db.exec('ROLLBACK');
+ await db.exec(`BEGIN;DELETE FROM "LessonRecord";`);await same();await db.exec('ROLLBACK');await same();
+ await db.exec(`ALTER TABLE "LessonRecord" DISABLE TRIGGER xs_payroll_digest_row;UPDATE "LessonRecord" SET "teacherPaySnapshot"=77;ALTER TABLE "LessonRecord" ENABLE ALWAYS TRIGGER xs_payroll_digest_row;`);await same();await db.exec(readFileSync(new URL('../sql/payroll-incremental-reads.sql',import.meta.url),'utf8'));await same();
+ await db.exec(`ALTER TABLE "LessonRecordFact" ADD COLUMN test_extension text DEFAULT 'changed schema';`);await same();
+ await db.exec(`CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGUAGE sql AS $$ SELECT jsonb_build_object('newAuthority',co) $$;CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql AS $$ SELECT 'new-policy:'||co $$;`);assert.equal((await one('SELECT xs_payroll_authority_ready() ready')).ready,false);await same();await db.exec(readFileSync(new URL('../sql/payroll-incremental-reads.sql',import.meta.url),'utf8'));assert.equal((await one('SELECT xs_payroll_authority_ready() ready')).ready,false);await same();
+});

+ 21 - 0
scripts/tests/payroll-preview-cache.test.mjs

@@ -0,0 +1,21 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';
+const source=fs.readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8');
+function setup(){const calls=[],c=vm.createContext({Date,Psql:{one:async(sql,args)=>{calls.push({sql,args});return sql.includes('_read')?{result:{stamp:'current',payload:null}}:{saved:true};}}});vm.runInContext(source,c);c.text=v=>String(v||'');c.number=v=>Number(v)||0;c.payrollTenant=()=> 'co';let count=0;c.payrollPreviewUncached=async()=>{count++;return{payableAmount:123,sourceStatus:{sync:{state:'batch_verified',settlementReady:false}}};};return{c,calls,count:()=>count};}
+test('commands and require-live callers cannot receive a cached approval',async()=>{const {c,calls,count}=setup();for(const [used,live,targetId]of[[false,false,''],[true,true,''],[true,false,'batch']])await c.payrollPreviewData({}, {targetId},'2026-11',used,live);assert.equal(count(),3);assert.equal(calls.length,0);});
+test('cache miss computes authoritative output and publishes with original dependency stamp',async()=>{const {c,calls,count}=setup();const r=await c.payrollPreviewData({storeId:12},{storeId:99,search:'a'},'2026-11',true);assert.equal(r.payableAmount,123);assert.equal(count(),1);assert.equal(calls.length,2);assert.equal(calls[1].args[2],'current');assert.match(calls[0].args[1],/12,99,"a",false,false/);});
+test('cached hit preserves amounts and denied settlement; roles are checked before reading cache',async()=>{const {c,count}=setup();c.Psql.one=async()=>({result:{stamp:'current',payload:{payableAmount:123,sourceStatus:{sync:{state:'batch_verified',settlementReady:false}}}}});const r=await c.payrollPreviewData({}, {},'2026-11',true);assert.equal(r.payableAmount,123);assert.equal(r.sourceStatus.sync.settlementReady,false);assert.equal(r.sourceStatus.cacheHit,true);assert.equal(count(),0);c.payrollTenant=()=>{throw Error('denied');};await assert.rejects(c.payrollPreviewData({}, {},'2026-11',true),/denied/);});
+test('cache error falls back; failed verification is never cached',async()=>{const {c,calls,count}=setup();c.Psql.one=async()=>{throw Error('database missing helper');};assert.equal((await c.payrollPreviewData({}, {},'2026-11',true)).payableAmount,123);assert.equal(count(),1);const other=setup();other.c.payrollPreviewUncached=async()=>({sourceStatus:{sync:{state:'error'}}});await other.c.payrollPreviewData({}, {},'2026-11',true);assert.equal(other.calls.length,1);});
+
+test('summary transport preserves every verified total; coach details preserve scope and exceptions',async()=>{
+ const {c}=setup(),lines=[{coachId:1,storeId:10,amount:20},{coachId:2,storeId:20,amount:30,exception:'needs review'}];
+ c.businessDate=()=> '2026-11-10';c.payrollPreviewData=async()=>({lines,sourceStatus:{sync:{state:'confirmed',settlementReady:true}}});c.payrollPage=async()=>({items:[]});c.storeNameOf=()=> 'store';c.Psql.query=async()=>[];
+ c.payrollResult=(month,rows,status,input)=>{const visible=rows.filter(l=>!input.storeId||l.storeId===input.storeId);return{month,lines:visible,exceptions:visible.filter(l=>l.exception),coachItems:visible.map(l=>({coachId:l.coachId,amount:l.amount})),payableAmount:visible.reduce((a,l)=>a+l.amount,0),exceptionCount:visible.filter(l=>l.exception).length,sourceStatus:status};};
+ const full=await c.payrollOverviewData({},{}),summary=await c.payrollOverviewData({},{summaryOnly:true}),detail=await c.payrollOverviewData({},{lineCoachId:2});
+ const withoutRows=r=>JSON.parse(JSON.stringify({...r,lines:[],exceptions:[]}));assert.deepEqual(withoutRows(full),withoutRows(summary));assert.deepEqual(withoutRows(full),withoutRows(detail));assert.equal(summary.lines.length,0);assert.equal(detail.lines.length,1);assert.equal(detail.exceptions.length,1);assert.equal(detail.lines[0].coachId,2);
+ const scoped=await c.payrollOverviewData({storeId:10,store:{toJSON:()=>({})}},{storeId:20,lineCoachId:2});assert.equal(scoped.lines.length,0);assert.equal(scoped.payableAmount,20);
+});
+
+test('export rejects presentation-only projections even when caller forwards overview flags',async()=>{
+ const {c}=setup();let seen;c.payrollOverviewData=async(_context,input)=>{seen=input;return{month:'2026-11',lines:[],sourceStatus:{}};};c.payrollVisibleLines=rows=>rows;c.payrollResult=()=>({coachItems:[]});
+ await c.payrollExportData({},{summaryOnly:true,lineCoachId:9,coachId:2});assert.equal(seen.summaryOnly,false);assert.equal(seen.lineCoachId,0);assert.equal(seen.coachId,2);
+});

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików