Переглянути джерело

fix historical reservation backfill

彭峰 3 тижнів тому
батько
коміт
f8879d4ea4

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html

@@ -34,7 +34,7 @@
    </section>
   } @else {
    <section class="content-card reconciliation-card"><header><div><strong>2026 年 9 月起复习课课时差异</strong><span>只预览新规则生效后的已完课记录;确认前不会修改余额</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reconciliation()?.summary?.differenceCount){<button class="ui-button primary" type="button" (click)="commitReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认退回差额</button>}</div></header>
-   @if(reconciliation();as result){<div class="reconciliation-summary"><article><span>已完成复习课</span><strong>{{result.summary.completedReviewCount}}</strong></article><article><span>待处理差异</span><strong>{{result.summary.differenceCount}}</strong></article><article><span>应退陪练时长</span><strong>{{result.summary.refundTotal|number:'1.1-2'}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员ID</th><th>上课时间</th><th>时长</th><th>规则应扣</th><th>实际已扣</th><th>应退</th><th>状态</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentId}}</td><td>{{row.lessonAt}}</td><td>{{row.durationMinutes}}分钟</td><td>{{row.expected}}</td><td>{{row.charged}}</td><td [class.money]="row.adjustment">{{row.adjustment}}</td><td><span class="badge" [class.warn]="row.adjustment">{{row.status}}</span></td></tr>}@empty{<tr><td colspan="8"><div class="empty">暂无已完成的复习课记录</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对复习课课时……</div>}
+   @if(reconciliation();as result){<div class="reconciliation-summary three-column"><article><span>已完成复习课</span><strong>{{result.summary.completedReviewCount}}</strong></article><article><span>待处理差异</span><strong>{{result.summary.differenceCount}}</strong></article><article><span>应退陪练时长</span><strong>{{result.summary.refundTotal|number:'1.1-2'}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员ID</th><th>上课时间</th><th>时长</th><th>规则应扣</th><th>实际已扣</th><th>应退</th><th>状态</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentId}}</td><td>{{row.lessonAt}}</td><td>{{row.durationMinutes}}分钟</td><td>{{row.expected}}</td><td>{{row.charged}}</td><td [class.money]="row.adjustment">{{row.adjustment}}</td><td><span class="badge" [class.warn]="row.adjustment">{{row.status}}</span></td></tr>}@empty{<tr><td colspan="8"><div class="empty">暂无已完成的复习课记录</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对复习课课时……</div>}
    </section>
   }
  }

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

@@ -496,7 +496,8 @@
 .category-actions { display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 8px; }
 .muted-text { color:#98a7a2; }
 .reconciliation-card .header-actions { display:flex;align-items:center;gap:8px; }
-.reconciliation-summary { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:18px; }
+.reconciliation-summary { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:18px; }
+.reconciliation-summary.three-column { grid-template-columns:repeat(3,minmax(0,1fr)); }
 .reconciliation-summary article { padding:16px 18px;border:1px solid #dce7e3;border-radius:14px;background:#f8fbfa; }
 .reconciliation-summary span,.reconciliation-summary strong { display:block; }
 .reconciliation-summary span { color:#71837d;font-size:12px;font-weight:700; }
@@ -518,7 +519,7 @@
 }
 
 @media (max-width: 900px) {
-  .standards-summary,.category-grid,.reconciliation-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
+  .standards-summary,.category-grid,.reconciliation-summary,.reconciliation-summary.three-column { grid-template-columns:repeat(2,minmax(0,1fr)); }
   .standard-editor .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
   .payroll-header {
     align-items: stretch;

+ 1 - 1
scripts/cloud/payroll-integrity.js

@@ -80,7 +80,7 @@ async function saveTeachingRule(context,input,payload){
  return (await payrollSql('SELECT xs_payroll_rule($1,$2,$3::jsonb) AS result',[co,context.current.id,JSON.stringify(req)])).result;
 }
 async function deactivateTeachingRule(context,input){const co=payrollTenant(context,input),r=await Psql.oneOrNone('SELECT * FROM "TeachingCompensationRule" WHERE "company"=$1 AND "objectId"=$2',[co,input.targetId]);if(!r)fail(404,'规则不存在');const latest=(await teachingRuleRows(context,input)).find(v=>v.objectId===r.objectId);if(latest?.superseded)fail(409,'请选择当前规则版本');return saveTeachingRule(context,input,{...r,active:false});}
-async function historicalReservationPreview(context,input){const co=payrollTenant(context,input),data=await historicalReservationPreviewLegacy(context,input),rows=await Psql.query('SELECT c."objectId",a."updatedAt" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text WHERE c."company"=$1 AND c."modelId"=54',[co]),versions=new Map(rows.map(r=>[r.objectId,r.updatedAt]));return{...data,items:data.items.map(i=>({...i,expectedUpdatedAt:versions.get(i.appointmentId)}))};}
+async function historicalReservationPreview(context,input){const co=payrollTenant(context,input),data=await historicalReservationPreviewLegacy(context,input),rows=await Psql.query('SELECT c."objectId",a."updatedAt"::text AS "updatedAt" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text WHERE c."company"=$1 AND c."modelId"=54',[co]),versions=new Map(rows.map(r=>[r.objectId,r.updatedAt]));return{...data,items:data.items.map(i=>({...i,expectedUpdatedAt:versions.get(i.appointmentId)}))};}
 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;}

+ 2 - 2
scripts/sql/payroll-integrity.sql

@@ -233,7 +233,7 @@ BEGIN
    status:='ready';why:='';
    IF u."objectId" IS NULL THEN status:='missing_student';why:='学员不存在'; END IF;
    FOR cost IN SELECT value FROM jsonb_array_elements(costs) LOOP
-    IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId",cost->>'account')<(cost->>'amount')::numeric THEN status:='insufficient';why:='可用课时不足,请补充课时后重新核对';END IF;
+    IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId"::numeric,cost->>'account')<(cost->>'amount')::numeric THEN status:='insufficient';why:='可用课时不足,请补充课时后重新核对';END IF;
    END LOOP;
   END IF;
   UPDATE "CourseAppointment" SET "creditHoldStatus"=status,"creditHoldReason"=why,
@@ -267,7 +267,7 @@ BEGIN
   SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=item->>'studentId' FOR UPDATE;
   IF NOT FOUND THEN RAISE EXCEPTION '学员不存在';END IF;
   before_value:=COALESCE((u."legacyUserData"->>'UserPoint')::numeric,0);tx:=xs_object_id();
-  PERFORM xs_ledger(co,u."legacyUserId",'UserPoint',delta,before_value,tx,actor,reason);
+  PERFORM xs_ledger(co,u."legacyUserId"::numeric,'UserPoint',delta,before_value,tx,actor,reason);
   UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),'{UserPoint}',to_jsonb(before_value+delta)),"updatedAt"=now() WHERE "objectId"=u."objectId";
   INSERT INTO "CreditTransaction" ("objectId","company","createdAt","updatedAt","idempotencyKey","request","kind","account","amount","studentId","operatorId","reason","balances") VALUES(tx,co,now(),now(),'reconcile:'||(item->>'appointmentId'),item,'reconciliation','UserPoint',delta,u."legacyUserId",actor,reason,jsonb_build_object('before',before_value,'after',before_value+delta));
   INSERT INTO "PayrollReconciliation" ("company","appointmentId","amount","transactionId") VALUES(co,item->>'appointmentId',delta,tx);

+ 6 - 0
scripts/tests/payroll-gateway-transform.test.mjs

@@ -1,5 +1,6 @@
 import test from 'node:test';
 import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
 import { normalizeHistoricalReservationPreviewBase } from '../payroll-gateway-transform.mjs';
 
 const original = "async function historicalReservationPreview(context,input){return 'legacy';}\n";
@@ -21,3 +22,8 @@ test('重复增量发布会移除递归包装并保留真正的旧预览', () =>
 test('缺少历史预约预览入口时拒绝发布', () => {
   assert.throws(() => normalizeHistoricalReservationPreviewBase('function unrelated(){}'), /版本不兼容/);
 });
+
+test('历史预约预览保留数据库微秒精度,避免确认时误判预约已更新', () => {
+  const integrity = readFileSync(new URL('../cloud/payroll-integrity.js', import.meta.url), 'utf8');
+  assert.match(integrity, /a\."updatedAt"::text AS "updatedAt"/);
+});

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

@@ -11,7 +11,7 @@ async function version(){return(await one('SELECT xs_payroll_source_version(\'co
 before(async()=>{
  const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n  });/)[1]+')');
  for(const [name,fields] of Object.entries(schemas)){const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);}
- await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz);
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz);
  CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
  CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);
  CREATE TABLE "DailyStudyRecord" ("objectId" text);