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

Automatically reconcile verified zero-fee legacy trial reviews

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

+ 80 - 13
scripts/cloud/legacy-direct-control.js

@@ -25,7 +25,7 @@ async function directOld(action,args={},timeout=60000) {
  const items=typeof j.result==='string'?JSON.parse(j.result):j.result;
  if(!Array.isArray(items))throw Error('legacy_invalid_result');return {items,page:j.page||{}};
 }
-async function directReviewFeeProof(co,rows) {
+async function directReviewFeeProof(co,rows,includePending=false) {
  const ids=rows.filter(row=>String(row.fxzt)==='1').map(row=>Number(row.GeneralID??row.generalId));
  if(!ids.length)return rows;
  const candidates=await Psql.query(`SELECT c."generalId",r."coachId"
@@ -34,9 +34,10 @@ async function directReviewFeeProof(co,rows) {
   JOIN "ReviewRewardIncome" v ON v.company=r.company AND v."objectId"=r."objectId"
   LEFT JOIN "LegacyReviewSyncState" s ON s.company=m.company AND s."taskId"=m."objectId"
   WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"=ANY($2::numeric[]) AND m.fxzt='1'
-  AND NOT COALESCE(s."pendingIncome",false) AND v.status IN ('historical','exception')
-  AND v."incomeMonth">='2026-09' AND r."completionRole"='historical_unknown'`,[co,ids]);
- const verified=new Set(),fields=['yhid','plid','xxjlid','orderId','kcid','fxzt','wcsj','kywrq','kywsj'];
+  AND ($3 OR NOT COALESCE(s."pendingIncome",false)) AND v.status IN ('historical','exception')
+  AND (v."incomeMonth">='2026-09' OR xs_legacy_review_needs_fee(m.company,m."objectId"))
+  AND r."completionRole"='historical_unknown'`,[co,ids,includePending]);
+ const verified=new Set(),zeros=new Map(),trials=new Map(),fields=['yhid','plid','xxjlid','orderId','kcid','fxzt','wcsj','kywrq','kywsj'];
  const deadline=Date.now()+15000;
  for(const coach of new Set(candidates.map(row=>Number(row.coachId)))){
   const coachIds=new Set(candidates.filter(row=>Number(row.coachId)===coach).map(row=>Number(row.generalId)));
@@ -60,14 +61,72 @@ async function directReviewFeeProof(co,rows) {
    if(read!==total)throw Error('legacy_review_income_page_incomplete');
    for(const row of rows){
     const id=Number(row.GeneralID??row.generalId),fee=income.get(id);
-    if(fee&&Number(fee.money)>0&&Number(row.plid)===coach&&fields.every(k=>String(fee[k]??'')===String(row[k]??'')))verified.add(id);
+    if(!fee||Number(row.plid)!==coach||!fields.every(k=>String(fee[k]??'')===String(row[k]??'')))continue;
+    if(Number(fee.money)>0)verified.add(id);
+    if(/^0(?:[.]0+)?$/.test(String(fee.money)))zeros.set(id,fee);
    }
   }catch {
    // The old fee API can fail for individual accounts. Keep syncing the source page and retry on the next run.
    console.warn(JSON.stringify({event:'legacy_review_income_proof_unavailable',coach}));
   }
  }
- return rows.map(row=>({...row,reviewFeeVerified:verified.has(Number(row.GeneralID??row.generalId))}));
+ if(zeros.size){
+  const zeroRows=rows.filter(row=>zeros.has(Number(row.GeneralID??row.generalId)));
+  try{
+   const [appointments,studies]=await Promise.all([
+    directReviewSourceById(54,zeroRows.map(row=>row.orderId)),
+    directReviewSourceById(56,zeroRows.map(row=>row.xxjlid)),
+   ]);
+   for(const row of zeroRows){
+    const id=Number(row.GeneralID??row.generalId),appointment=appointments.get(Number(row.orderId)),study=studies.get(Number(row.xxjlid));
+    if(!appointment||String(appointment.dslx)!=='3'||!/[((]体验课[))]$/.test(String(appointment.Title??'').trim())||
+       String(appointment.szyh)!==String(row.yhid)||!study||String(study.dsid)!==String(row.orderId))continue;
+    const pick=(value,keys)=>Object.fromEntries(keys.map(key=>[key,value[key]]));
+    trials.set(id,{fee:pick(zeros.get(id),['GeneralID','money',...fields]),
+     appointment:pick(appointment,['GeneralID','Title','szyh','dslx']),study:pick(study,['GeneralID','dsid'])});
+   }
+  }catch{console.warn(JSON.stringify({event:'legacy_review_trial_source_unavailable'}));}
+ }
+ return rows.map(row=>{
+  const id=Number(row.GeneralID??row.generalId);
+  return {...row,reviewFeeVerified:verified.has(id),reviewTrialProof:trials.get(id)??null};
+ });
+}
+
+async function directReviewSourceById(modelId,values) {
+ const ids=[...new Set(values.map(Number).filter(id=>Number.isSafeInteger(id)&&id>0))];
+ const result=new Map(),deadline=Date.now()+15000;
+ for(let i=0;i<ids.length;i+=75){
+  const batch=ids.slice(i,i+75),remaining=deadline-Date.now();
+  if(remaining<=0)throw Error('legacy_review_source_timeout');
+  const {items,page}=await directOld('content_list',{modelId,ids:batch.join(','),cpage:1,psize:100},Math.min(6000,remaining));
+  const pages=Number(page.pageCount);
+  if(Number(page.itemCount)!==items.length||!Number.isSafeInteger(pages)||pages<0||pages>1)throw Error('legacy_review_source_incomplete');
+  for(const item of items){
+   const id=Number(item.GeneralID);
+   if(!batch.includes(id))throw Error('legacy_review_source_mismatch');
+   result.set(id,result.has(id)?null:item);
+  }
+ }
+ return result;
+}
+
+async function directPendingTrialProof(co,afterTaskId) {
+ // A task can become complete during projection, after its first source-page read.
+ const rows=await Psql.query(`SELECT c."generalId" FROM "LegacyReviewSyncState" s
+  JOIN "MemoryPracticeRecord" m ON m.company=s.company AND m."objectId"=s."taskId"
+  JOIN "CommonModel" c ON c.company=m.company AND c."modelId"=60 AND c."itemId"=m.id
+  WHERE s.company=$1 AND s."taskId" IN (
+   SELECT "taskId" FROM "LegacyReviewSyncState" WHERE company=$1 AND "pendingIncome" AND "taskId">$2 ORDER BY "taskId" LIMIT 100
+  ) AND s."legacyFeeProof" IS NULL AND xs_legacy_review_needs_fee(m.company,m."objectId")`,[co,afterTaskId]);
+ if(!rows.length)return [];
+ try{
+  const source=await directReviewSourceById(60,rows.map(row=>row.generalId));
+  return await directReviewFeeProof(co,[...source.values()].filter(Boolean),true);
+ }catch{
+  console.warn(JSON.stringify({event:'legacy_review_pending_trial_source_unavailable'}));
+  return [];
+ }
 }
 
 async function directControl(p,co,u) {
@@ -87,14 +146,22 @@ async function directControl(p,co,u) {
  });
  if(p.action==='finish'){const prior=await Psql.oneOrNone('SELECT status,summary FROM "LegacyDirectRun" WHERE company=$1 AND "runId"=$2 AND owner=$3',[co,p.runId,p.owner]);if(prior&&prior.status!=='running')return prior.summary;}
  await directLease(Psql,p,co);
- if(p.action==='review-income')return Psql.tx(async t=>{
-  await directLease(t,p,co);
+ if(p.action==='review-income'){
   if(!/^review-income:[A-Za-z0-9:._-]{1,140}$/.test(p.batchKey||''))throw Error('Invalid review income batch');
-  const fingerprint=(await t.one('SELECT md5($1) value',[JSON.stringify(['review-income',String(p.afterTaskId||'')])])).value;
-  const prior=await t.oneOrNone('SELECT fingerprint,result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,p.batchKey]);if(prior){if(prior.fingerprint!==fingerprint)throw Error('batch_key_reused');return prior.result;}
-  const result=(await t.one('SELECT xs_legacy_review_income($1,$2,100,$3) result',[co,u.id,String(p.afterTaskId||'')])).result;
-  await t.none('INSERT INTO "LegacyDirectBatch"(company,"runId",key,fingerprint,result) VALUES($1,$2,$3,$4,$5::jsonb)',[co,p.runId,p.batchKey,fingerprint,JSON.stringify({...result,modelId:0})]);return result;
- });
+  const afterTaskId=String(p.afterTaskId||'');
+  const proof=await directPendingTrialProof(co,afterTaskId);
+  return Psql.tx(async t=>{
+   await directLease(t,p,co);
+   const fingerprint=(await t.one('SELECT md5($1) value',[JSON.stringify(['review-income',String(p.afterTaskId||'')])])).value;
+   const prior=await t.oneOrNone('SELECT fingerprint,result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,p.batchKey]);if(prior){if(prior.fingerprint!==fingerprint)throw Error('batch_key_reused');return prior.result;}
+   if(proof.length){
+    await t.none('LOCK TABLE "MemoryPracticeRecord","ReviewReward" IN SHARE ROW EXCLUSIVE MODE');
+    await t.one('SELECT xs_legacy_review_observe($1,$2::jsonb)',[co,JSON.stringify(proof)]);
+   }
+   const result=(await t.one('SELECT xs_legacy_review_income($1,$2,100,$3) result',[co,u.id,afterTaskId])).result;
+   await t.none('INSERT INTO "LegacyDirectBatch"(company,"runId",key,fingerprint,result) VALUES($1,$2,$3,$4,$5::jsonb)',[co,p.runId,p.batchKey,fingerprint,JSON.stringify({...result,modelId:0})]);return result;
+  });
+ }
  if(p.action==='finance-members')return Psql.query('SELECT u."legacyUserId" id FROM "_User" u WHERE u.company=$1 AND u."identityType"=\'member\' AND COALESCE(u."isDeleted",false)=false AND (u."sourceKey" LIKE \'legacy%\' OR u."sourceKey" LIKE \'[["UserID",%\') AND COALESCE(u."legacyUserData"->>\'SyncExcluded\',\'false\')<>\'true\' AND ((SELECT count(*) FROM "LegacyFinanceReceipt" f WHERE f.company=u.company AND f."studentId"=u."legacyUserId" AND f."runId"=$2)<4 OR NOT EXISTS(SELECT 1 FROM "LegacyCreditBalanceReceipt" b WHERE b.company=u.company AND b."studentId"=u."legacyUserId" AND b."runId"=$2)) ORDER BY u."legacyUserId"',[co,p.runId]);
  if(p.action==='finance'){
   const result=await importLegacyFinance(co,Number(p.studentId),p.runId,args=>directOld('user_point_list',args));

+ 3 - 1
scripts/deploy-direct-sync.mjs

@@ -14,9 +14,11 @@ const session=sessions.find(s=>s.sessionToken===token&&s.user?.adminRoleKey==='s
 if(!session)throw Error('A valid scoped super-admin session is required');
 const path='xiaoshu/system/direct-deploy-'+randomUUID(),expiry=Date.now()+600000;
 const reviewSql=readFileSync(new URL('./sql/legacy-review-sync.sql',import.meta.url),'utf8').replace(/^BEGIN;\s*$/m,'').replace(/^COMMIT;\s*$/m,'');
+const resolutionSql=readFileSync(new URL('./sql/review-reconciliation.sql',import.meta.url),'utf8').match(/CREATE OR REPLACE FUNCTION xs_review_resolve\([\s\S]*?\$\$;/)?.[0];
+if(!resolutionSql)throw Error('Missing review resolution function');
 const atomicSql=readFileSync(new URL('./sql/mobile-teaching.sql',import.meta.url),'utf8').match(/CREATE OR REPLACE FUNCTION xs_mobile_teaching_write\([\s\S]*?\$\$;/)?.[0];
 if(!atomicSql)throw Error('Missing atomic teaching writer');
-const sql=[readFileSync(new URL('./sql/legacy-direct-sync.sql',import.meta.url),'utf8'),readFileSync(new URL('./sql/legacy-credit-balances.sql',import.meta.url),'utf8'),reviewSql,atomicSql].join('\n');let helperId;
+const sql=[readFileSync(new URL('./sql/legacy-direct-sync.sql',import.meta.url),'utf8'),readFileSync(new URL('./sql/legacy-credit-balances.sql',import.meta.url),'utf8'),resolutionSql,reviewSql,atomicSql].join('\n');let helperId;
 try{
  const code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(session.user.objectId)}||Date.now()>${expiry})return response.status(403).json({success:false});try{await Psql.tx(async t=>t.none(${JSON.stringify(sql)}));return response.json({success:true,data:await Psql.query(${JSON.stringify("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public'")})});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
  new Function(code);helperId=(await api('/classes/Function','POST',{name:path,path,type:'standalone',enabled:true,code,params:[],paramList:[],respType:'json'})).objectId;

+ 42 - 13
scripts/sql/legacy-review-sync.sql

@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS "LegacyReviewSyncState" (
  "lastError" text NOT NULL DEFAULT '',"updatedAt" timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY(company,"taskId")
 );
+ALTER TABLE "LegacyReviewSyncState" ADD COLUMN IF NOT EXISTS "legacyFeeProof" jsonb;
 CREATE INDEX IF NOT EXISTS legacy_review_income_pending ON "LegacyReviewSyncState"(company,"taskId") WHERE "pendingIncome";
 CREATE TABLE IF NOT EXISTS "LegacyReviewSyncAudit" (
  "objectId" text PRIMARY KEY,company text NOT NULL,"taskId" text NOT NULL,
@@ -34,7 +35,7 @@ BEGIN
  SELECT EXISTS(SELECT 1 FROM "ReviewRewardIncome" r WHERE r.company=co AND r."taskId"=task_id AND r."incomeMonth">='2026-09' AND r.status IN ('historical','exception')) INTO pending;
  INSERT INTO "LegacyReviewSyncState"(company,"taskId","assignmentVersion",fingerprint,"pendingIncome")
  VALUES(co,task_id,COALESCE((m->>'assignmentVersion')::numeric,0),md5(m::text),pending)
- ON CONFLICT(company,"taskId") DO UPDATE SET "assignmentVersion"=EXCLUDED."assignmentVersion",fingerprint=EXCLUDED.fingerprint,"pendingIncome"=EXCLUDED."pendingIncome","lastError"='',"updatedAt"=now();
+ ON CONFLICT(company,"taskId") DO UPDATE SET "assignmentVersion"=EXCLUDED."assignmentVersion",fingerprint=EXCLUDED.fingerprint,"pendingIncome"=EXCLUDED."pendingIncome","legacyFeeProof"=NULL,"lastError"='',"updatedAt"=now();
  INSERT INTO "LegacyDirectBaseline"(company,model,gid,fingerprint) VALUES(co,60,(c->>'generalId')::numeric,md5(jsonb_build_array(c,m)::text))
  ON CONFLICT(company,model,gid) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,"updatedAt"=now();
 END $$;
@@ -45,12 +46,17 @@ CREATE OR REPLACE FUNCTION xs_legacy_review_observe(co text, source_rows jsonb)
 DECLARE row_data record; n integer:=0;
 BEGIN
  IF jsonb_typeof(source_rows)<>'array' OR jsonb_array_length(source_rows)>1000 THEN RAISE EXCEPTION '无效旧复习来源页'; END IF;
- FOR row_data IN SELECT m."objectId",m.fxzt,md5(to_jsonb(m)::text) AS fingerprint FROM jsonb_array_elements(source_rows) s
+ FOR row_data IN SELECT m."objectId",m.fxzt,COALESCE(m."assignmentVersion",0) AS version,md5(to_jsonb(m)::text) AS fingerprint,
+  CASE WHEN jsonb_typeof(s->'reviewTrialProof')='object' THEN jsonb_build_object('trial',s->'reviewTrialProof')
+       WHEN s->>'reviewFeeVerified'='true' THEN '{"positive":true}'::jsonb END AS proof
+ FROM jsonb_array_elements(source_rows) s
   JOIN "CommonModel" c ON c.company=co AND c."modelId"=60 AND c."generalId"=(s->>'GeneralID')::numeric
   JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId"
   LEFT JOIN "LegacyDirectBaseline" b ON b.company=c.company AND b.model=60 AND b.gid=c."generalId"
   LEFT JOIN "LegacyReviewSyncState" st ON st.company=m.company AND st."taskId"=m."objectId"
-  WHERE COALESCE(m."assignmentVersion",0)=0 AND m."sourceKey" LIKE 'legacy:model:60:general:%'
+  WHERE (COALESCE(m."assignmentVersion",0)=0 OR
+       (m.fxzt='1' AND st."pendingIncome" AND st."assignmentVersion"=m."assignmentVersion" AND st.fingerprint=md5(to_jsonb(m)::text)))
+  AND m."sourceKey" LIKE 'legacy:model:60:general:%'
   AND NOT COALESCE(c."teachingManaged",false) AND COALESCE(c.status,99)<>-2
   AND (b.fingerprint IS NULL OR b.fingerprint=md5(jsonb_build_array(to_jsonb(c),to_jsonb(m))::text)
        OR (m."assignmentVersion" IS NULL AND b.fingerprint=md5(jsonb_build_array(to_jsonb(c),to_jsonb(m)-'assignmentVersion')::text)))
@@ -59,13 +65,14 @@ BEGIN
   AND ((m.fxzt='0' AND st."taskId" IS NULL AND NOT EXISTS(
          SELECT 1 FROM "ReviewReward" r WHERE r.company=m.company AND r."taskId"=m."objectId"))
        OR (m.fxzt='1' AND COALESCE(c.status,99)=99
-         AND s->>'reviewFeeVerified'='true'
+         AND (s->>'reviewFeeVerified'='true' OR jsonb_typeof(s->'reviewTrialProof')='object')
          AND m."sourceKey"='legacy:model:60:general:'||c."generalId"::text AND c."sourceKey"=m."sourceKey"
-         AND (st."taskId" IS NULL OR (NOT st."pendingIncome" AND st."assignmentVersion"=0 AND st.fingerprint=md5(to_jsonb(m)::text)))
+         AND (st."taskId" IS NULL OR (st."assignmentVersion"=COALESCE(m."assignmentVersion",0) AND st.fingerprint=md5(to_jsonb(m)::text)))
          AND EXISTS(SELECT 1 FROM "ReviewReward" r
            JOIN "ReviewRewardIncome" v ON v.company=r.company AND v."objectId"=r."objectId"
            WHERE r.company=m.company AND r."taskId"=m."objectId" AND r.amount=0
-           AND v.status IN ('historical','exception') AND v.amount=0 AND v."incomeMonth">='2026-09'
+           AND v.status IN ('historical','exception') AND v.amount=0
+           AND (v."incomeMonth">='2026-09' OR jsonb_typeof(s->'reviewTrialProof')='object')
            AND r."completionRole"='historical_unknown' AND NULLIF(r."completedBy",'') IS NULL
            AND r."studentId"::text=m.yhid::text AND r."coachId"::text=m.plid::text AND r."plannedDate"=LEFT(m.kywrq,10)
            AND r."completedAt"=xiaoshu_safe_shanghai_timestamp(m.wcsj) AND r."completedAt"<=now()
@@ -76,10 +83,11 @@ BEGIN
    PERFORM xs_legacy_review_track(co,row_data."objectId");
    n:=n+1;
   ELSE
-   INSERT INTO "LegacyReviewSyncState"(company,"taskId","assignmentVersion",fingerprint,"pendingIncome")
-   VALUES(co,row_data."objectId",0,row_data.fingerprint,true)
-   ON CONFLICT(company,"taskId") DO UPDATE SET "pendingIncome"=true,"updatedAt"=now()
-   WHERE NOT "LegacyReviewSyncState"."pendingIncome" AND "LegacyReviewSyncState"."assignmentVersion"=0
+   INSERT INTO "LegacyReviewSyncState"(company,"taskId","assignmentVersion",fingerprint,"pendingIncome","legacyFeeProof")
+   VALUES(co,row_data."objectId",row_data.version,row_data.fingerprint,true,row_data.proof)
+   ON CONFLICT(company,"taskId") DO UPDATE SET "pendingIncome"=true,"legacyFeeProof"=EXCLUDED."legacyFeeProof","updatedAt"=now()
+   WHERE (NOT "LegacyReviewSyncState"."pendingIncome" OR "LegacyReviewSyncState"."legacyFeeProof" IS NULL)
+     AND "LegacyReviewSyncState"."assignmentVersion"=EXCLUDED."assignmentVersion"
      AND "LegacyReviewSyncState".fingerprint=EXCLUDED.fingerprint;
    IF FOUND THEN n:=n+1;END IF;
   END IF;
@@ -122,6 +130,18 @@ BEGIN
 EXCEPTION WHEN OTHERS THEN PERFORM set_config('xiaoshu.review_transfer',prior,true);RAISE;
 END $$;
 
+-- These projected course types require a verified old fee before newly completed
+-- tasks can enter the existing positive-payment path.
+CREATE OR REPLACE FUNCTION xs_legacy_review_needs_fee(co text, task_id text) RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT EXISTS(
+  SELECT 1 FROM xs_review_task_link link
+  JOIN "CommonModel" c ON c.company=link.company AND c."modelId"=54 AND c."generalId"::text=link."appointmentId"
+  JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" AND a.szyh::text=link."studentId"
+  WHERE link.company=co AND link."taskId"=task_id AND a.dslx::text='3'
+ )
+$$;
+REVOKE ALL ON FUNCTION xs_legacy_review_needs_fee(text,text) FROM PUBLIC;
+
 CREATE OR REPLACE FUNCTION xs_legacy_review_income(co text, actor text, batch_limit integer DEFAULT 100, after_task text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE item record; m "MemoryPracticeRecord"; r "ReviewReward"; best record; expired record; outcome text; err text; n integer:=0; earned integer:=0; excluded integer:=0; amount numeric:=0; pending integer; errors jsonb:='[]'; cursor_id text:=after_task; more boolean; saved_n integer; saved_earned integer; saved_excluded integer; saved_amount numeric;
 BEGIN
@@ -136,7 +156,16 @@ BEGIN
    SELECT * INTO r FROM "ReviewReward" WHERE company=co AND "taskId"=item."taskId" FOR UPDATE;
    IF m."objectId" IS NULL OR r."objectId" IS NULL OR item.fingerprint<>md5(to_jsonb(m)::text) OR COALESCE(m."assignmentVersion",0)<>item."assignmentVersion" THEN RAISE EXCEPTION '来源同步后任务已改变'; END IF;
    IF EXISTS(SELECT 1 FROM "ReviewRewardIncome" WHERE company=co AND "objectId"=r."objectId" AND status IN ('earned','ineligible')) THEN
-    UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";n:=n+1;CONTINUE;
+    UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"legacyFeeProof"=NULL,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";n:=n+1;CONTINUE;
+   END IF;
+   IF item."legacyFeeProof" ? 'trial' THEN
+    PERFORM xs_review_resolve(co,actor,jsonb_build_array(jsonb_build_object('rewardId',r."objectId",'reasonCode','legacy_trial_no_fee','legacyTrialProof',item."legacyFeeProof"->'trial')),
+     '旧系统体验课复习不计费。原预约、学习记录和旧费用明细已自动核实,旧系统本条费用为 ¥0。',true);
+    UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"legacyFeeProof"=NULL,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";
+    excluded:=excluded+1;n:=n+1;CONTINUE;
+   END IF;
+   IF item."legacyFeeProof"->>'positive' IS DISTINCT FROM 'true' AND xs_legacy_review_needs_fee(co,item."taskId") THEN
+    RAISE EXCEPTION '原课次为旧系统体验课类型,仍需核实旧费用明细';
    END IF;
    IF r."incomeMonth"<'2026-09' OR r."completedAt">now() OR r."completionRole" IS DISTINCT FROM 'historical_unknown' OR NULLIF(r."completedBy",'') IS NOT NULL THEN RAISE EXCEPTION '不符合已确认的历史核定范围'; END IF;
    IF EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."coachId" AND concat_ws('',u."realName",u.nickname,u.username) LIKE '%自习%') THEN RAISE EXCEPTION '自习账号需单独核对,不能自动计薪'; END IF;
@@ -152,7 +181,7 @@ BEGIN
      AND t."sourceKey" LIKE 'legacy:model:60:general:%'
     LOOP
      PERFORM xs_review_resolve(co,actor,jsonb_build_array(jsonb_build_object('rewardId',expired."objectId",'reasonCode','overdue')),'旧来源重复复习核对:先排除已核实逾期副本,保留计划当天完成副本的计薪资格。',true);
-     UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=expired."taskId";
+     UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"legacyFeeProof"=NULL,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=expired."taskId";
      excluded:=excluded+1;n:=n+1;
     END LOOP;
    END IF;
@@ -172,7 +201,7 @@ BEGIN
     '旧系统来源自动核对:按已确认历史绑定老师、计划当天完成和来源课时时长口径;保留实际完成身份未知,排除逾期和重复。',true);
    SELECT * INTO best FROM "ReviewRewardIncome" WHERE company=co AND "objectId"=r."objectId";
    IF best.status='earned' THEN earned:=earned+1;amount:=amount+best.amount;ELSE excluded:=excluded+1;END IF;
-   UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";
+   UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"legacyFeeProof"=NULL,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";
    n:=n+1;
   EXCEPTION WHEN OTHERS THEN
    GET STACKED DIAGNOSTICS err=MESSAGE_TEXT;

+ 16 - 1
scripts/sql/review-reconciliation.sql

@@ -34,7 +34,7 @@ LEFT JOIN "ReviewRewardRateCorrection" rc ON rc.company=r.company AND rc."reward
 LEFT JOIN "ReviewRewardResolution" d ON d.company=r.company AND d."rewardId"=r."objectId";
 
 CREATE OR REPLACE FUNCTION xs_review_resolve(co text, actor text, items jsonb, reason text, historical_assignment boolean DEFAULT false) RETURNS jsonb LANGUAGE plpgsql AS $$
-DECLARE item jsonb; r "ReviewReward"; m "MemoryPracticeRecord"; canonical record; minutes integer; k text; outcome text; code text; snapshot jsonb; source jsonb; coach_data jsonb; coach_profile jsonb; n integer:=0; existing integer:=0; prior text:=COALESCE(current_setting('xiaoshu.review_resolution',true),'');
+DECLARE item jsonb; r "ReviewReward"; m "MemoryPracticeRecord"; canonical record; minutes integer; k text; outcome text; code text; snapshot jsonb; source jsonb; coach_data jsonb; coach_profile jsonb; fee jsonb; appointment jsonb; study jsonb; n integer:=0; existing integer:=0; prior text:=COALESCE(current_setting('xiaoshu.review_resolution',true),'');
 BEGIN
  PERFORM xs_review_manager(co,actor);
  IF length(trim(COALESCE(reason,'')))<2 OR jsonb_typeof(items) IS DISTINCT FROM 'array' OR jsonb_array_length(items) NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION '需要明确核定依据和1至100条凭据'; END IF;
@@ -58,6 +58,21 @@ BEGIN
    IF NOT FOUND THEN RAISE EXCEPTION '重复记录必须关联同一老师、同一任务的有效计薪凭据或保留凭据'; END IF;
    IF canonical."objectId" IS DISTINCT FROM (SELECT v."objectId" FROM "ReviewRewardIncome" v JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId" WHERE v.company=co AND v.status IN ('earned','historical','exception') AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10) ORDER BY (v.status='earned') DESC,(v.status='historical') DESC,v."completedAt",v."objectId" LIMIT 1) THEN RAISE EXCEPTION '必须保留已有计薪凭据或唯一优先完成凭据'; END IF;
    snapshot:=snapshot||jsonb_build_object('canonicalRewardId',canonical."objectId");
+  ELSIF code='legacy_trial_no_fee' THEN
+   source:=item->'legacyTrialProof';
+   fee:=source->'fee';appointment:=source->'appointment';study:=source->'study';
+   IF r."completionRole" IS DISTINCT FROM 'historical_unknown' OR NULLIF(r."completedBy",'') IS NOT NULL
+    OR r."completedAt" IS NULL OR r."completedAt">now()
+    OR COALESCE(fee->>'money','') !~ '^0([.]0+)?$'
+    OR COALESCE(m."sourceKey",'') IS DISTINCT FROM 'legacy:model:60:general:'||COALESCE(fee->>'GeneralID','')
+    OR NOT EXISTS(SELECT 1 FROM "CommonModel" c WHERE c.company=co AND c."modelId"=60 AND c."itemId"=m.id AND c.status=99 AND c."sourceKey"=m."sourceKey" AND c."generalId"::text=fee->>'GeneralID')
+    OR jsonb_build_array(fee->>'yhid',fee->>'plid',fee->>'xxjlid',fee->>'orderId',fee->>'kcid',fee->>'fxzt',fee->>'wcsj',fee->>'kywrq',fee->>'kywsj')
+     IS DISTINCT FROM jsonb_build_array(m.yhid::text,m.plid::text,m.xxjlid::text,m."orderId"::text,m.kcid::text,m.fxzt::text,m.wcsj::text,m.kywrq::text,m.kywsj::text)
+    OR appointment->>'GeneralID' IS DISTINCT FROM m."orderId"::text OR appointment->>'szyh' IS DISTINCT FROM m.yhid::text
+    OR appointment->>'dslx' IS DISTINCT FROM '3' OR COALESCE(trim(appointment->>'Title'),'') !~ '[((]体验课[))]$'
+    OR study->>'GeneralID' IS DISTINCT FROM m.xxjlid::text OR study->>'dsid' IS DISTINCT FROM m."orderId"::text
+   THEN RAISE EXCEPTION '体验课原预约、学习记录和零元费用未能核实'; END IF;
+   snapshot:=snapshot||jsonb_build_object('policy','legacy-trial-no-fee-v1','legacyTrialProof',source);
   ELSIF code='legacy_assignment_policy' THEN
    IF NOT historical_assignment THEN RAISE EXCEPTION '历史绑定老师核算口径未确认'; END IF;
    IF COALESCE(r."completionRole",'') IN ('student','unknown','coach') OR NULLIF(r."completedBy",'') IS NOT NULL OR r."ineligibleReason"='self_study' THEN RAISE EXCEPTION '已有实际身份凭据不能按历史口径覆盖'; END IF;

+ 2 - 0
scripts/tests/direct-nightly-sync.test.mjs

@@ -35,6 +35,8 @@ before(async()=>{
  await db.exec('CREATE TABLE "LegacySyncConflict" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,dataset text,"legacyKey" text,"conflictType" text,message text,resolved boolean,resolution text,"createdAt" timestamptz,"updatedAt" timestamptz)');
  await db.exec('CREATE VIEW "ReviewRewardIncome" AS SELECT r.*,NULL::text AS "incomeMonth" FROM "ReviewReward" r');
  await db.exec("CREATE FUNCTION xs_review_target(text,text) RETURNS void LANGUAGE plpgsql AS $$ BEGIN RETURN; END $$");
+ const lessonSource=readFileSync(new URL('../sql/lesson-records.sql',import.meta.url),'utf8');
+ await db.exec(lessonSource.match(/CREATE OR REPLACE VIEW xs_review_task_link AS[\s\S]*?;/)[0]);
  await db.exec(readFileSync(new URL('../sql/legacy-review-sync.sql',import.meta.url),'utf8'));
  const columns=await sql("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public'");
  context={Psql,Parse:{Config:{get:async()=>({get:k=>({legacyScheduleApiUrl:'https://legacy.invalid/api',legacyScheduleApiId:'test',legacyScheduleApiKey:'test'}[k])})}},fetch:async()=>({ok:true,json:async()=>({retcode:0,result:[{GeneralID:101,kcid:'5',xxcs:1}],page:{itemCount:1,pageCount:1}})}),AbortSignal,Date,console};

+ 27 - 0
scripts/tests/legacy-review-sync-policy.test.mjs

@@ -167,3 +167,30 @@ test('同一计划日的未来完成原件不能把已经有效完成的副本
  const result=await drain();assert.equal(result.earned,0);assert.equal(result.excluded,0);assert.equal((await income('a-future-valid-copy')).status,'exception');assert.equal((await income('z-future-original')).status,'historical');
  assert((await one('SELECT "pendingIncome" FROM "LegacyReviewSyncState" WHERE "taskId"=\'a-future-valid-copy\'')).pendingIncome);
 });
+
+test('已导入与刚完成的零元体验课可自动结案,正式课按原金额计薪',async()=>{
+ for(const [name,trial,tracked,minutes] of [['trial-history',true,false,60],['trial-new',true,true,60],['trial-no-proof',true,true,60],['formal-same-book',false,true,30]]){
+  const id=await task(name,{minutes});
+  if(trial)await q('UPDATE "CourseAppointment" SET dslx=\'3\' WHERE id=$1',[id]);
+  await q('UPDATE "CommonModel" SET status=99 WHERE "modelId"=60 AND "generalId"=$1',[id]);
+  await q("SELECT xs_legacy_review_track('c',$1)",[name]);
+  assert.equal((await patch(name,{fxzt:'1',wcsj:'2026-09-05 10:00'})).applied,true);
+  if(!tracked)await q('DELETE FROM "LegacyReviewSyncState" WHERE "taskId"=$1',[name]);
+  if(!trial||name==='trial-no-proof')continue;
+  const m=await snapshot(name),source={...m,GeneralID:id};
+  const proof={fee:{...source,money:'0'},appointment:{GeneralID:id,szyh:m.yhid,dslx:'3',Title:'学员(体验课)'},study:{GeneralID:id,dsid:id}};
+  const observe=async evidence=>(await one("SELECT xs_legacy_review_observe('c',$1::jsonb) n",[JSON.stringify([{...source,reviewTrialProof:evidence}])])).n;
+  assert.equal(await observe(proof),1);
+  assert.equal(await observe(proof),0);
+ }
+ const result=await drain();
+ assert.equal(result.earned,1);assert.equal(result.amount,.5);assert.equal(result.excluded,2,JSON.stringify(result.errors));
+ assert.equal((await income('formal-same-book')).status,'earned');
+ for(const name of ['trial-history','trial-new']){
+  const v=await income(name);
+  assert.equal(v.status,'ineligible');assert.equal(v.ineligibleReason,'legacy_trial_no_fee');assert.equal(Number(v.amount),0);
+  assert.equal((await one('SELECT evidence FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[v.objectId])).evidence.legacyTrialProof.fee.money,'0');
+ }
+ assert.equal((await income('trial-no-proof')).status,'historical');
+ assert.equal((await drain()).earned,0);
+});

+ 24 - 0
scripts/tests/legacy-review-sync-runner.test.mjs

@@ -16,6 +16,7 @@ test('旧费用页完整且逐字段匹配的正金额记录才允许补进队
   Psql:{query:async()=>rows.map(row=>({generalId:row.GeneralID,coachId:row.plid}))},
   console:{warn:message=>warnings.push(message)},
   directOld:async(action,{uid,cpage})=>{
+   if(action==='content_list')return {items:[],page:{itemCount:0,pageCount:0}};
    assert.equal(action,'e_get_21list');
    if(uid===31)return {items:[{...rows[4],money:1}],page:{pageCount:1,itemCount:2}};
    return cpage===1?{items:[{...rows[0],money:1},{...rows[1],money:0},{...rows[2],money:1}],page:{pageCount:2,itemCount:5}}:
@@ -50,3 +51,26 @@ test('所有课次同步后分页核定工资,待核定原因进入回执,
  assert(order.lastIndexOf(59)<order.indexOf('review-income'));assert(order.indexOf('review-income')<order.indexOf('finance-members'));
  }finally{globalThis.fetch=original;await rm(root,{recursive:true,force:true});}
 });
+
+test('体验课零元需要精确费用、原预约和学习记录,正式课正金额沿用原验证',async()=>{
+ const control=readFileSync(new URL('../cloud/legacy-direct-control.js',import.meta.url),'utf8');
+ const body=control.slice(control.indexOf('async function directReviewFeeProof('),control.indexOf('async function directControl('));
+ const rows=[80,81,82,83,84].map((orderId,i)=>({GeneralID:i+1,plid:30,yhid:20,xxjlid:90+i,orderId,kcid:70,fxzt:'1',wcsj:'2026-10-13 10:00',kywrq:'2026-10-13',kywsj:'2026-10-13 09:00'}));
+ const page=items=>({items,page:{itemCount:items.length,pageCount:1}});
+ const run=vm.runInNewContext(body+';directReviewFeeProof',{
+  Psql:{query:async()=>rows.map(row=>({generalId:row.GeneralID,coachId:30}))},
+  directOld:async(action,args)=>{
+   if(action==='e_get_21list')return page(rows.map((row,i)=>({...row,money:['0','1','0','',null][i]})));
+   const ids=args.ids.split(',').map(Number);
+   if(args.modelId===54)return page(rows.map((row,i)=>({GeneralID:row.orderId,szyh:20,dslx:i===2?'2':'3',Title:'学员(体验课)'})).filter(row=>ids.includes(row.GeneralID)));
+   if(args.modelId===56)return page(rows.map(row=>({GeneralID:row.xxjlid,dsid:row.orderId})).filter(row=>ids.includes(row.GeneralID)));
+   throw Error('unexpected source');
+  },
+ });
+ const result=await run('c',rows);
+ assert.equal(result[0].reviewTrialProof?.fee.money,'0');
+ assert.equal(result[0].reviewTrialProof?.appointment.GeneralID,80);
+ assert.equal(result[0].reviewTrialProof?.study.GeneralID,90);
+ assert.deepEqual(Array.from(result,row=>row.reviewFeeVerified),[false,true,false,false,false]);
+ assert.deepEqual(Array.from(result,row=>Boolean(row.reviewTrialProof)),[true,false,false,false,false]);
+});