legacy-direct-control.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. async function directLease(db,p,co) {
  2. const r=await db.oneOrNone('UPDATE "LegacyDirectRun" SET "leaseUntil"=now()+interval \'5 minutes\' WHERE company=$1 AND "runId"=$2 AND owner=$3 AND status=\'running\' AND "leaseUntil">now() RETURNING "runId"',[co,p.runId,p.owner]);
  3. if(!r)throw Error('sync_lease_unavailable');
  4. }
  5. async function directBaseline(t,co,m,gid,row) {
  6. await t.none('INSERT INTO "LegacyDirectBaseline" (company,model,gid,fingerprint) VALUES($1,$2,$3,md5($4::jsonb::text)) ON CONFLICT(company,model,gid) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,"updatedAt"=now()',[co,m,gid,JSON.stringify([row.common,row.addon])]);
  7. }
  8. async function directUnchanged(t,co,m,gid,c,a,fallback) {
  9. const snapshot=JSON.stringify([c,a]);
  10. let priorSnapshot=null;
  11. if(m===60&&a.assignmentVersion===null){
  12. const {assignmentVersion,...legacyAddon}=a;
  13. priorSnapshot=JSON.stringify([c,legacyAddon]);
  14. }
  15. const r=await t.oneOrNone('SELECT (fingerprint=md5($4::jsonb::text) OR COALESCE(fingerprint=md5($5::jsonb::text),false)) matches FROM "LegacyDirectBaseline" WHERE company=$1 AND model=$2 AND gid=$3',[co,m,gid,snapshot,priorSnapshot]);
  16. return r?r.matches:fallback(c)&&fallback(a);
  17. }
  18. async function directOld(action,args={},timeout=60000) {
  19. const config=await Parse.Config.get({useMasterKey:true}),url=config.get('legacyScheduleApiUrl'),apiId=config.get('legacyScheduleApiId'),apiKey=config.get('legacyScheduleApiKey');
  20. if(!url||!apiId||!apiKey)throw Error('direct_api_not_configured');
  21. const query=Object.entries({action,...args,apiId,apiKey}).map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(String(v))).join('&');
  22. let j;
  23. try {const r=await fetch(url+(url.includes('?')?'&':'?')+query,{signal:AbortSignal.timeout(timeout)});if(!r.ok)throw Error();j=await r.json();} catch {throw Error('legacy_read_failed');}
  24. if(Number(j.retcode)===-1)throw Error('legacy_read_rejected');
  25. const items=typeof j.result==='string'?JSON.parse(j.result):j.result;
  26. if(!Array.isArray(items))throw Error('legacy_invalid_result');return {items,page:j.page||{}};
  27. }
  28. async function directReviewFeeProof(co,rows,includePending=false) {
  29. const ids=rows.filter(row=>String(row.fxzt)==='1').map(row=>Number(row.GeneralID??row.generalId));
  30. if(!ids.length)return rows;
  31. const candidates=await Psql.query(`SELECT c."generalId",r."coachId"
  32. FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId"
  33. JOIN "ReviewReward" r ON r.company=m.company AND r."taskId"=m."objectId"
  34. JOIN "ReviewRewardIncome" v ON v.company=r.company AND v."objectId"=r."objectId"
  35. LEFT JOIN "LegacyReviewSyncState" s ON s.company=m.company AND s."taskId"=m."objectId"
  36. WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"=ANY($2::numeric[]) AND m.fxzt='1'
  37. AND ($3 OR NOT COALESCE(s."pendingIncome",false)) AND v.status IN ('historical','exception')
  38. AND (v."incomeMonth">='2026-09' OR xs_legacy_review_needs_fee(m.company,m."objectId"))
  39. AND r."completionRole"='historical_unknown'`,[co,ids,includePending]);
  40. const verified=new Set(),zeros=new Map(),trials=new Map(),fields=['yhid','plid','xxjlid','orderId','kcid','fxzt','wcsj','kywrq','kywsj'];
  41. const deadline=Date.now()+15000;
  42. for(const coach of new Set(candidates.map(row=>Number(row.coachId)))){
  43. const coachIds=new Set(candidates.filter(row=>Number(row.coachId)===coach).map(row=>Number(row.generalId)));
  44. const income=new Map();
  45. let total, count, read=0;
  46. try{
  47. for(let page=1;;page++){
  48. const remaining=deadline-Date.now();
  49. if(remaining<=0)throw Error('legacy_review_income_proof_timeout');
  50. const result=await directOld('e_get_21list',{uid:coach,status:1,shouru:1,cpage:page,psize:1000},Math.min(6000,remaining));
  51. const items=Number(result.page.itemCount),pages=Math.max(1,Number(result.page.pageCount));
  52. total??=items;count??=pages;
  53. if(!Number.isSafeInteger(total)||!Number.isSafeInteger(count)||count>100||items!==total||pages!==count)throw Error('legacy_review_income_page_changed');
  54. read+=result.items.length;
  55. for(const row of result.items){
  56. const id=Number(row.GeneralID??row.generalId);
  57. if(coachIds.has(id))income.set(id,income.has(id)?null:row);
  58. }
  59. if(page>=count)break;
  60. }
  61. if(read!==total)throw Error('legacy_review_income_page_incomplete');
  62. for(const row of rows){
  63. const id=Number(row.GeneralID??row.generalId),fee=income.get(id);
  64. if(!fee||Number(row.plid)!==coach||!fields.every(k=>String(fee[k]??'')===String(row[k]??'')))continue;
  65. if(Number(fee.money)>0)verified.add(id);
  66. if(/^0(?:[.]0+)?$/.test(String(fee.money)))zeros.set(id,fee);
  67. }
  68. }catch {
  69. // The old fee API can fail for individual accounts. Keep syncing the source page and retry on the next run.
  70. console.warn(JSON.stringify({event:'legacy_review_income_proof_unavailable',coach}));
  71. }
  72. }
  73. if(zeros.size){
  74. const zeroRows=rows.filter(row=>zeros.has(Number(row.GeneralID??row.generalId)));
  75. try{
  76. const [appointments,studies]=await Promise.all([
  77. directReviewSourceById(54,zeroRows.map(row=>row.orderId)),
  78. directReviewSourceById(56,zeroRows.map(row=>row.xxjlid)),
  79. ]);
  80. for(const row of zeroRows){
  81. const id=Number(row.GeneralID??row.generalId),appointment=appointments.get(Number(row.orderId)),study=studies.get(Number(row.xxjlid));
  82. if(!appointment||String(appointment.dslx)!=='3'||!/[((]体验课[))]$/.test(String(appointment.Title??'').trim())||
  83. String(appointment.szyh)!==String(row.yhid)||!study||String(study.dsid)!==String(row.orderId))continue;
  84. const pick=(value,keys)=>Object.fromEntries(keys.map(key=>[key,value[key]]));
  85. trials.set(id,{fee:pick(zeros.get(id),['GeneralID','money',...fields]),
  86. appointment:pick(appointment,['GeneralID','Title','szyh','dslx']),study:pick(study,['GeneralID','dsid'])});
  87. }
  88. }catch{console.warn(JSON.stringify({event:'legacy_review_trial_source_unavailable'}));}
  89. }
  90. return rows.map(row=>{
  91. const id=Number(row.GeneralID??row.generalId);
  92. return {...row,reviewFeeVerified:verified.has(id),reviewTrialProof:trials.get(id)??null};
  93. });
  94. }
  95. async function directReviewSourceById(modelId,values) {
  96. const ids=[...new Set(values.map(Number).filter(id=>Number.isSafeInteger(id)&&id>0))];
  97. const result=new Map(),deadline=Date.now()+15000;
  98. for(let i=0;i<ids.length;i+=75){
  99. const batch=ids.slice(i,i+75),remaining=deadline-Date.now();
  100. if(remaining<=0)throw Error('legacy_review_source_timeout');
  101. const {items,page}=await directOld('content_list',{modelId,ids:batch.join(','),cpage:1,psize:100},Math.min(6000,remaining));
  102. const pages=Number(page.pageCount);
  103. if(Number(page.itemCount)!==items.length||!Number.isSafeInteger(pages)||pages<0||pages>1)throw Error('legacy_review_source_incomplete');
  104. for(const item of items){
  105. const id=Number(item.GeneralID);
  106. if(!batch.includes(id))throw Error('legacy_review_source_mismatch');
  107. result.set(id,result.has(id)?null:item);
  108. }
  109. }
  110. return result;
  111. }
  112. async function directPendingTrialProof(co,afterTaskId) {
  113. // A task can become complete during projection, after its first source-page read.
  114. const rows=await Psql.query(`SELECT c."generalId" FROM "LegacyReviewSyncState" s
  115. JOIN "MemoryPracticeRecord" m ON m.company=s.company AND m."objectId"=s."taskId"
  116. JOIN "CommonModel" c ON c.company=m.company AND c."modelId"=60 AND c."itemId"=m.id
  117. WHERE s.company=$1 AND s."taskId" IN (
  118. SELECT "taskId" FROM "LegacyReviewSyncState" WHERE company=$1 AND "pendingIncome" AND "taskId">$2 ORDER BY "taskId" LIMIT 100
  119. ) AND s."legacyFeeProof" IS NULL AND xs_legacy_review_needs_fee(m.company,m."objectId")`,[co,afterTaskId]);
  120. if(!rows.length)return [];
  121. try{
  122. const source=await directReviewSourceById(60,rows.map(row=>row.generalId));
  123. return await directReviewFeeProof(co,[...source.values()].filter(Boolean),true);
  124. }catch{
  125. console.warn(JSON.stringify({event:'legacy_review_pending_trial_source_unavailable'}));
  126. return [];
  127. }
  128. }
  129. async function directControl(p,co,u) {
  130. const specs=__SPECS__;
  131. if(p.action==='status')return {version:'2026-11-02-review-source-v1',reviewSyncVersion:'source-owned-v1',source:'old-api/WXAPP',bridgeUsed:false,runs:await Psql.query('SELECT "businessDate","runId",status,"startedAt","finishedAt",summary FROM "LegacyDirectRun" WHERE company=$1 ORDER BY "startedAt" DESC LIMIT 3',[co])};
  132. if(p.action==='claim')return Psql.tx(async t=>{
  133. await t.any('SELECT pg_advisory_xact_lock(hashtext($1))',['direct-sync:'+co]);
  134. const today=(await t.one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS business_day,to_char((now() AT TIME ZONE 'Asia/Shanghai')-interval '1 day','YYYY-MM-DD') AS yesterday"));
  135. const day=p.targetBusinessDate||today.business_day;if(![today.business_day,today.yesterday].includes(day))throw Error('invalid_business_date');
  136. const latest=await t.oneOrNone('SELECT * FROM "LegacyDirectRun" WHERE company=$1 AND status=\'running\' ORDER BY "startedAt" DESC LIMIT 1',[co]);
  137. if(latest&&latest.owner!==p.owner&&new Date(latest.leaseUntil).getTime()>Date.now())throw Error('another_sync_running');
  138. const existing=latest||await t.oneOrNone('SELECT * FROM "LegacyDirectRun" WHERE company=$1 AND "businessDate"=$2',[co,day]);
  139. if(existing&&existing.status!=='running')return {alreadyCompleted:true,...existing};
  140. if(!/^[a-zA-Z0-9-]{10,80}$/.test(p.owner||'')||!/^[a-zA-Z0-9-]{10,80}$/.test(p.proposedRunId||''))throw Error('invalid_run_identity');
  141. if(existing){await t.none('UPDATE "LegacyDirectRun" SET owner=$2,"leaseUntil"=now()+interval \'5 minutes\' WHERE "runId"=$1',[existing.runId,p.owner]);return {runId:existing.runId,businessDate:existing.businessDate,resumed:true};}
  142. await t.none('INSERT INTO "LegacyDirectRun" (company,"businessDate","runId",owner,"leaseUntil") VALUES($1,$2,$3,$4,now()+interval \'5 minutes\')',[co,day,p.proposedRunId,p.owner]);return {runId:p.proposedRunId,businessDate:day,resumed:false};
  143. });
  144. 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;}
  145. await directLease(Psql,p,co);
  146. if(p.action==='review-income'){
  147. if(!/^review-income:[A-Za-z0-9:._-]{1,140}$/.test(p.batchKey||''))throw Error('Invalid review income batch');
  148. const afterTaskId=String(p.afterTaskId||'');
  149. const proof=await directPendingTrialProof(co,afterTaskId);
  150. return Psql.tx(async t=>{
  151. await directLease(t,p,co);
  152. const fingerprint=(await t.one('SELECT md5($1) value',[JSON.stringify(['review-income',String(p.afterTaskId||'')])])).value;
  153. 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;}
  154. if(proof.length){
  155. await t.none('LOCK TABLE "MemoryPracticeRecord","ReviewReward" IN SHARE ROW EXCLUSIVE MODE');
  156. await t.one('SELECT xs_legacy_review_observe($1,$2::jsonb)',[co,JSON.stringify(proof)]);
  157. }
  158. const result=(await t.one('SELECT xs_legacy_review_income($1,$2,100,$3) result',[co,u.id,afterTaskId])).result;
  159. 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;
  160. });
  161. }
  162. 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]);
  163. if(p.action==='finance'){
  164. const result=await importLegacyFinance(co,Number(p.studentId),p.runId,args=>directOld('user_point_list',args));
  165. result.balances=await reconcileLegacyCreditBalances(co,Number(p.studentId),p.runId);return result;
  166. }
  167. if(p.action==='source'){
  168. const m=Number(p.modelId);if(!specs[m])throw Error('invalid_model');
  169. if(p.id){if(!Number.isSafeInteger(p.id)||p.id<1)throw Error('invalid_id');return directOld('content_get',{id:p.id});}
  170. if(!Number.isSafeInteger(p.page)||p.page<1||p.page>10000)throw Error('invalid_page');
  171. const result=await directOld('content_list',{modelId:m,psize:p.countOnly?1:1000,cpage:p.page,...(m===2?{nid:3}:{})});
  172. if(!p.countOnly){const ids=result.items.map(x=>Number(x.GeneralID??x.generalId));if(ids.some(id=>!Number.isSafeInteger(id)||id<1))throw Error('invalid_source_id');
  173. if(m===60)await Psql.one('SELECT xs_legacy_review_observe($1,$2::jsonb)',[co,JSON.stringify(await directReviewFeeProof(co,result.items))]);
  174. await Psql.none('INSERT INTO "LegacyDirectSourcePage" (company,"runId",model,page,ids,total,checksum) VALUES($1,$2,$3,$4,$5::jsonb,$6,md5($7::jsonb::text)) ON CONFLICT(company,"runId",model,page) DO UPDATE SET ids=EXCLUDED.ids,total=EXCLUDED.total,checksum=EXCLUDED.checksum',[co,p.runId,m,p.page,JSON.stringify(ids),Number(result.page.itemCount??ids.length),JSON.stringify(result.items)]);
  175. }return result;
  176. }
  177. if(p.action==='supplement')return directSupplement(p,co);
  178. if(p.action==='conflicts'){
  179. if(!Array.isArray(p.rows)||p.rows.length>100)throw Error('invalid_conflicts');
  180. return Psql.tx(async t=>{await directLease(t,p,co);for(const c of p.rows){if(!specs[Number(c.modelId)]||!Number.isSafeInteger(c.generalId))throw Error('invalid_conflict');
  181. const source='direct-api:'+c.modelId+':'+c.generalId;
  182. await t.none('INSERT INTO "LegacySyncConflict" ("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") SELECT substr(md5($1),1,20),$2,$1,$3,$4,\'direct-api-preserve-new\',$5,false,\'保留新系统,待人工复核\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$2 AND "sourceKey"=$1)',[source,co,String(c.modelId),String(c.generalId),JSON.stringify({fields:c.fields||[],reason:c.reason||'保留新系统'})]);
  183. }return {saved:p.rows.length};});
  184. }
  185. if(p.action==='verify'){
  186. const m=Number(p.modelId),s=specs[m];if(!s||!Array.isArray(p.ids)||p.ids.length>10000||p.ids.some(x=>!Number.isSafeInteger(x)||x<1))throw Error('invalid_verification');
  187. return Psql.query('SELECT c."generalId",count(*)::int common_rows,count(a."objectId")::int addon_rows FROM "CommonModel" c LEFT JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId"=ANY($3::numeric[]) GROUP BY c."generalId" ORDER BY c."generalId"',[co,m,p.ids]);
  188. }
  189. if(p.action==='evidence'){
  190. const m=Number(p.modelId),s=specs[m];if(!s)throw Error('invalid_evidence_model');
  191. const latest=await directOld('content_list',{modelId:m,psize:1,cpage:1,...(m===2?{nid:3}:{})});
  192. return Psql.tx(async t=>{
  193. await directLease(t,p,co);
  194. const pages=await t.any('SELECT page,ids,total,checksum FROM "LegacyDirectSourcePage" WHERE company=$1 AND "runId"=$2 AND model=$3 ORDER BY page',[co,p.runId,m]);
  195. const ids=[...new Set(pages.flatMap(r=>r.ids))].sort((a,b)=>a-b),total=Number(latest.page.itemCount??latest.items.length);
  196. if(!pages.length||ids.length!==total||pages.some((r,i)=>Number(r.page)!==i+1||Number(r.total)!==total)||pages.length!==Math.max(1,Math.ceil(total/1000)))throw Error('source_shifted_restart_dataset');
  197. const rows=await t.any('SELECT c."generalId",count(*)::int common_rows,count(a."objectId")::int addon_rows FROM "CommonModel" c LEFT JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId" IN (SELECT jsonb_array_elements_text($3::jsonb)::numeric) GROUP BY c."generalId" ORDER BY c."generalId"',[co,m,JSON.stringify(ids)]);
  198. if(rows.length!==ids.length||rows.some(r=>r.common_rows!==1||r.addon_rows!==1))throw Error('server_projection_reconciliation_failed');
  199. const checks=await t.one('SELECT md5($1) source,md5($2) target,md5($3) content',[ids.join('\n'),rows.map(r=>Number(r.generalId)).join('\n'),pages.map(r=>r.checksum).join('\n')]);
  200. if(checks.source!==checks.target)throw Error('server_id_checksum_failed');
  201. await t.none('INSERT INTO "LegacyDirectEvidence" (company,"runId",model,ids,"sourceCount","sourceChecksum","targetChecksum",conflicts,"contentChecksum") VALUES($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9) ON CONFLICT(company,"runId",model) DO UPDATE SET ids=EXCLUDED.ids,"sourceCount"=EXCLUDED."sourceCount","sourceChecksum"=EXCLUDED."sourceChecksum","targetChecksum"=EXCLUDED."targetChecksum",conflicts=EXCLUDED.conflicts,"contentChecksum"=EXCLUDED."contentChecksum"',[co,p.runId,m,JSON.stringify(ids),ids.length,checks.source,checks.target,Number(p.conflicts||0),checks.content]);
  202. return {modelId:m,sourceCount:ids.length,sourceChecksum:checks.source,targetChecksum:checks.target,sourcePageContentChecksum:checks.content,reconciled:true};
  203. });
  204. }
  205. if(p.action==='reset-dataset'){if(!specs[Number(p.modelId)])throw Error('invalid_model');await Psql.none('DELETE FROM "LegacyDirectSourcePage" WHERE company=$1 AND "runId"=$2 AND model=$3',[co,p.runId,p.modelId]);await Psql.none('DELETE FROM "LegacyDirectEvidence" WHERE company=$1 AND "runId"=$2 AND model=$3',[co,p.runId,p.modelId]);return {reset:true};}
  206. if(p.action==='finish')return Psql.tx(async t=>{
  207. await directLease(t,p,co);
  208. const expected=[2,52,58,56,53,60,54,59,61],d=p.summary?.datasets;
  209. if(!Array.isArray(d)||d.length!==expected.length||!expected.every(m=>d.some(x=>x.modelId===m&&x.reconciled&&x.failed===0)))throw Error('reconciliation_incomplete');
  210. // Recompute ID coverage on the server, using the actual run's captured IDs, before a success receipt.
  211. const evidence=await t.any('SELECT model,ids,"sourceCount","sourceChecksum","targetChecksum" FROM "LegacyDirectEvidence" WHERE company=$1 AND "runId"=$2',[co,p.runId]);
  212. if(evidence.length!==expected.length)throw Error('server_reconciliation_missing');
  213. for(const e of evidence){const s=specs[e.model],r=await t.one('SELECT count(*)::int n FROM (SELECT c."generalId" FROM "CommonModel" c JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId" IN (SELECT jsonb_array_elements_text($3::jsonb)::numeric) GROUP BY c."generalId" HAVING count(*)=1) x',[co,e.model,JSON.stringify(e.ids)]);if(r.n!==Number(e.sourceCount)||e.sourceChecksum!==e.targetChecksum)throw Error('server_id_reconciliation_failed');}
  214. const totals=await t.any('SELECT (result->>\'modelId\')::int model,COALESCE(sum((result->>\'createdCommon\')::int),0)::int created,COALESCE(sum((result->>\'createdAddon\')::int),0)::int addon,COALESCE(sum((result->>\'updated\')::int),0)::int updated FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 GROUP BY (result->>\'modelId\')::int',[co,p.runId]);
  215. for(const x of d){const total=totals.find(t=>t.model===x.modelId);x.created=total?.created||0;x.createdAddon=total?.addon||0;x.updated=total?.updated||0;}
  216. const members=await t.any('SELECT "legacyUserId" id FROM "_User" WHERE company=$1 AND "identityType"=\'member\' AND COALESCE("isDeleted",false)=false AND ("sourceKey" LIKE \'legacy%\' OR "sourceKey" LIKE \'[["UserID",%\') AND COALESCE("legacyUserData"->>\'SyncExcluded\',\'false\')<>\'true\'',[co]);
  217. const finance=await t.any('SELECT "studentId",account,ids,checksum FROM "LegacyFinanceReceipt" WHERE company=$1 AND "runId"=$2',[co,p.runId]);
  218. if(members.some(m=>finance.filter(f=>Number(f.studentId)===Number(m.id)).length!==4))throw Error('finance_reconciliation_incomplete');
  219. for(const [account,table] of [['Purse','UserExpDomP'],['SilverCoin','UserSIcon'],['UserExp','UserExpHis'],['UserPoint','UserUserPoint']]){
  220. const mismatch=await t.one('SELECT count(*)::int n FROM "LegacyFinanceReceipt" r WHERE r.company=$1 AND r."runId"=$2 AND r.account=$3 AND (jsonb_array_length(r.ids)<>(SELECT count(*) FROM "'+table+'" l WHERE l.company=r.company AND l."userId"=r."studentId" AND l."legacyFinanceId" IN (SELECT jsonb_array_elements_text(r.ids))) OR r.checksum<>(SELECT md5(COALESCE(jsonb_agg(jsonb_build_object(\'id\',l."legacyFinanceId",\'hash\',l."legacyFinanceHash") ORDER BY l."legacyFinanceId"::numeric),\'[]\'::jsonb)::text) FROM "'+table+'" l WHERE l.company=r.company AND l."userId"=r."studentId" AND l."legacyFinanceId" IN (SELECT jsonb_array_elements_text(r.ids))))',[co,p.runId,account]);if(mismatch.n)throw Error('finance_postimage_changed');
  221. }
  222. const balanceReceipts=await t.any('SELECT "studentId",result FROM "LegacyCreditBalanceReceipt" WHERE company=$1 AND "runId"=$2',[co,p.runId]);
  223. if(members.some(m=>!balanceReceipts.some(b=>Number(b.studentId)===Number(m.id))))throw Error('balance_reconciliation_incomplete');
  224. const balances={members:balanceReceipts.length,applied:balanceReceipts.reduce((n,b)=>n+Number(b.result.applied||0),0),conflicts:balanceReceipts.reduce((n,b)=>n+Number(b.result.conflicts||0),0)};
  225. const summary={...p.summary,finance:{members:members.length,streams:finance.length,rows:finance.reduce((n,f)=>n+f.ids.length,0),checksum:(await t.one('SELECT md5($1::jsonb::text) hash',[JSON.stringify(finance.sort((a,b)=>(a.studentId+":"+a.account).localeCompare(b.studentId+":"+b.account)))])).hash,reconciled:true,balances},createdBusinessRecords:d.reduce((n,x)=>n+x.created,0),refreshedBusinessRecords:d.reduce((n,x)=>n+x.updated,0),completedAt:new Date().toISOString(),source:'old-api/WXAPP',bridgeUsed:false,continuousSync:false,settlementReady:false,nightlyAutomation:true};
  226. const income=await t.one('SELECT COALESCE(sum((result->>\'earned\')::int),0)::int earned,COALESCE(sum((result->>\'excluded\')::int),0)::int excluded,COALESCE(sum((result->>\'amount\')::numeric),0) amount FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND result->>\'modelId\'=\'0\'',[co,p.runId]);
  227. const pending=await t.one('SELECT count(*)::int n FROM "LegacyReviewSyncState" WHERE company=$1 AND "pendingIncome"',[co]);
  228. summary.reviewIncome={...income,amount:Number(income.amount),pending:pending.n,errors:await t.any('SELECT \"taskId\",\"lastError\" AS reason FROM \"LegacyReviewSyncState\" WHERE company=$1 AND \"pendingIncome\" ORDER BY \"taskId\" LIMIT 25',[co])};
  229. summary.conflicts=d.reduce((n,x)=>n+Number(x.conflicts||0),0)+Number(summary.accounts?.conflicts||0)+pending.n+balances.conflicts;
  230. summary.status=summary.conflicts?'completed-with-preserved-conflicts':'completed';
  231. await t.none('UPDATE "LegacyDirectRun" SET status=$3,summary=$4::jsonb,"finishedAt"=now(),"leaseUntil"=now() WHERE company=$1 AND "runId"=$2',[co,p.runId,summary.status,JSON.stringify(summary)]);
  232. await t.none('INSERT INTO "SysLog" ("objectId",company,"sourceKey",type1,type2,type3,detail,"createdAt","updatedAt") VALUES(substr(md5($1),1,20),$2,$1,\'migration\',\'legacy-direct-sync-completed\',\'nightly\',$3,now(),now())',['direct-nightly:'+p.runId,co,JSON.stringify({actor:u.id,action:'legacy-direct-sync-completed',after:summary})]);
  233. return summary;
  234. });
  235. throw Error('invalid_control_action');
  236. }