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

Reconcile admin schedules with live legacy appointments

彭峰 1 день назад
Родитель
Сommit
fcad13ef11

+ 3 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -563,6 +563,8 @@ export interface CoachPage extends OperationsPage<CoachSummary> {
 
 export interface ScheduleAppointment extends Record<string, unknown> {
   bindingId?: string;
+  sourceKey?: string;
+  statusUnverified?: boolean;
   targetId: string;
   generalId: number;
   studentObjectId: string;
@@ -603,6 +605,7 @@ export interface ScheduleCalendarData {
   dateFrom: string;
   dateTo: string;
   refreshedAt: string;
+  sourceRefreshedAt?: string;
   totalAppointments: number;
   dataDateFrom: string;
   dataDateTo: string;

+ 2 - 2
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -128,8 +128,8 @@ export class OperationsService {
     return this.functions.operations<LearningReportDetail>('ops/learning-reports/export', { targetId });
   }
 
-  schedule(dateFrom: string, dateTo: string, coachId = 0): Promise<ScheduleCalendarData> {
-    return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId } });
+  schedule(dateFrom: string, dateTo: string, coachId = 0, refresh = false): Promise<ScheduleCalendarData> {
+    return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId }, ...(refresh ? { refresh: true } : {}) });
   }
 
   availability(coachId: number): Promise<OperationsPage<CoachAvailability>> {

+ 7 - 3
projects/xiaoshu-admin/src/app/pages/operations-schedule-status.ts

@@ -8,6 +8,7 @@ export type ScheduleStatusTone =
   | 'status-completed'
   | 'status-archived'
   | 'status-overdue'
+  | 'status-unverified'
   | 'status-cancelled'
   | 'status-neutral';
 
@@ -20,6 +21,7 @@ export type ScheduleStatusFilter =
   | 'completed'
   | 'archived'
   | 'overdue'
+  | 'unverified'
   | 'cancelled'
   | 'unassigned';
 
@@ -33,10 +35,11 @@ const scheduleStatusLabels: Partial<Record<number, string>> = {
 };
 
 export function scheduleStatusTone(
-  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt'>,
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'statusUnverified'>,
   now = Date.now(),
 ): ScheduleStatusTone {
   if (item.cancelled) return 'status-cancelled';
+  if (item.statusUnverified) return 'status-unverified';
   const endTime = Date.parse(item.endsAt);
   if ((item.statusCode === 0 || item.statusCode === 10) && Number.isFinite(endTime) && endTime < now) return 'status-overdue';
   if (item.statusCode === 0) return 'status-upcoming';
@@ -49,17 +52,18 @@ export function scheduleStatusTone(
 }
 
 export function scheduleStatusLabel(
-  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'statusLabel' | 'endsAt'>,
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'statusLabel' | 'endsAt' | 'statusUnverified'>,
   now = Date.now(),
 ): string {
   const tone = scheduleStatusTone(item, now);
   if (tone === 'status-cancelled') return '已取消';
+  if (tone === 'status-unverified') return '状态待核对';
   if (tone === 'status-overdue') return '逾期未完成';
   return scheduleStatusLabels[item.statusCode] || item.statusLabel || `状态 ${item.statusCode}`;
 }
 
 export function scheduleMatchesStatus(
-  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'coachId' | 'coachAccountMissing'>,
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'statusUnverified' | 'coachId' | 'coachAccountMissing'>,
   filter: ScheduleStatusFilter,
   now = Date.now(),
 ): boolean {

+ 5 - 3
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -32,7 +32,7 @@
 
 <section class="schedule-filterbar" aria-label="排课记录筛选">
   <label class="schedule-search"><span>搜索排课</span><span class="filter-control"><lucide-icon [img]="icons.Search" [size]="16"/><input type="search" [ngModel]="scheduleSearchInput()" (ngModelChange)="scheduleSearchInput.set($event)" (keydown.enter)="applyScheduleFilters()" placeholder="学员、陪练、课程或业务ID"/></span></label>
-  <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilterInput()" (ngModelChange)="statusFilterInput.set($event)"><option value="all">全部状态</option><option value="upcoming">待开始</option><option value="live">上课中</option><option value="coach-ended">陪练已结束</option><option value="confirmed">学员已确认</option><option value="completed">已完课</option><option value="archived">已归档</option><option value="overdue">逾期未完成</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
+  <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilterInput()" (ngModelChange)="statusFilterInput.set($event)"><option value="all">全部状态</option><option value="upcoming">待开始</option><option value="live">上课中</option><option value="coach-ended">陪练已结束</option><option value="confirmed">学员已确认</option><option value="completed">已完课</option><option value="archived">已归档</option><option value="overdue">逾期未完成</option><option value="unverified">状态待核对</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
   <div class="filter-actions">
     <button class="find-button" type="button" (click)="applyScheduleFilters()" [disabled]="searching()"><lucide-icon [img]="searching()?icons.LoaderCircle:icons.Search" [size]="16" [class.spin]="searching()" />{{searching()?'正在查找':'查找'}}</button>
     <button class="reset-button" type="button" (click)="clearScheduleFilters()" [disabled]="searching()"><lucide-icon [img]="icons.RotateCcw" [size]="15" />重置</button>
@@ -43,6 +43,7 @@
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
+@if(data()?.sync?.state!=='synced'&&data()){<p class="notice error" role="status">旧系统持久化同步尚未追平。{{data()?.liveSourceAvailable?'本页已读取旧系统当前排课':'本页暂时无法核对旧系统当前排课'}};刷新页面不等于新数据库已同步,未核实记录不能据此认定缺课或结算工资。</p>}
 
 <section class="schedule-workflow" aria-label="预约到完课业务流程">
   <header>
@@ -62,7 +63,7 @@
   <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
   <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
   <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
-  <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>{{ data()?.sync?.label || '实时排课接口读取' }}</small></article>
+  <article class="summary-card"><span>旧系统读取</span><strong>{{data()?.sourceRefreshedAt?(data()?.sourceRefreshedAt|date:'MM-dd HH:mm:ss'):'—'}}</strong><small>{{data()?.liveSourceAvailable?'当前排课已核对':'当前状态未核实'}} · {{data()?.sync?.label||'同步状态未知'}}</small></article>
 </section>
 
 <section class="content-card" [class.is-loading]="loading()" [attr.aria-busy]="loading()">
@@ -76,12 +77,13 @@
       <span class="status-completed"><i></i>已完课</span>
       <span class="status-archived"><i></i>已归档</span>
       <span class="status-overdue"><i></i>逾期未完成</span>
+      <span class="status-unverified"><i></i>状态待核对</span>
       <span class="status-cancelled"><i></i>已取消</span>
     </div>
   </header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">实时数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions">@if(item.statusCode>=11){<button type="button" (click)="viewFeedback(item)">查看完课反馈</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
+      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions">@if(item.statusCode>=11&&!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看完课反馈</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
       @empty{<tr><td colspan="7"><div class="empty">{{hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
   } @else {

+ 7 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss

@@ -581,6 +581,12 @@ textarea:focus-visible {
   --status-border: #e2e8f0;
 }
 
+.status-unverified {
+  --status-color: #7c6541;
+  --status-bg: #fffbeb;
+  --status-border: #f5dfaa;
+}
+
 .appointment.status-upcoming,
 .appointment.status-live,
 .appointment.status-coach-ended,
@@ -589,6 +595,7 @@ textarea:focus-visible {
 .appointment.status-archived,
 .appointment.status-overdue,
 .appointment.status-cancelled,
+.appointment.status-unverified,
 .appointment.status-neutral {
   border-color: var(--status-border);
   border-left-color: var(--status-color);

+ 8 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -53,6 +53,14 @@ describe('排课状态视觉分类', () => {
     expect(scheduleStatusLabel(item, now)).toBe('逾期未完成');
   });
 
+  it('持续同步不可用时旧预约只标记待核对,不当作缺课', () => {
+    const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z', statusUnverified: true, coachId: 88, coachAccountMissing: false };
+    expect(scheduleStatusTone(item, now)).toBe('status-unverified');
+    expect(scheduleStatusLabel(item, now)).toBe('状态待核对');
+    expect(scheduleMatchesStatus(item, 'overdue', now)).toBeFalse();
+    expect(scheduleMatchesStatus(item, 'unverified', now)).toBeTrue();
+  });
+
   it('按原系统状态链区分陪练结束、学员确认和最终完课', () => {
     expect(scheduleStatusTone({ cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z' }, now)).toBe('status-upcoming');
     expect(scheduleStatusTone({ cancelled: false, statusCode: 10, endsAt: '2026-08-20T03:00:00.000Z' }, now)).toBe('status-live');

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -153,7 +153,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   ngOnInit():void{void this.load().then(()=>{const id=Number(this.route.snapshot.queryParamMap.get('studentId'));const member=this.data()?.members.find(m=>m.userId===id);if(member){this.openCreate();this.chooseMember(member);}});this.stopLiveRefresh=this.liveRefresh.watch(['courses','appointments','account'],()=>{if(this.loading()||this.saving()||this.searching())return false;void this.load(true);return true;});}
   ngOnDestroy():void{this.stopLiveRefresh?.();}
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}
-  async load(background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(request!==this.loadRequest)return;if(!background&&this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range(),latest=await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter());if(request!==this.loadRequest)return;this.data.set(latest);this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{if(!background&&request===this.loadRequest)this.loading.set(false);}}
+  async load(background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter(),!background);if(request!==this.loadRequest)return;if(!background&&this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range(),latest=await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter(),true);if(request!==this.loadRequest)return;this.data.set(latest);this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{if(!background&&request===this.loadRequest)this.loading.set(false);}}
   move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
   showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
   async applyScheduleFilters():Promise<void>{if(this.searching())return;this.searching.set(true);this.scheduleSearch.set(this.scheduleSearchInput().trim());this.statusFilter.set(this.statusFilterInput());await new Promise(resolve=>setTimeout(resolve,220));this.searching.set(false);}
@@ -162,7 +162,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
   openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
-  async viewFeedback(item:ScheduleAppointment):Promise<void>{this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
+  async viewFeedback(item:ScheduleAppointment):Promise<void>{if(item.readOnly){this.message.set('这条排课来自旧系统实时接口;完课反馈尚未同步到新库,请在原业务端查看。');return;}this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
   closeFeedback():void{this.feedback.set(null);}
   setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
   openSearch(field:SearchField):void{this.activeSearch.set(field);}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
scripts/deploy-admin-functions.mjs


+ 99 - 0
scripts/deploy-schedule-reconciliation.mjs

@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import vm from 'node:vm';
+
+const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const sourcePath = 'scripts/deploy-admin-functions.mjs';
+const base = process.argv.find((arg) => arg.startsWith('--base='))?.slice(7);
+const commit = process.argv.includes('--commit');
+const masterKey = process.env.XIAOSHU_MASTER_KEY || '';
+const appId = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const parseUrl = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const sha = (value) => createHash('sha256').update(value).digest('hex');
+
+if (!base) throw new Error('请指定部署前的源码版本:--base=<commit>');
+if (!masterKey) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const previousSource = execFileSync('git', ['show', `${base}:${sourcePath}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10_000_000 });
+const nextSource = await readFile(path.join(repo, sourcePath), 'utf8');
+const markers = [
+  ['function appointmentProjection(', 'async function allAppointmentRows('],
+  ['async function liveAppointmentRows(', 'async function cachedLiveAppointmentRows('],
+  ['async function cachedLiveAppointmentRows(', 'function payrollDateIso('],
+  ['async function professionalScheduleDataLocal(', 'function addBusinessDays('],
+  ["if(operation==='ops/schedule/calendar')", "if(operation==='ops/coach-availability/list')"],
+];
+
+function section(source, start, end) {
+  const from = source.indexOf(start);
+  const to = source.indexOf(end, from + start.length);
+  if (from < 0 || to < 0) throw new Error(`找不到排课代码段:${start}`);
+  return source.slice(from, to);
+}
+
+function replaceOnce(source, before, after) {
+  const first = source.indexOf(before);
+  if (first < 0 || source.indexOf(before, first + before.length) >= 0) throw new Error('线上排课代码与预期不一致,已拒绝覆盖');
+  return source.slice(0, first) + after + source.slice(first + before.length);
+}
+
+const headers = {
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+  'Content-Type': 'application/json',
+};
+const functionName = 'xiaoshu.ops.gateway.v3';
+const where = encodeURIComponent(JSON.stringify({ name: functionName }));
+const queryPath = `/classes/Function?where=${where}&keys=objectId,name,code,updatedAt`;
+async function parse(pathname, init = {}) {
+  const response = await fetch(parseUrl + pathname, { ...init, headers });
+  const body = await response.json();
+  if (!response.ok || body.error) throw new Error(`云函数接口失败:HTTP ${response.status} ${String(body.error || '')}`);
+  return body;
+}
+
+const initial = (await parse(queryPath)).results || [];
+if (initial.length !== 1 || typeof initial[0].code !== 'string') throw new Error('未找到唯一的线上运营网关');
+const original = initial[0];
+let patched = original.code;
+for (const [start, end] of markers) {
+  const before = section(previousSource, start, end);
+  const after = section(nextSource, start, end);
+  if (before === after) throw new Error(`本地排课代码段没有变化:${start}`);
+  patched = replaceOnce(patched, before, after);
+}
+const helper = section(nextSource, 'function reconcileScheduleAppointments(', 'async function professionalScheduleDataLocal(');
+if (patched.includes('function reconcileScheduleAppointments(')) throw new Error('线上已有排课对账函数,不能重复部署');
+patched = replaceOnce(patched, 'async function professionalScheduleDataLocal(', helper + 'async function professionalScheduleDataLocal(');
+new vm.Script(patched, { filename: functionName });
+
+const result = { functionName, objectId: original.objectId, before: sha(original.code), after: sha(patched), sourceUpdatedAt: original.updatedAt, changedSections: markers.length + 1 };
+if (!commit) {
+  console.log(JSON.stringify({ ...result, dryRun: true }, null, 2));
+  process.exit(0);
+}
+
+const backupDir = path.join(repo, 'dist', 'schedule-deploy-backup');
+await mkdir(backupDir, { recursive: true });
+const backupPath = path.join(backupDir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${original.objectId}.json`);
+await writeFile(backupPath, JSON.stringify({ ...result, code: original.code }, null, 2));
+const beforeWrite = (await parse(queryPath)).results?.[0];
+if (!beforeWrite || sha(beforeWrite.code || '') !== result.before || beforeWrite.updatedAt !== original.updatedAt) throw new Error('线上网关在部署期间发生变化,已停止写入');
+
+try {
+  await parse(`/classes/Function/${original.objectId}`, { method: 'PUT', body: JSON.stringify({ code: patched }) });
+  const afterWrite = (await parse(queryPath)).results?.[0];
+  if (!afterWrite || sha(afterWrite.code || '') !== result.after) throw new Error('写入后代码校验失败');
+  console.log(JSON.stringify({ ...result, deployed: true, backupPath }, null, 2));
+} catch (error) {
+  const current = (await parse(queryPath).catch(() => ({ results: [] }))).results?.[0];
+  if (current && sha(current.code || '') === result.after) {
+    await parse(`/classes/Function/${original.objectId}`, { method: 'PUT', body: JSON.stringify({ code: original.code }) });
+  }
+  throw error;
+}

+ 54 - 0
scripts/tests/schedule-live-reconciliation.test.mjs

@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+const gateway = source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)?.[1];
+assert.ok(gateway, '运营网关源码可提取');
+const context = vm.createContext({ Date, Intl, process: { env: {} } });
+vm.runInContext(gateway, context);
+
+const startsAt = '2026-10-03T06:00:00.000Z';
+const stored = [
+  { targetId: 'old-1', generalId: 1, sourceKey: 'legacy:model:54:general:1', startsAt, statusCode: 0, readOnly: false },
+  { targetId: 'old-2', generalId: 2, sourceKey: 'legacy:model:54:general:2', startsAt, statusCode: 0, readOnly: false },
+  { targetId: 'new-3', generalId: 3, sourceKey: 'cloud:appointment:3', startsAt, statusCode: 0, readOnly: false },
+];
+
+test('完整旧系统快照覆盖同 ID 状态,移除已不在旧系统的迁移预约,保留新系统预约', () => {
+  const live = [{ targetId: 'legacy-live:1', generalId: 1, startsAt, statusCode: 30, readOnly: true }];
+  const result = context.reconcileScheduleAppointments(stored, live, { available: true, complete: true }, '2026-10-03', '2026-10-03');
+  assert.deepEqual(Array.from(result, (row) => row.generalId), [3, 1]);
+  assert.equal(result.find((row) => row.generalId === 1).statusCode, 30);
+  assert.equal(result.find((row) => row.generalId === 3).readOnly, false);
+});
+
+test('旧系统不可用、快照过期或分页不完整时不判定旧预约缺课', () => {
+  for (const sourceState of [
+    { available: false, complete: false },
+    { available: true, complete: true, stale: true },
+    { available: true, complete: false },
+  ]) {
+    const result = context.reconcileScheduleAppointments(stored, [], sourceState, '2026-10-03', '2026-10-03');
+    assert.equal(result.length, 3);
+    assert.equal(result[0].statusUnverified, true);
+    assert.equal(result[0].readOnly, true);
+    assert.equal(result[2].statusUnverified, undefined);
+  }
+});
+
+test('实时排课分页以所选范围数量为准,不被全量历史数量误导', async () => {
+  const requests = [];
+  context.legacyScheduleConfig = async () => ({ url: 'https://example.test', apiId: 'id', apiKey: 'key' });
+  context.legacyScheduleRequest = async (_config, params) => {
+    requests.push(params);
+    if (params.dateFilter === '2000-01-01') return { items: [], page: { itemCount: 30000 } };
+    return { items: [{ GeneralID: 1, yysj: '2026-10-03 14:00', dszt: '30' }], page: { itemCount: 1, pageCount: 1 } };
+  };
+  const result = await context.liveAppointmentRows({ dateFrom: '2026-10-03' });
+  assert.equal(result.complete, true);
+  assert.equal(result.total, 30000);
+  assert.equal(result.allItems.length, 1);
+  assert.equal(requests.length, 2);
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов