Jelajahi Sumber

fix(payroll): reconcile writing pay and legacy review rewards

彭峰 1 Minggu lalu
induk
melakukan
5dfc683f63

+ 55 - 0
docs/migration/payroll-repair-2026-10-12.md

@@ -0,0 +1,55 @@
+# 2026-10-12 工资及业务列表修复验收
+
+本次针对 2026 年 10 月工资预览中的 47 条课酬异常、抗遗忘工资为零及业务列表数据不完整进行核查和修复。生产数据按原始业务编号、记录版本及完成凭据核对;不覆盖已保存工资快照,不改写课时余额,不生成工资批次或发放记录。
+
+## 原因及处理
+
+1. **47 条课酬异常**:均属于通用“阅读写作”课程,缺少可用于计价的学段分类,涉及 18 名学员。课程名称本身不能区分小学、初中、高中。根据同学年已有人工确认记录、旧系统明确资料及用户补充的 6 人实际学段,逐预约保存分类和核对依据。没有为通用课程设置统一学段映射。用户补充的 6 人学段也写入新系统档案,保存前核验为空或与确认一致。
+2. **已完课记录无法在学段核对入口处理**:原列表只返回未开始预约。现同时返回所选工资月份中已完课的待核对预约,以工资规范来源的上课月份为准;确认后刷新工资预览。
+3. **历史有效复习为零**:导入凭据默认是零元“历史待核对”,不能直接推定旧系统尚未发放。用户已明确确认 10 月未发放;核验 1,267 条凭据后执行每条 1 元的计薪确认。其中 1,266 条与旧系统完成记录逐字段一致,另 1 条有新系统首次完成时间及原始凭据创建时间证据。确认事务保留来源、老师、学生、日期及防重复校验,并记录审计。
+4. **新系统完成旧待办复习仍被识别为历史**:触发器只看旧来源标记,导致导入的待完成任务在新系统完成后仍生成零元凭据。现由经过身份校验的完成函数在事务内标记具体帐套和任务,使用服务器完成时间并计 1 元;同步写入历史完成状态仍保持零元。重复完成保留首次凭据和金额,事务上下文在成功或异常时恢复。
+5. **已同步却显示“尚无成功记录”**:展示原来只读取同步桥检查点。现读取真实旧接口手动同步审计收据,展示该批次完成时间及保留差异。该收据不会伪造桥检查点,也不会放开结算。
+6. **业务列表停在 5,000 条**:预约、上课记录和每日学习列表先截取前 5,000 条,再进行搜索、分页及统计。现对绑定、预约、上课和每日学习列表统一在数据库内搜索、计数和分页,同一语句快照返回总数与当前页;保留帐套、学员过滤和稳定排序。上课记录同时补齐学生姓名,并从预约读取已确认课程分类。
+
+## 生产回读结果
+
+| 10 月收入预览 | 修复前 | 修复后 |
+| --- | ---: | ---: |
+| 有效课次 | 154 | 201 |
+| 工时 | 144.5 | 191.5 |
+| 课酬 | 4,820 元 | 7,940 元 |
+| 有效复习 | 0 | 1,267 |
+| 抗遗忘工资 | 0 元 | 1,267 元 |
+| 当月收入 | 4,820 元 | 9,207 元 |
+| 课酬异常 | 47 | 0 |
+
+V1 与 V3 运营网关均已发布,线上代码与本地完整网关代码一致,保留已上线的教学流程模块。两条真实网关均回读上述金额。
+
+业务列表实际返回:课程绑定 1,537 条、预约 31,586 条、上课记录 19,409 条、每日学习 19,640 条。线上分别核验了第 251 页、较早业务编号搜索、按学员过滤,以及超出末页时仍保留完整总数。课程绑定核验第 2 页。12 个相关业务查询均成功返回。
+
+## 验证与证据
+
+- Node 测试覆盖工资事务、复习奖励、导入任务在新系统完成、重复确认、历史同步零元、完整来源分页、搜索转义及帐套隔离。
+- 管理端 39 项相关测试通过,管理端构建成功,11 个云函数定义校验通过。
+- 生产事务回滚测试:旧待办首次在新系统完成计 1 元,重复完成仍为 1 元;直接同步旧完成记录为历史零元;事务上下文恢复,测试记录回读为零。
+- 1,267 条历史确认完成;再次确认其中 100 条返回新增 0、已确认 100,没有新增金额。
+- 数据库修复仅安装 5 个受影响函数;安装前后的线上定义备份保存在私有目录。临时执行函数均删除并回读确认,无新建测试账号。
+- 私有明细、原始证据、版本指纹和回读报告位于本机 `/Users/mac/.local/state/xiaoshu/payroll-repair/2026-10-12/`,不提交用户资料、会话或密钥。
+- 本地管理端运行于 `http://127.0.0.1:4300/`;浏览器访问进入正常登录页。本轮没有管理员浏览器会话,因此已登录工资界面的视觉验收不作已完成声明;生产金额通过真实管理员 API 验证。
+
+## 仍需核对的业务事实
+
+抗遗忘工资页另保留 44 条真实来源异常:43 条涉及重复计划任务,1 条缺少有效来源学习记录或计划日期。43 条按学员、学习记录和计划日期归为 23 组;旧系统完成记录中同组存在重复来源,不能直接按每个重复行补发。这些异常凭据保留零元,未删除、合并或转成有效工资。它们与原先 47 条课酬分类异常分别统计。
+
+手动同步收据的真实完成时间为北京时间 2026-10-12 16:39:11。保留的新旧差异、工资截止范围及每天 23 点自动同步尚未完成验收,所以 `settlementReady` 仍为 `false`。本次修复完成计薪预览和凭据确认,不代表已经完成工资结算、支付或夜间任务上线。
+
+## 窄范围数据库发布入口
+
+已有有效超级管理员会话时,从受限环境加载凭据并指定私有备份目录后运行:
+
+```sh
+node scripts/migrate-payroll-repair.mjs          # 备份并核查现有定义
+node scripts/migrate-payroll-repair.mjs --apply  # 事务安装受影响函数
+```
+
+入口要求 `XIAOSHU_MASTER_KEY` 和 `XIAOSHU_PAYROLL_REPORT_DIR`,不得把主密钥放进命令行、仓库或报告。它不会执行全量奖励回填、改写历史工资或创建用户。

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

@@ -858,7 +858,7 @@ export interface PayrollSourceStatus extends Record<string, unknown> {
   total: number;
   refreshedAt: string;
   sync?: {
-    state: 'not_configured' | 'synced' | 'delayed' | 'inconsistent' | 'error';
+    state: 'not_configured' | 'synced' | 'delayed' | 'inconsistent' | 'error' | 'batch_verified';
     settlementReady: boolean; message: string; checkedAt: string;
     lastSuccessAt: string; lagSeconds: number | null;
     datasets: Array<{ key: string; state: string; lastSuccessAt: string; lagSeconds: number | null; pendingFailures: number; conflicts: number; legacyCount: number | null; localCount: number | null; complete?: boolean }>;

File diff ditekan karena terlalu besar
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html


+ 3 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts

@@ -91,7 +91,9 @@ describe('payroll interactions', () => {
   service.mutate.and.resolveTo({updated:true});
   await component.saveWritingMapping(row);
   expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z');
-  expect(component.message()).toBe('该预约的学段分类已确认');
+  expect(service.request).toHaveBeenCalledWith('ops/course-mappings/students',{month:component.month()});
+  expect(service.payrollOverview).toHaveBeenCalled();
+  expect(component.message()).toBe('该预约的学段分类已确认,工资统计已刷新');
  });
  it('ignores an older batch page response',async()=>{
   let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));

File diff ditekan karena terlalu besar
+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


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

@@ -16,7 +16,7 @@ const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
 async function payrollSettlementSyncStatus(context,input){
  const checkedAt=new Date().toISOString(),datasets=PAYROLL_SYNC_DATASETS.map(key=>({key,state:'never_synced',lastSuccessAt:'',lagSeconds:null,pendingFailures:0,conflicts:0,legacyCount:null,localCount:null})),config=syncConfig();
  const result={state:'not_configured',settlementReady:false,message:'旧系统持续同步尚未配置,工资统计可能缺少最新记录',checkedAt,lastSuccessAt:'',lagSeconds:null,datasets};
- if(!config.baseUrl||!config.keyId||!config.secret)return result;
+ if(!config.baseUrl||!config.keyId||!config.secret)return directPayrollSyncStatus(context,input,result);
  try{
   const co=payrollTenant(context,input),[checkpoints,issues,localCounts,manifest]=await Promise.all([
    Psql.query('SELECT "dataset","state","cursor","lastSuccessAt","lastWatermark" FROM "LegacySyncCheckpoint" WHERE "company"=$1 AND "dataset"=ANY($2::text[]) ORDER BY "updatedAt" DESC',[co,PAYROLL_SYNC_DATASETS]),
@@ -78,7 +78,9 @@ async function payrollOverviewData(context,input){
 }
 async function payrollExportData(context,input){const data=input.targetId?await payrollDetailData(context,input,input.targetId):await payrollOverviewData(context,input),filtered=payrollVisibleLines(data.lines,input).filter(l=>!number(input.coachId)||l.coachId===number(input.coachId)),included=filtered.filter(l=>!l.excluded),excluded=filtered.filter(l=>l.excluded),totals=payrollResult(data.month,included,data.sourceStatus||{refreshedAt:new Date().toISOString()},{});return{fileName:'小树陪练工资_'+data.month+'.xlsx',summary:totals,coachItems:totals.coachItems,lines:included.filter(l=>!l.exception),exceptions:included.filter(l=>l.exception),excluded};}
 async function confirmLegacyReviewRewards(context,input,payload){
- requireRole(context,['ops-manager']);const sync=await payrollSettlementSyncStatus(context,input);if(!sync.settlementReady)fail(503,'旧系统数据尚未追平,暂不能核对复习工资:'+sync.message);
+ // Confirmation validates the selected immutable receipts in SQL; bridge configuration
+ // is unrelated to whether these particular legacy tasks were completed and unpaid.
+ requireRole(context,['ops-manager']);
  const ids=Array.isArray(payload.rewardIds)?[...new Set(payload.rewardIds.map(text).filter(Boolean))]:[];if(!ids.length||ids.length>100)fail(400,'请选择 1 至 100 条待核对凭据');
  const result=await payrollSql('SELECT xs_review_reward_confirm($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(ids),reasonOf(input)]);return result.result;
 }
@@ -111,7 +113,7 @@ async function historicalReservationPreview(context,input){const co=payrollTenan
 async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const co=payrollTenant(context,input),selected=new Set((payload.appointmentIds||[]).map(text));if(!selected.size)fail(400,'请选择预约');const before=await Psql.one('SELECT xs_payroll_source_version($1) AS version',[co]),preview=await historicalReservationPreview(context,input),items=preview.items.filter(i=>selected.has(i.appointmentId));if(!items.length)return{reserved:0,held:0};return(await payrollSql('SELECT xs_payroll_reservations($1,$2,$3::jsonb,$4,$5) AS result',[co,context.current.id,JSON.stringify(items),reasonOf(input),before.version])).result;}
 async function creditReconciliationPreview(context,input){const rows=await Psql.query('SELECT s AS row FROM xs_payroll_refund_rows($1) s',[payrollTenant(context,input)]),items=rows.map(r=>r.row);return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter(i=>i.adjustment>0).length,refundTotal:items.reduce((s,i)=>s+Number(i.adjustment),0)},refreshedAt:new Date().toISOString()};}
 async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);if(!payload.appointmentIds?.length)fail(400,'请选择要核对的预约');return(await payrollSql('SELECT xs_payroll_refunds($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(payload.appointmentIds.map(text)),reasonOf(input)])).result;}
-async function writingMappingPage(context,input){const co=payrollTenant(context,input),rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND a."dszt" IN ('0','10') AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co]);
+async function writingMappingPage(context,input){const co=payrollTenant(context,input),month=text(input.month);if(month&&!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');const rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND (a."dszt" IN ('0','10') OR ($2<>'' AND c."generalId"::text IN (SELECT source->>'yyds' FROM xs_payroll_sources($1) source WHERE to_char(xs_payroll_time(source->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM')=$2))) AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co,month]);
  return{items:rows.map(r=>{const stage=teachingStageFromDatabase([r.grade,r.nickname,r.username]);return{...r,suggestedCategoryKey:stage?stage+'_writing':'',evidence:r.evidence||r.grade||'',categoryKey:r.categoryKey||'',studentName:r.studentName||r.nickname};})};}
 async function writingMappingCommit(context,input,payload){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',[payrollTenant(context,input),context.current.id,input.targetId,text(payload.categoryKey),text(payload.evidence),dateIso(input.expectedUpdatedAt)||null,reasonOf(input)])).result;}
 async function payRateRows(context,input){
@@ -124,3 +126,22 @@ async function savePayRateUpsert(context,input,payload){
  if(![1,2,3,4].includes(type)||!Number.isFinite(amount)||amount<0||amount>1000000000||Math.abs(Math.round(amount*100)-amount*100)>.000001||!Number.isFinite(hours)||hours<=0||hours>24||payrollBusinessDate(effectiveFrom)!==effectiveFrom)fail(400,'课型、单价、工时或生效日期无效');
  return(await payrollSql('SELECT xs_payroll_legacy_rate($1,$2,$3::jsonb) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify({classType:type,amount,durationHours:hours,effectiveFrom,active:payload.active!==false,reason:reasonOf(input)})])).result;
 }
+
+// A verified manual API import is useful evidence even when no continuous bridge exists.
+// It never substitutes for a payroll cutoff snapshot or enables settlement.
+async function directSyncReceipt(context,input){
+ const co=companyIdOf(context,input);if(!co)return null;
+ const rows=await Psql.query('SELECT "detail" FROM "SysLog" WHERE "company"=$1 AND "type2"=\'legacy-direct-sync-completed\' ORDER BY "createdAt" DESC LIMIT 1',[co]);
+ if(!rows.length)return null;let event;try{event=typeof rows[0].detail==='string'?JSON.parse(rows[0].detail):rows[0].detail;}catch{return null;}
+ const receipt=event?.after;if(receipt?.source!=='old-api/WXAPP'||!['completed','completed-with-preserved-conflicts'].includes(receipt.status)||!Array.isArray(receipt.datasets)||!receipt.datasets.length||!receipt.datasets.every(r=>r.reconciled===true&&number(r.failed)===0)||!payrollDateIso(receipt.completedAt))return null;
+ return receipt;
+}
+async function directPayrollSyncStatus(context,input,fallback){
+ try{const receipt=await directSyncReceipt(context,input);if(!receipt)return fallback;const lastSuccessAt=payrollDateIso(receipt.completedAt),lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000));
+ return {...fallback,state:'batch_verified',lastSuccessAt,lagSeconds,settlementReady:false,message:'旧接口手动同步批次已完成并核对业务编号;保留差异仍待复核,结算前须核验工资截止范围。每日自动同步尚未验收。',datasets:fallback.datasets.map(r=>{const model={'appointments':54,'lessons':59,'memory-records':60}[r.key],d=receipt.datasets.find(d=>number(d.modelId)===model);return d?{...r,state:'batch_verified',lastSuccessAt,lagSeconds,legacyCount:number(d.source),localCount:number(d.source),conflicts:number(d.conflicts),pendingFailures:number(d.failed),complete:false}:r;})};
+ }catch{return fallback;}
+}
+async function operationalSyncIndicator(context,input){
+ const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret){const receipt=await directSyncReceipt(context,input).catch(()=>null);if(receipt){const lastSuccessAt=payrollDateIso(receipt.completedAt);return{state:'delayed',label:'旧接口批次已同步,保留差异待核对',lastSuccessAt,lagSeconds:Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000))};}return{state:'not_configured',label:'尚未启用持续同步',lastSuccessAt:'',lagSeconds:null};}
+ const rows=await newClassObjects('LegacySyncCheckpoint',context,input,1000),critical=rows.filter(row=>PAYROLL_SYNC_DATASETS.includes(text(row.get('dataset')))),lastSuccessAt=critical.map(row=>dateIso(row.get('lastSuccessAt'))).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=critical.length===3&&critical.every(row=>text(row.get('state'))==='healthy')&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'工资相关数据刚刚同步':'数据同步延迟,正在追平',lastSuccessAt,lagSeconds};
+}

File diff ditekan karena terlalu besar
+ 20 - 4
scripts/deploy-admin-functions.mjs


+ 20 - 0
scripts/migrate-payroll-repair.mjs

@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+// Install only the affected routines. No historical reward backfill or user creation.
+import fs from 'node:fs';import path from 'node:path';import {randomUUID} from 'node:crypto';
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',key=process.env.XIAOSHU_MASTER_KEY,co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
+if(!key)throw Error('缺少 XIAOSHU_MASTER_KEY');
+const out=process.env.XIAOSHU_PAYROLL_REPORT_DIR;if(!out)throw Error('必须指定私有备份目录 XIAOSHU_PAYROLL_REPORT_DIR');fs.mkdirSync(out,{recursive:true,mode:0o700});
+async function api(p,method='GET',body){const r=await fetch(base+p,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':key},...(body?{body:JSON.stringify(body)}:{})});const j=await r.json();if(!r.ok||j.error)throw Error(j.error||'HTTP '+r.status);return j;}
+const names=['xiaoshu_review_before_write','xiaoshu_review_after_write','xiaoshu_complete_review','xiaoshu_reward_immutable','xs_review_reward_confirm'];
+const sources=['review-rewards','payroll-integrity'].map(n=>fs.readFileSync(new URL('./sql/'+n+'.sql',import.meta.url),'utf8')).join('\n');
+const definitions=names.map(name=>{const re=new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?END \\$\\$;');const m=sources.match(re);if(!m)throw Error('找不到函数 '+name);return m[0];});
+const sessions=(await api('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results,session=sessions.find(s=>s.user?.adminRoleKey==='super-admin'&&!s.user?.isDisabled&&s.user?.company?.objectId===co&&new Date(s.expiresAt?.iso)>new Date());if(!session)throw Error('缺少有效的帐套超级管理员会话');
+const route='xiaoshu/system/payroll-repair-'+randomUUID(),expires=Date.now()+600000;let id;
+try{
+ const readSql="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=ANY($1::text[])";
+ const code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(session.user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});try{const apply=(request.body.params||{}).apply===true;if(apply)await Psql.tx(async t=>{await t.none(${JSON.stringify(definitions.join('\n'))});});return response.json({success:true,data:await Psql.query(${JSON.stringify(readSql)},[${JSON.stringify(names)}])});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ new Function(code);id=(await api('/classes/Function','POST',{name:route,path:route,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ async function call(apply){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+route,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':app},body:JSON.stringify({token:session.sessionToken,params:{apply}})}),j=await r.json();if(!r.ok||!j.success)throw Error(j.message||'执行失败');return j.data;}
+ const stamp=Date.now();fs.writeFileSync(path.join(out,'routines-before-'+stamp+'.json'),JSON.stringify(await call(false),null,2),{mode:0o600});
+ if(process.argv.includes('--apply')){const after=await call(true);if(after.length!==names.length)throw Error('安装后函数数量不符');fs.writeFileSync(path.join(out,'routines-after-'+stamp+'.json'),JSON.stringify(after,null,2),{mode:0o600});console.log(JSON.stringify({applied:true,routines:after.map(r=>r.proname)}));}else console.log(JSON.stringify({applied:false,routines:names}));
+}finally{if(id){await api('/classes/Function/'+id,'DELETE');const remaining=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:id}),count:'1',limit:'0'}));if(remaining.count)throw Error('临时函数未清理');}}

+ 9 - 2
scripts/sql/review-rewards.sql

@@ -144,7 +144,7 @@ BEGIN
      NEW."fxzt" := OLD."fxzt"; NEW."wcsj" := OLD."wcsj"; NEW."plid" := OLD."plid";
      RETURN NEW;
    END IF;
-   IF NEW."fxzt"::text='1' AND NOT ((COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%') AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL) THEN
+   IF NEW."fxzt"::text='1' AND (current_setting('xiaoshu.review_completion',true)=NEW."company"||':'||NEW."objectId" OR NOT ((COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%') AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL)) THEN
      NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
    END IF;
  ELSE
@@ -156,7 +156,7 @@ CREATE OR REPLACE FUNCTION xiaoshu_review_after_write() RETURNS trigger LANGUAGE
 BEGIN
  IF NEW."fxzt"::text='1' THEN
    IF TG_OP='INSERT' THEN PERFORM xiaoshu_record_review_reward(NEW,true);
-   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%'); END IF;
+   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1' OR (COALESCE(current_setting('xiaoshu.review_completion',true),'')<>NEW."company"||':'||NEW."objectId" AND (COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%'))); END IF;
  END IF;
  RETURN NEW;
 END $$;
@@ -167,9 +167,16 @@ CREATE TRIGGER review_reward_after_write AFTER INSERT OR UPDATE ON "MemoryPracti
 
 -- The compatibility endpoint calls this, so an unapplied migration fails closed.
 CREATE OR REPLACE FUNCTION xiaoshu_complete_review(tenant text, task_id text) RETURNS void LANGUAGE plpgsql AS $$
+DECLARE previous text:=COALESCE(current_setting('xiaoshu.review_completion',true),'');
 BEGIN
+ -- Only this authenticated completion path earns a reward for an imported pending task.
+ PERFORM set_config('xiaoshu.review_completion',tenant||':'||task_id,true);
  UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=statement_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
  IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
+ PERFORM set_config('xiaoshu.review_completion',previous,true);
+EXCEPTION WHEN OTHERS THEN
+ PERFORM set_config('xiaoshu.review_completion',previous,true);
+ RAISE;
 END $$;
 CREATE OR REPLACE FUNCTION xiaoshu_reward_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
 BEGIN

+ 15 - 0
scripts/tests/business-record-pagination.test.mjs

@@ -0,0 +1,15 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+test('完整来源超过5000条仍可分页、搜索较早记录,空末页保留真实总数且租户隔离',async()=>{
+ const db=new PGlite();try{
+  await db.exec(`CREATE TABLE records(company text,"objectId" text,"targetId" text,"generalId" integer,"commonUpdatedAt" timestamptz,"studentObjectId" text,"studentRealName" text,"studentNickname" text,"studentUsername" text,"studentLegacy" jsonb,"courseTitle" text,"userId" text,dqrq text,learned int,ygg int,djq int);
+   INSERT INTO records SELECT 'co','r'||g,'r'||g,g,'2026-10-12'::timestamptz-g*interval '1 second','s','学员'||g,'','','{}','单词','20','20261012',20,15,5 FROM generate_series(1,6005) g;
+   INSERT INTO records SELECT 'other','private','private',99999,now(),'x','其他帐套','','','{}','单词','99','20261012',20,20,0;`);
+  const ctx=vm.createContext({Date,Psql:{query:async(sql,args)=>(await db.query(sql,args)).rows}});vm.runInContext(source,ctx);
+  const sql='SELECT * FROM records WHERE company=$1 AND ($2=\'\' OR "userId"=$2) ORDER BY "commonUpdatedAt" DESC';
+  let page=await ctx.businessRecordSqlPage('learning-records',sql,['co',''],{page:251,pageSize:20});assert.equal(page.total,6005);assert.equal(page.items.length,20);assert.equal(page.items[0].generalId,5001);
+  page=await ctx.businessRecordSqlPage('learning-records',sql,['co',''],{search:'学员6005'});assert.equal(page.total,1);assert.equal(page.items[0].generalId,6005);
+  page=await ctx.businessRecordSqlPage('learning-records',sql,['co',''],{page:999,pageSize:20});assert.equal(page.total,6005);assert.equal(page.items.length,0);
+  for(const search of ['其他帐套','%','_'])assert.equal((await ctx.businessRecordSqlPage('learning-records',sql,['co',''],{search})).total,0);
+ }finally{await db.close();}
+});

+ 3 - 3
scripts/tests/member-enrollment-gateway.test.mjs

@@ -13,7 +13,7 @@ test('零余额、配套陪练不足和空内容分别说明原因',async()=>{co
 test('同一内容有多个开课分类时旧请求拒绝,指定 bindingId 匹配各自规则',async()=>{const c=setup();c.enrollmentBindings=async()=>[{bindingId:'word',courseId:100,categoryKey:'word'},{bindingId:'self',courseId:100,categoryKey:'word_self_study'}];await assert.rejects(c.enrollmentTeachingContext({}, {},{studentId:20,courseId:100,durationMinutes:60,deliveryMode:'online'},'2026-09-09'),/多个开课分类/);for(const [id,key] of [['word','word'],['self','word_self_study']]){const r=await c.enrollmentTeachingContext({}, {},{studentId:20,courseId:100,bindingId:id,durationMinutes:60,deliveryMode:'online'},'2026-09-09');assert.equal(r.rule.categoryKey,key);assert.equal(r.bindingId,id);}});
 test('开课请求成功后的重试复用结果,不重新校验已经耗尽的余额',async()=>{const c=setup(),req={uid:20,category:'word',courseIds:[100],actor:'a',reason:'正式开课'};c.enrollmentOptions=async()=>{throw Error('不应重新计算资格');};c.Psql={oneOrNone:async()=>({request:req,result:{bindingIds:['b'],count:1}}),one:async(sql,args)=>({same:JSON.stringify(JSON.parse(args[0]))===JSON.stringify(JSON.parse(args[1]))})};assert.deepEqual(plain(await c.saveEnrollment({current:{id:'a'}},{reason:'正式开课',idempotencyKey:'test-0001'},{studentId:20,categoryKey:'word',courseIds:[100]})),{bindingIds:['b'],count:1});});
 test('批量排课跨规则日期分别保存快照',async()=>{const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.enrollmentBindings=async()=>[{bindingId:'b',courseId:100,categoryKey:'trial'}];c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{UserExp:10,UserPoint:10}});const result=await c.preflightAppointments({}, {},{studentId:20,courseId:100,bindingId:'b',durationMinutes:60,deliveryMode:'online',date:'2026-09-08',startTime:'10:00',recurrence:'daily',occurrences:2});assert.deepEqual(plain(result.candidates.map(r=>r.teachingRule.teacherPay)),[40,30]);assert.equal(result.creditAvailability.find(r=>r.account==='UserExp').required,2);});
-test('新后台列表按学员精确查询,筛选发生在全站记录上限之前',async()=>{const c=setup();const calls=[];c.Psql={query:async(sql,args)=>{calls.push([sql,args]);return[];}};for(const kind of ['course-bindings','appointments','lessons','learning-records'])await c.businessRows(kind,{}, {studentId:20});await c.moneyRows({}, {studentId:20});for(const [sql,args] of calls){assert.equal(args[1],'20');assert.ok(sql.indexOf('$2')<sql.indexOf('LIMIT 5000'));}});
+test('新后台列表按学员精确查询,筛选发生在全站记录上限之前',async()=>{const c=setup();const calls=[];c.Psql={query:async(sql,args)=>{calls.push([sql,args]);return[];}};for(const kind of ['course-bindings','appointments','lessons','learning-records'])await c.businessRows(kind,{}, {studentId:20});await c.moneyRows({}, {studentId:20});for(const [sql,args] of calls){assert.equal(args[1],'20');assert.ok(sql.indexOf('$2')<sql.indexOf('LIMIT '));}});
 test('旧同步不能覆盖账号或云端创建的开课预约,未授权投影被拒绝',async()=>{
  const c=setup();await assert.rejects(c.applyUserSync({}, {},{}),/禁止旧数据覆盖/);
  for(const dataset of ['course-bindings','appointments','lessons'])assert.throws(()=>c.assertLegacyProjectionWritable({get:key=>key==='sourceKey'?'cloud:test':''},dataset),/未覆盖新数据/);
@@ -22,8 +22,8 @@ test('旧同步不能覆盖账号或云端创建的开课预约,未授权投
 });
 test('用户端通用内容接口不能绕过开课、预留及完课事务',async()=>{const c={};vm.createContext(c);vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c);for(const modelId of [54,58,59])await assert.rejects(c.createContentPair({content:JSON.stringify({modelId}),addon:'{}'},{}),/正式完课流程/);});
 
-test('会员预约详情沿用开课分类并识别回收站取消状态',async()=>{const c=setup();c.Psql={query:async()=>[{targetId:'a',generalId:1,commonStatus:-2,szyh:'20',pl:'30',dslx:'2',dszt:'0',yysj:'2026-09-09 10:00',courseCategoryKey:'word_self_study',courseCategoryName:'单词自学课',bindingId:'b',durationMinutes:60}]};const page=await c.businessRows('appointments',{}, {studentId:20,page:1,pageSize:20});assert.equal(page.items[0].bindingId,'b');assert.equal(page.items[0].classTypeLabel,'单词自学课·60分钟');assert.equal(page.items[0].statusLabel,'已取消');});
-test('会员上课记录使用教师姓名和开课分类,不将编号当姓名',async()=>{const c=setup();c.Psql={query:async()=>[{objectId:'l',xymz:'20',jsmz:'30',coachRealName:'测试老师',kclx:'2',courseCategoryName:'单词自学课',bindingId:'b'}]};const page=await c.businessRows('lessons',{}, {studentId:20});assert.equal(page.items[0].coachName,'测试老师');assert.equal(page.items[0].classTypeLabel,'单词自学课');});
+test('会员预约详情沿用开课分类并识别回收站取消状态',async()=>{const c=setup();c.Psql={query:async()=>[{total:1,items:[{targetId:'a',generalId:1,commonStatus:-2,szyh:'20',pl:'30',dslx:'2',dszt:'0',yysj:'2026-09-09 10:00',courseCategoryKey:'word_self_study',courseCategoryName:'单词自学课',bindingId:'b',durationMinutes:60}]}]};const page=await c.businessRows('appointments',{}, {studentId:20,page:1,pageSize:20});assert.equal(page.items[0].bindingId,'b');assert.equal(page.items[0].classTypeLabel,'单词自学课·60分钟');assert.equal(page.items[0].statusLabel,'已取消');});
+test('会员上课记录使用教师姓名和开课分类,不将编号当姓名',async()=>{const c=setup();c.Psql={query:async()=>[{total:1,items:[{objectId:'l',xymz:'20',jsmz:'30',coachRealName:'测试老师',kclx:'2',courseCategoryName:'单词自学课',bindingId:'b'}]}]};const page=await c.businessRows('lessons',{}, {studentId:20});assert.equal(page.items[0].coachName,'测试老师');assert.equal(page.items[0].classTypeLabel,'单词自学课');});
 
 test('会员概览未结束数、体验记录与下次预约排除已取消,保留总历史数',async()=>{
  const {PGlite}=await import('@electric-sql/pglite');const db=new PGlite();

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

@@ -69,3 +69,19 @@ test('工资结算仅在三个数据集追平、两分钟内同步且无失败
  checkpoints=checkpoints.map(row=>({...row,state:'healthy'}));issues=[{dataset:'appointments',kind:'failure',total:1}];assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
  issues=[];checkpoints=checkpoints.map(row=>row.dataset==='memory-records'?{...row,lastSuccessAt:new Date(Date.now()-121000).toISOString()}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
 });
+
+test('所选工资月份的已完课写作预约可逐人核对,其他月份不混入',async()=>{
+ await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,kcid,yysj,"updatedAt") VALUES ('completed-writing','co',9101,'20','11','398','2026-10-02 10:00',now());
+ INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status,subtitle) VALUES ('completed-writing-common','co',54,9101,9101,99,'阅读写作'),('completed-writing-lesson-common','co',59,9102,9102,99,'阅读写作');
+ INSERT INTO "LessonRecord" ("objectId",company,id,yyds,"lessonAt",jsmz,xymz,kclx) VALUES ('completed-writing-lesson','co',9102,'9101','2026-10-02 11:00+08','30','20','2');`);
+ assert.ok((await context.writingMappingPage(manager,{month:'2026-10'})).items.some(r=>r.appointmentId==='completed-writing-common'));
+ assert.ok(!(await context.writingMappingPage(manager,{month:'2026-09'})).items.some(r=>r.appointmentId==='completed-writing-common'));
+ await assert.rejects(context.writingMappingPage(manager,{month:'2026-13'}),/月份/);
+});
+test('手动同步真实收据显示完成时间,但不能冒充结算准备完成或桥检查点',async()=>{
+ const receipt={source:'old-api/WXAPP',status:'completed-with-preserved-conflicts',completedAt:'2026-10-12T08:39:11.787Z',datasets:[{modelId:54,source:100,failed:0,conflicts:2,reconciled:true}]};
+ const ctx=vm.createContext({Date,Psql:{query:async()=>[{detail:JSON.stringify({after:receipt})}]}});
+ vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';
+ const status=await ctx.payrollSettlementSyncStatus(manager,{});assert.equal(status.state,'batch_verified');assert.equal(status.lastSuccessAt,receipt.completedAt);assert.equal(status.settlementReady,false);assert.equal(status.datasets[0].conflicts,2);
+ receipt.datasets[0].reconciled=false;assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).state,'not_configured');
+});

+ 14 - 0
scripts/tests/review-rewards.test.mjs

@@ -127,3 +127,17 @@ test('实际工资草稿刷新路径反复调用仍只有两条待结算奖励
  assert.equal((await one('SELECT "status" FROM "PayrollBatch" WHERE "objectId"=\'batch2\'')).status,'reviewed');
  assert.equal((await context.reviewRewardPayrollLines('c','2099-02',false)).length,0);
 });
+
+test('导入的待完成任务经新系统首次完成计薪一元,重试幂等且完成上下文不泄漏',async()=>{
+ for(const [id,sourceKey,day] of [['import-pending','legacy:model:60:general:801','2026-10-01'],['bridge-pending','legacy-sync:memory-records:addon:802','2026-10-02']]){
+  await task(id,day,{sourceKey});await complete(id);await complete(id);
+  const reward=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[id]);
+  assert.equal(reward.status,'earned');assert.equal(Number(reward.amount),1);
+  assert.equal(reward.incomeMonth,new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}).slice(0,7));
+  assert.notEqual((await one('SELECT "wcsj" FROM "MemoryPracticeRecord" WHERE "objectId"=$1',[id])).wcsj,'2026-08-01 10:00:00');
+ }
+ assert.equal((await one("SELECT COALESCE(current_setting('xiaoshu.review_completion',true),'') AS value")).value,'');
+ await task('later-import','2026-10-03',{sourceKey:'legacy:model:60:general:803'});
+ await query(`UPDATE "MemoryPracticeRecord" SET fxzt='1',wcsj='2026-10-03 12:00:00' WHERE "objectId"='later-import'`);
+ assert.equal((await one(`SELECT status FROM "ReviewReward" WHERE "taskId"='later-import'`)).status,'historical');
+});

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini