// Only confirmed absences in the published legacy appointment catalog are cancelled. // Keep the appointment row, balances, reservations and teaching evidence intact. async function appointmentDeletionOld(args) { const config=await Parse.Config.get({useMasterKey:true}),url=config.get('legacyScheduleApiUrl'),apiId=config.get('legacyScheduleApiId'),apiKey=config.get('legacyScheduleApiKey'); if(!url||!apiId||!apiKey)throw Error('legacy_api_not_configured'); let response,payload; try{response=await fetch(url+(url.includes('?')?'&':'?')+new URLSearchParams({action:'content_list',modelId:'54',...args,apiId,apiKey}),{signal:AbortSignal.timeout(20000)});payload=await response.json();}catch{throw Error('legacy_appointment_read_failed');} if(!response.ok||![0,1].includes(Number(payload.retcode)))throw Error('legacy_appointment_read_rejected'); let items=payload.result;try{if(typeof items==='string')items=JSON.parse(items);}catch{throw Error('invalid_legacy_appointments');} const total=Number(payload.page?.itemCount),pageCount=Number(payload.page?.pageCount); if(!Array.isArray(items)||!Number.isSafeInteger(total)||total<0||!Number.isSafeInteger(pageCount)||pageCount<0)throw Error('invalid_legacy_pagination'); const ids=items.map(row=>Number(row.GeneralID??row.generalId)); if(ids.some(id=>!Number.isSafeInteger(id)||id<1)||new Set(ids).size!==ids.length)throw Error('invalid_legacy_ids'); return{ids,total,pageCount}; } async function appointmentDeletionSnapshot(db,co,runId) { const pages=await db.any('SELECT page,ids,total,"capturedAt" FROM "LegacyDirectSourcePage" WHERE company=$1 AND "runId"=$2 AND model=54 ORDER BY page',[co,runId]); const total=Number(pages[0]?.total),ids=pages.flatMap(page=>page.ids); if(!pages.length||!Number.isSafeInteger(total)||total<1||pages.length!==Math.ceil(total/1000)||pages.some((page,i)=>Number(page.page)!==i+1||Number(page.total)!==total||Date.now()-Date.parse(page.capturedAt)>30*60000)||ids.length!==total||new Set(ids).size!==total)throw Error('incomplete_appointment_snapshot'); return{ids,total,canary:ids[0]}; } function appointmentDeletionImported(row) { return typeof row?.sourceKey==='string'&&(/^legacy:model:54:general:\d+$/.test(row.sourceKey)||/\[\["(?:ID|GeneralID)",\d+\]\]/.test(row.sourceKey)); } function appointmentDeletionUnmodified(row) { const updated=Date.parse(row?.updatedAt); return appointmentDeletionImported(row)&&!row.teachingManaged&&(row.createdAt===row.updatedAt||!row.updatedAt||updatedupdated>=a&&updated<=b)); } async function appointmentDeletionConflict(db,co,gid,reason) { const key='legacy-appointment-deletion:'+gid; await db.none('INSERT INTO "LegacySyncConflict" ("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") SELECT substr(md5($1),1,20),$2,$1,\'54\',$3,\'legacy-deleted-preserve-new\',$4,false,\'保留新系统,待人工复核\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$2 AND "sourceKey"=$1)',[key,co,String(gid),reason]); } async function handler(request,response) { try { const user=request.user;if(!user)return response.status(401).json({success:false});await user.fetch({useMasterKey:true}); const co=__COMPANY_ID__;if(user.get('adminRoleKey')!=='super-admin'||user.get('isDisabled')||user.get('isDeleted')||user.get('company')?.id!==co)return response.status(403).json({success:false}); const p=request.body?.params||{}; if(p.action==='status')return response.json({success:true,data:{version:'confirmed-ids-v1'}}); if(!/^[a-f0-9-]{36}$/.test(p.runId||''))throw Error('invalid_deletion_run'); const sourceRunId=p.sourceRunId||p.runId;if(!/^[a-f0-9-]{36}$/.test(sourceRunId))throw Error('invalid_snapshot_run'); if(p.action==='source') { if(sourceRunId!==p.runId||!Number.isSafeInteger(p.page)||p.page<1||p.page>1000)throw Error('invalid_snapshot_page'); const data=await appointmentDeletionOld({cpage:String(p.page),psize:'1000'}); if(data.ids.length!==Math.min(1000,Math.max(0,data.total-(p.page-1)*1000)))throw Error('partial_appointment_page'); await Psql.none('INSERT INTO "LegacyDirectSourcePage" (company,"runId",model,page,ids,total,checksum) VALUES($1,$2,54,$3,$4::jsonb,$5,md5($4::jsonb::text)) ON CONFLICT(company,"runId",model,page) DO UPDATE SET ids=EXCLUDED.ids,total=EXCLUDED.total,checksum=EXCLUDED.checksum,"capturedAt"=now()',[co,p.runId,p.page,JSON.stringify(data.ids),data.total]); return response.json({success:true,data:{total:data.total,pages:Math.max(1,data.pageCount),count:data.ids.length}}); } if(p.action!=='reconcile')throw Error('invalid_deletion_action'); const after=Number(p.after||0);if(!Number.isSafeInteger(after)||after<0)throw Error('invalid_deletion_cursor'); const snapshot=await appointmentDeletionSnapshot(Psql,co,sourceRunId); const key='appointment-deletion:'+sourceRunId+':'+after+':'+(p.dryRun?'preview':'apply'); const prior=await Psql.oneOrNone('SELECT result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,key]); if(prior)return response.json({success:true,data:prior.result}); const rows=await Psql.any('SELECT c."generalId",to_jsonb(c) AS common,to_jsonb(a) AS addon FROM "CommonModel" c LEFT JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=54 AND c.status=99 AND c."generalId">$2 AND (c."sourceKey" LIKE \'legacy:model:54:general:%\' OR c."sourceKey" LIKE \'[["ID",%\' OR c."sourceKey" LIKE \'[["GeneralID",%\') AND c."generalId"<>ALL($3::numeric[]) ORDER BY c."generalId" LIMIT 50',[co,after,snapshot.ids]); if(new Set(rows.map(row=>Number(row.generalId))).size!==rows.length)throw Error('duplicate_appointment_identity'); // A positive control detects a rejected/empty catalog; the same ID filter verifies // every candidate. Errors, malformed pages and partial results never imply deletion. const canary=await appointmentDeletionOld({ids:String(snapshot.canary),cpage:'1',psize:'1000'}); if(canary.total!==1||canary.ids.length!==1||canary.ids[0]!==snapshot.canary)throw Error('legacy_positive_control_failed'); const ids=rows.map(row=>Number(row.generalId)); const live=ids.length?await appointmentDeletionOld({ids:ids.join(','),cpage:'1',psize:'1000'}):{ids:[],total:0,pageCount:0}; if(live.total!==live.ids.length||live.pageCount>1||live.ids.some(id=>!ids.includes(id)))throw Error('unverified_appointment_absence'); const present=new Set(live.ids); const result=await Psql.tx(async db=>{ await db.any("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[co]); const receipt=await db.oneOrNone('SELECT result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,key]);if(receipt)return receipt.result; const out={scanned:rows.length,present:present.size,cancelled:0,wouldCancel:0,conflicts:0,after:ids.at(-1)||after,hasMore:rows.length===50,changes:[]}; for(const row of rows){ const gid=Number(row.generalId);if(present.has(gid))continue; const c=(await db.oneOrNone('SELECT to_jsonb(c) AS data FROM "CommonModel" c WHERE company=$1 AND "objectId"=$2 FOR UPDATE',[co,row.common.objectId]))?.data; if(!c||Number(c.status)===-2)continue; const a=(await db.oneOrNone('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a WHERE company=$1 AND id=$2 FOR UPDATE',[co,c.itemId]))?.data; let reason=''; if(!a||!appointmentDeletionImported(c)||!appointmentDeletionImported(a))reason='来源或主附表不完整'; else if(JSON.stringify(c)!==JSON.stringify(row.common)||JSON.stringify(a)!==JSON.stringify(row.addon))reason='核对期间新系统已有修改'; else if(c.teachingManaged||a.teachingManaged||a.bindingId||a.creditHoldStatus||a.courseCategoryKey||a.payStatus||Number(a.dszt||0)!==0)reason='保留新系统排课、课时预留、上课或计薪凭据'; else if(await db.oneOrNone('SELECT "objectId" FROM "LessonRecord" WHERE company=$1 AND yyds::text=ANY($2::text[]) LIMIT 1',[co,[String(gid),String(a.id)]]))reason='已有上课记录'; else { const baseline=await db.oneOrNone('SELECT fingerprint=md5($4::jsonb::text) AS matches FROM "LegacyDirectBaseline" WHERE company=$1 AND model=$2 AND gid=$3',[co,54,gid,JSON.stringify([c,a])]); if(baseline?!baseline.matches:!appointmentDeletionUnmodified(c)||!appointmentDeletionUnmodified(a))reason='保留新系统已修改的课次'; } if(reason){if(!p.dryRun)await appointmentDeletionConflict(db,co,gid,reason);out.conflicts++;continue;} if(p.dryRun){out.wouldCancel++;continue;} await db.none('UPDATE "CommonModel" SET status=-2,"updatedAt"=now() WHERE company=$1 AND "objectId"=$2',[co,c.objectId]); out.cancelled++;out.changes.push({generalId:gid,objectId:c.objectId,previousStatus:c.status,status:-2,source:'old-api/WXAPP',confirmedAbsent:true}); } await db.none('INSERT INTO "LegacyDirectBatch" (company,"runId",key,fingerprint,result) VALUES($1,$2,$3,md5($4),$5::jsonb)',[co,p.runId,key,JSON.stringify([sourceRunId,after,ids,live.ids]),JSON.stringify(out)]); return out; }); return response.json({success:true,data:result}); }catch(error){return response.status(500).json({success:false,message:String(error.message||'appointment_deletion_failed')});} }