| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192 |
- // 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||updated<Date.parse('2026-08-01')||__WINDOWS__.some(([a,b])=>updated>=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')});}
- }
|