Переглянути джерело

fix(mobile): reconcile student hours from lesson facts and simplify home stats

彭峰 3 годин тому
батько
коміт
86786f6cf2

+ 138 - 0
docs/qa/student-home-hours-2026-11-15.json

@@ -0,0 +1,138 @@
+{
+  "state": "local_fix_not_deployed",
+  "tests": {
+    "mobile": 404,
+    "admin": 394,
+    "server": 591,
+    "total": 1389,
+    "failed": 0
+  },
+  "build": "passed",
+  "cloudDefinitions": 11,
+  "protectedFinancialFilesUnchanged": 14,
+  "target": {
+    "studentId": 1850,
+    "lessons": 19,
+    "formal60MinuteLessons": 18,
+    "trial60MinuteLessons": 1,
+    "oldHomepageHours": 10,
+    "completedRecordHours": 19,
+    "formalReceiptsMatched": 18,
+    "trialReceiptAccount": "UserExp",
+    "latestRechargeHours": 10
+  },
+  "finance": {
+    "ledgerUnchanged": true,
+    "groups": 41,
+    "balances": {
+      "Purse": {
+        "balance": 0,
+        "reserved": 0,
+        "available": 0
+      },
+      "SilverCoin": {
+        "balance": 12,
+        "reserved": 1,
+        "available": 11
+      },
+      "UserExp": {
+        "balance": 0,
+        "reserved": 0,
+        "available": 0
+      },
+      "UserPoint": {
+        "balance": 12,
+        "reserved": 1,
+        "available": 11
+      }
+    },
+    "at": "2026-11-15T05:32:27.952Z"
+  },
+  "memberAudit": {
+    "members": 639,
+    "withRecords": 546,
+    "affected": 93,
+    "confirmed": 82,
+    "unknown": 9,
+    "missingRecords": 2
+  },
+  "browser": {
+    "externalRequests": 0,
+    "results": [
+      {
+        "scenario": "healthy",
+        "oldGateAccepts": true,
+        "error": "",
+        "values": [
+          "18",
+          "12.5",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": true,
+        "expiryUpdatesWithoutRequests": true,
+        "actions": [
+          "e_order_tongji",
+          "app_account_overview",
+          "app_review_page"
+        ]
+      },
+      {
+        "scenario": "balance-error",
+        "oldGateAccepts": true,
+        "error": "课时余额暂时无法更新 重新加载",
+        "values": [
+          "18",
+          "—",
+          "—",
+          "—"
+        ],
+        "correctedGateAccepts": false,
+        "actions": [
+          "e_order_tongji",
+          "app_account_overview",
+          "app_review_page"
+        ]
+      },
+      {
+        "scenario": "stats-error",
+        "oldGateAccepts": true,
+        "error": "统计暂时无法加载 重新加载",
+        "values": [
+          "—",
+          "12.5",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": false,
+        "actions": [
+          "e_order_tongji",
+          "app_account_overview",
+          "app_review_page"
+        ]
+      },
+      {
+        "scenario": "warm-legacy-cache",
+        "oldGateAccepts": true,
+        "error": "",
+        "values": [
+          "18",
+          "12.5",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": true,
+        "expiryUpdatesWithoutRequests": true,
+        "actions": [
+          "e_order_tongji",
+          "app_review_page"
+        ]
+      }
+    ]
+  },
+  "limits": [
+    "Member statistics audit is not a full financial audit of each member.",
+    "9 historical duration gaps and 2 missing-record cases need individual reconciliation.",
+    "Production financial data was not modified; candidate code was not deployed."
+  ]
+}

+ 38 - 0
docs/qa/student-home-hours-2026-11-15.md

@@ -0,0 +1,38 @@
+# 学生首页时长统计核对与四项显示
+
+2026-11-15。用户先要求核对熊梓含初中及其他学生的累计陪练时长,随后明确要求学生首页隐藏累计时长,仅保留截图红框余额。当前交付为本地修复,尚未上线。
+
+## 熊梓含初中
+
+正式会员ID1850。实际上课记录19次,18次正式60分钟、1次体验60分钟,共19小时。原首页 `e_order_tongji` 从 `CourseAppointment.dszt>10` 推算时长,只有10条满足;多条实际已上课的旧预约仍是0/10状态,漏计9小时。学习记录本身完整,不应通过再次结课来补统计。
+
+逐笔账务核对:原充值SilverCoin、UserPoint各20,体验UserExp1;18次正式课每次各扣SilverCoin和UserPoint1,逐一匹配18个不同lessonId;体验课仅UserExp扣1。11月14日完课后正式余额2。11月15日09:06续费10,当前余额12,其中已预留1、可用11。首页截图显示12与账本一致,累计10有误。没有修改、补扣、补偿、重置余额或再次结课。
+
+核对前后全量学生流水聚合版本与41组条数一致,余额及预留查询结果一致。
+
+## 界面与统计修复
+
+- 学生首页移除累计陪练时长卡片,四列为累积生词、剩余陪练时长、30分钟软件时长、60分钟软件时长。老师端累计时长仍显示。
+- 学生统计使用单独 `student-stats-v2` 缓存,升级后不继续命中旧统计;余额缓存和老师统计键保留。
+- 学生接口的累计时长改用完成课记事实,同一学生同一预约去重、排除作废课记;依次采用同学生的完课报告时长、原预约时长、有效历史分钟字段及明确课型时长。30分钟按0.5小时、60分钟按1小时、体验按实际分钟处理;不按课程次数直接当小时。
+- 数据不足、非有限时长、已结束预约对应课记缺失(包括部分缺失)均返回未知,不猜测时长或静默归零,也不依据预约补加实际出勤。
+- 保留生词 `t_total` 原有PracticeRecord来源、老师收入分支、费用和结算规则。新模块仅SELECT,无生产迁移。
+- 浏览器验收门槛同步为四项有效数字,余额/统计失败时仍拒绝通过。
+
+## 其他学生排查
+
+对639个正式会员做只读排查,其中546人有完成课记。发现93人的原首页与课记口径不同:82人可由现有课记计算出差异,9人历史分钟信息不全,2人有已结束预约但没有对应课记。后11人保留待核对,不自动修正历史记录或余额。
+
+这是一份同一时点的只读统计快照;82项不是逐人完成过财务审计的结论。姓名及原值/记录次数/候选值/异常状态清单仅保存在受保护本地CSV,不进入Git。
+
+全局核对在REPEATABLE READ、READ ONLY事务中执行,带25秒statement_timeout,固定帐套、固定SQL。临时函数有令牌摘要、租户和短期过期检查,执行后已删除,不保留任意SQL执行入口。
+
+## 验证
+
+- 缺陷用例对旧版本确实失败:混合30/60分钟及30分钟体验,已结束状态滞后,期待2小时而旧代码返回1小时;随后修复通过。
+- 5项隔离PostgreSQL回归覆盖19小时原案例、部分或全部缺课记、取消/作废、去重、归属隔离、报告优先级、未知与NaN时长,且禁止统计发出写入SQL。
+- 移动端404、管理端394、服务端591,共1,389项通过;生产构建及11项云函数定义校验通过。独立审查发现的未知值/部分缺失问题均有红测、修复及复审。
+- 实际候选构建配本地合成API,在390px和1024px检查四列和横向溢出,累计卡片不存在;健康/余额失败/统计失败/旧缓存四场景通过,外部请求0。合成显示值只用于测试,不是对真实学生余额的修改。
+- 生产核查只读,不等于候选已部署或家长当前页面已更新。真实数据的19小时计算已由只读SQL核验,界面和接口变更仍等待上线。
+
+私有证据:`/Users/mac/.local/state/xiaoshu/qa/2026-11-15-student-hours/`。其中 `学生累计时长排查清单.csv` 为93项排查清单;原始账号、账本及课记数据不进入Git。

+ 1 - 1
perf/recovery-performance-2026-11-14/home-readiness-audit.mjs

@@ -19,6 +19,6 @@ const server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,'h
 await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port,browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',args:['--no-proxy-server']});
 try{for(scenario of ['healthy','balance-error','stats-error','warm-legacy-cache']){actions=[];const context=await browser.newContext({viewport:{width:390,height:844},timezoneId:'Asia/Shanghai'});await context.addInitScript(({scenario,today})=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-audit-only',user:{userId:20,groupId:1,userName:'本地测试学生'},student:null}));if(scenario==='warm-legacy-cache'){const updatedAt=today+'T03:00:00Z';localStorage.setItem('xiaoshu.learning.homeDaily.v1',JSON.stringify({identity:'20|1|'+today,values:{review:{updatedAt,value:{items:[],dates:[],summary:{total:99,completed:0,due:99,today:99,income:0}}},stats:{updatedAt,value:{t_total:18,t_shichang:13}},balances:{updatedAt,value:{UserPoint:12.5,Purse:8,SilverCoin:7}}}}));}},{scenario,today});await context.route('**/*',route=>{if(new URL(route.request().url()).origin===origin)return route.continue();external.push(route.request().url());return route.abort();});const page=await context.newPage();await page.goto(origin+'/pages/home/index',{waitUntil:'domcontentloaded'});try{await page.waitForSelector('.memory-content',{timeout:10000});}catch(error){await page.screenshot({path:path.join(out,'failed-'+scenario+'.png'),fullPage:true});console.log(JSON.stringify({scenario,actions,body:await page.locator('body').innerText()}));throw error;}await page.waitForFunction(()=>!!document.querySelector('.student-home-refresh:not(:disabled)'));
  const observed=await page.evaluate(()=>{const q=s=>document.querySelector(s),stats=q('.student-stats'),previous=stats?.previousElementSibling;return{oldGateAccepts:!!q('.memory-content')&&!!q('button[aria-label="刷新学习数据"]:not(:disabled)')&&!q('.hero-error'),error:previous?.matches('p[role="status"]')?previous.textContent.trim():'',values:Array.from(stats?.querySelectorAll('strong')||[]).map(x=>x.textContent.trim())};});
- const correctedGateAccepts=await page.evaluate(pageReadyGate,{role:'student',stableMs:0});assert.equal(correctedGateAccepts,['healthy','warm-legacy-cache'].includes(scenario));observed.correctedGateAccepts=correctedGateAccepts;assert.equal(observed.oldGateAccepts,true);assert.equal(Boolean(observed.error),!['healthy','warm-legacy-cache'].includes(scenario));if(!['healthy','warm-legacy-cache'].includes(scenario))assert.ok(observed.values.includes('—'));if(scenario==='warm-legacy-cache')assert.deepEqual(actions,['app_review_page']);if(['healthy','warm-legacy-cache'].includes(scenario)){const count=actions.length;await page.evaluate(()=>{const original=Date.now;Date.now=()=>original()+61_001;document.dispatchEvent(new Event('visibilitychange'));});assert.match(await page.locator('.review-sync-notice').innerText(),/已过期/);assert.equal(actions.length,count);observed.expiryUpdatesWithoutRequests=true;}results.push({scenario,...observed,actions:[...actions]});await page.screenshot({path:path.join(out,scenario+'.png'),fullPage:true});await context.close();}
+ const correctedGateAccepts=await page.evaluate(pageReadyGate,{role:'student',stableMs:0});assert.equal(correctedGateAccepts,['healthy','warm-legacy-cache'].includes(scenario));observed.correctedGateAccepts=correctedGateAccepts;assert.equal(observed.oldGateAccepts,true);assert.equal(Boolean(observed.error),!['healthy','warm-legacy-cache'].includes(scenario));if(!['healthy','warm-legacy-cache'].includes(scenario))assert.ok(observed.values.includes('—'));if(scenario==='warm-legacy-cache')assert.deepEqual([...actions].sort(),['app_review_page','e_order_tongji']);if(['healthy','warm-legacy-cache'].includes(scenario)){const count=actions.length;await page.evaluate(()=>{const original=Date.now;Date.now=()=>original()+61_001;document.dispatchEvent(new Event('visibilitychange'));});assert.match(await page.locator('.review-sync-notice').innerText(),/已过期/);assert.equal(actions.length,count);observed.expiryUpdatesWithoutRequests=true;}assert.equal(observed.values.length,4);assert.ok(!(await page.locator('.student-stats').innerText()).includes('累计陪练时长'));results.push({scenario,...observed,actions:[...actions]});if(scenario==='healthy'){await page.setViewportSize({width:1024,height:900});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await page.screenshot({path:path.join(out,'healthy-1024.png'),fullPage:true});await page.setViewportSize({width:390,height:844});}await page.screenshot({path:path.join(out,scenario+'.png'),fullPage:true});await context.close();}
  assert.deepEqual(external,[]);fs.writeFileSync(path.join(out,'home-readiness-audit.json'),JSON.stringify({externalRequests:0,results},null,2));console.log(JSON.stringify({externalRequests:0,results}));
 }finally{await browser.close();await new Promise(resolve=>server.close(resolve));}

+ 1 - 1
perf/recovery-performance-2026-11-14/readiness.mjs

@@ -12,7 +12,7 @@ export function pageReadyGate({role,course=false,stableMs=500}) {
   const stats=q('.student-stats'),status=stats?.previousElementSibling;
   const values=Array.from(stats?.querySelectorAll('strong')||[]).map(x=>x.textContent.trim());
   valid=!!q('.memory-content')&&!!q('.student-home-refresh:not(:disabled)')&&!q('.hero-error')
-   &&!status?.matches('p[role="status"]')&&values.length===5&&values.every(v=>v!==''&&v!=='—'&&Number.isFinite(Number(v)));
+   &&!status?.matches('p[role="status"]')&&values.length===4&&values.every(v=>v!==''&&v!=='—'&&Number.isFinite(Number(v)));
   key=stats?.textContent+'|'+q('.today-panel')?.textContent;
  }
  if(!valid){window.__ready=null;return false;}

+ 2 - 2
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts

@@ -5,8 +5,8 @@ import { SessionService } from './session.service';
 import { beijingToday } from './teacher-today-review.service';
 
 const STORAGE_KEY = 'xiaoshu.learning.homeDaily.v1';
-type Section = 'stats' | typeof HOME_REVIEW_CACHE_KEY | 'balances' | 'account' | `companion:${string}`;
-const FIXED_SECTIONS = ['stats', HOME_REVIEW_CACHE_KEY, 'balances', 'account'];
+type Section = 'student-stats-v2' | 'stats' | typeof HOME_REVIEW_CACHE_KEY | 'balances' | 'account' | `companion:${string}`;
+const FIXED_SECTIONS = ['student-stats-v2', 'stats', HOME_REVIEW_CACHE_KEY, 'balances', 'account'];
 const MAX_COMPANION_PAGES = 80;
 const COMPANION_CACHE_VERSION = 2;
 export interface HomeSnapshot<T> { value: T; updatedAt: string; }

+ 6 - 4
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -564,11 +564,12 @@ describe('home learning modules', () => {
       stats:{value:{t_total:18,t_shichang:9},updatedAt:at}
     }}));
     const api=TestBed.inject(ApiService),original=api.post.bind(api);
-    const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post);
+    const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='e_order_tongji'?of({retcode:0,result:{t_total:18,t_shichang:19}}):action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post);
     const harness=await RouterTestingHarness.create('/pages/home/index');harness.fixture.detectChanges();
     expect(post.calls.allArgs().filter(([action])=>action==='app_review_page').length).toBe(1);
+    expect(post.calls.allArgs().filter(([action])=>action==='e_order_tongji').length).toBe(1);
     expect(harness.routeNativeElement?.querySelector('.review-sync-notice')).not.toBeNull();
-    expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).map(e=>e.textContent?.trim())).toEqual(['18','12.5','9','8','7']);
+    expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).map(e=>e.textContent?.trim())).toEqual(['18','12.5','8','7']);
     expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).not.toContain('99');
   });
 
@@ -599,13 +600,14 @@ describe('home learning modules', () => {
     });
     const harness=await RouterTestingHarness.create('/pages/home/index');
     harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.student-stats')?.textContent).not.toContain('累计陪练时长');
     expect(api.lastMemoryParams).toEqual(jasmine.objectContaining({view:'home',source:'database'}));
     expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('已同步');
     expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('暂不可用');
     expect(harness.routeNativeElement?.querySelector('.continue-button')).not.toBeNull();
     expect((harness.routeNativeElement?.querySelector('.student-home-refresh') as HTMLButtonElement).disabled).toBeFalse();
     expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('2');
-    expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).slice(1).map(e=>e.textContent?.trim())).toEqual(['12.5','9','8','7']);
+    expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).slice(1).map(e=>e.textContent?.trim())).toEqual(['12.5','8','7']);
   });
 
   for (const groupId of [1, 3]) {
@@ -1641,7 +1643,7 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.memory-hero')).not.toBeNull();
     expect(harness.routeNativeElement?.querySelectorAll('.memory-dot').length).toBe(21);
     expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('学习新词');
-    expect(harness.routeNativeElement?.querySelectorAll('.student-stats > div').length).toBe(5);
+    expect(harness.routeNativeElement?.querySelectorAll('.student-stats > div').length).toBe(4);
     expect(harness.routeNativeElement?.querySelectorAll('.quick-grid > a').length).toBe(4);
     expect(harness.routeNativeElement?.querySelector('.continue-button')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
     expect(harness.routeNativeElement?.querySelector('.task-link')?.getAttribute('href')).toBe('/pages/home/cart');

+ 0 - 1
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -106,7 +106,6 @@
     <section class="student-stats" aria-label="学习统计">
       <div><strong>{{ stat('t_total') }}</strong><span>累积生词</span></div>
       <div><strong>{{ balance('UserPoint') }}</strong><span>剩余陪练时长</span></div>
-      <div><strong>{{ stat('t_shichang') }}</strong><span>累计陪练时长</span></div>
       <div><strong>{{ balance('purse','Purse') }}</strong><span>30分钟软件时长</span></div>
       <div><strong>{{ balance('silverCoin','SilverCoin') }}</strong><span>60分钟软件时长</span></div>
     </section>

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss

@@ -45,7 +45,7 @@
 .hero-error { width: 100%; color: white; }
 .state-spinner { width: 24px; height: 24px; border: 2px solid rgb(255 255 255 / 35%); border-top-color: white; border-radius: 50%; animation: spin .8s linear infinite; }
 
-.student-stats { display: grid; overflow: hidden; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-top: 14px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
+.student-stats { display: grid; overflow: hidden; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 14px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .student-stats div { display: flex; min-height: 82px; align-items: center; justify-content: center; position: relative; padding: 10px 7px; flex-direction: column; gap: 5px; }
 .student-stats div + div::before { position: absolute; top: 18px; bottom: 18px; left: 0; width: 1px; background: var(--line); content: ''; }
 .student-stats strong { color: var(--brand-strong); font-size: 22px; }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -171,7 +171,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
   private loadStats(uid: string | number, force = false): void {
     this.statsLoading.set(true);
     this.coachStatsError.set('');
-    this.requests.add(this.dailyCache.load('stats', () =>
+    this.requests.add(this.dailyCache.load(this.isCoach()?'stats':'student-stats-v2', () =>
       this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(map(({ result }) => result)), force,
     ).pipe(finalize(() => this.statsLoading.set(false))).subscribe({
       next: ({ value }) => { this.stats.set(value); this.statsAvailable.set(true); this.coachStatsError.set(''); },

+ 41 - 0
scripts/cloud/student-lesson-statistics.js

@@ -0,0 +1,41 @@
+// Display-only statistics from completed lesson facts. Never transition an appointment
+// or infer a balance from attendance; reservations and financial ledgers remain independent.
+function studentLessonStatisticsSql() {
+ return `WITH facts AS (
+ SELECT l.*,COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
+ COALESCE(r."actualStartAt",l."lessonAt",xiaoshu_safe_shanghai_timestamp(l.plpjsj),c."createdAt",l."sourceCreatedAt",l."createdAt") AS "actualAt",
+ COALESCE(NULLIF(CASE WHEN r."durationMinutes">0 AND r."durationMinutes"::text NOT IN ('NaN','Infinity','-Infinity') THEN r."durationMinutes" END,0),
+ CASE WHEN a."durationMinutes">0 AND a."durationMinutes"::text NOT IN ('NaN','Infinity','-Infinity') THEN a."durationMinutes" END,
+ CASE WHEN to_jsonb(l)->>'durationMinutes' ~ '^\\d+(\\.\\d+)?$' THEN NULLIF((to_jsonb(l)->>'durationMinutes')::numeric,0) END,
+ CASE l.kclx WHEN '1' THEN 30 WHEN '2' THEN 60 WHEN '3' THEN 60 END) AS minutes,
+ COALESCE(NULLIF(l."courseCategoryKey",''),NULLIF(r."courseCategoryKey",''),a."courseCategoryKey",'') AS category
+ FROM "LessonRecord" l
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
+ LEFT JOIN LATERAL (SELECT r.* FROM "LessonCompletionReport" r WHERE r.company=l.company AND r."studentId"::text=l.xymz
+ AND (r."lessonId"=l."objectId" OR r."appointmentGeneralId"::numeric=CASE WHEN l.yyds~'^\\d+$' THEN l.yyds::numeric END)
+ ORDER BY r."submittedAt" DESC NULLS LAST,r."objectId" DESC LIMIT 1) r ON true
+ LEFT JOIN LATERAL (SELECT a.* FROM "CommonModel" ac JOIN "CourseAppointment" a ON a.company=ac.company AND a.id=ac."itemId" AND a.szyh=l.xymz
+ WHERE ac.company=l.company AND ac."modelId"=54 AND ac."generalId"=CASE WHEN l.yyds~'^\\d+$' THEN l.yyds::numeric END ORDER BY ac."objectId" LIMIT 1) a ON true
+ WHERE l.company=$1 AND ($2='' OR l.xymz=$2) AND c.status IS DISTINCT FROM -2
+ ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn FROM facts),
+ lessons AS (SELECT * FROM ranked WHERE rn=1)
+ SELECT xymz AS "studentId",COUNT(*)::int total,
+ COUNT(*) FILTER(WHERE minutes=30 AND kclx IS DISTINCT FROM '3' AND category<>'trial')::int t30,
+ COUNT(*) FILTER(WHERE minutes=60 AND kclx IS DISTINCT FROM '3' AND category<>'trial')::int t60,
+ COUNT(*) FILTER(WHERE kclx='3' OR category='trial')::int tiyan,
+ COUNT(*) FILTER(WHERE minutes IS NULL)::int "unknownDurationCount",
+ CASE WHEN COUNT(*) FILTER(WHERE minutes IS NULL)>0 THEN NULL ELSE SUM(minutes) END AS "totalMinutes"
+ FROM lessons GROUP BY xymz`;
+}
+function studentUnmatchedLessonsSql() {
+ return `SELECT a.szyh AS "studentId",COUNT(*)::int total FROM "CourseAppointment" a
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id ORDER BY c."objectId" LIMIT 1) c ON true
+ WHERE a.company=$1 AND ($2='' OR a.szyh=$2) AND a.dszt ~ '^[0-9]+$' AND a.dszt::numeric>10 AND c.status IS DISTINCT FROM -2
+ AND NOT EXISTS(SELECT 1 FROM "LessonRecord" l WHERE l.company=a.company AND l.xymz=a.szyh AND l.yyds=c."generalId"::text) GROUP BY a.szyh`;
+}
+async function studentLessonStatistics(companyId,studentId) {
+ const [rows,missing]=await Promise.all([Psql.query(studentLessonStatisticsSql(),[companyId,String(studentId)]),Psql.query(studentUnmatchedLessonsSql(),[companyId,String(studentId)])]);
+ const result=rows[0]||{total:0,t30:0,t60:0,tiyan:0,totalMinutes:0,unknownDurationCount:0};
+ const unresolved=Number(missing[0]?.total)||0;
+ return {...result,unknownDurationCount:Number(result.unknownDurationCount)+unresolved,totalMinutes:unresolved?null:result.totalMinutes};
+}

+ 4 - 3
scripts/deploy-admin-functions.mjs

@@ -21,6 +21,7 @@ const lessonCoursesCode = readFileSync(new URL('./cloud/lesson-courses.js', impo
 const lessonWorkflowCode = readFileSync(new URL('./cloud/lesson-workflow.js', import.meta.url), 'utf8');
 const wordAudioCode = readFileSync(new URL('./cloud/word-audio.js', import.meta.url), 'utf8');
 const lessonAttachmentsCode = readFileSync(new URL('./cloud/lesson-attachments.js', import.meta.url), 'utf8');
+const studentLessonStatisticsCode = readFileSync(new URL('./cloud/student-lesson-statistics.js', import.meta.url), 'utf8');
 const mobileTeachingCode = readFileSync(new URL('./cloud/mobile-teaching.js', import.meta.url), 'utf8');
 const enrollmentAppCode = readFileSync(new URL('./cloud/member-enrollment-app.js', import.meta.url), 'utf8');
 const payrollIntegrityCode = readFileSync(new URL('./cloud/payroll-integrity.js', import.meta.url), 'utf8');
@@ -2820,8 +2821,8 @@ async function completeOrderWithFeedback() { fail(409,'请刷新课程详情,
 async function orderStatistics(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); let target = current; if (uid !== ownLegacyId(current)) { const fields = await fieldsOf('_User'); target = await findByLegacyId('_User', fields, uid, ['legacyUserId','userid','num']); } if (!target) fail(404, '用户不存在'); const groupId = number(target.get('legacyGroupId') || target.get('groupId')); let t30 = 0; let t60 = 0; let tTiyan = 0; let total = 0; let commission = 0; let duration = 0;
   if (groupId === 3) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'3\')::int AS tiyan, COUNT(*)::int AS total FROM "LessonRecord" WHERE "company" = $1 AND CAST("jsmz" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(row.total); commission = 0; duration = t30 * 0.5 + t60 + tTiyan; }
-  else if (groupId === 1) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'3\')::int AS tiyan FROM "CourseAppointment" WHERE "company" = $1 AND CAST("szyh" AS text) = $2 AND CAST("dszt" AS text) ~ \'^[0-9]+$\' AND CAST("dszt" AS numeric) > 10', [DEFAULT_COMPANY_ID, String(uid)]); const practice = await Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(practice.total); duration = t30 * 0.5 + t60 + tTiyan; }
-  return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:String(duration), t_total:String(total), t_yongji:String(commission),...(groupId===3?await teacherIncomeLegacySummary(DEFAULT_COMPANY_ID,uid):{}) };
+  else if (groupId === 1) { const [row,practice]=await Promise.all([studentLessonStatistics(DEFAULT_COMPANY_ID,uid),Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2',[DEFAULT_COMPANY_ID,String(uid)])]);t30=number(row.t30);t60=number(row.t60);tTiyan=number(row.tiyan);total=number(practice.total);duration=number(row.unknownDurationCount)>0||row.totalMinutes==null||!Number.isFinite(Number(row.totalMinutes))?null:Number(row.totalMinutes)/60; }
+  return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:duration===null?null:String(duration), t_total:String(total), t_yongji:String(commission),...(groupId===3?await teacherIncomeLegacySummary(DEFAULT_COMPANY_ID,uid):{}) };
 }
 function appOverviewDateKey(value) {
   const raw=String(value||'').trim(),compact=raw.match(/^(\d{4})(\d{2})(\d{2})(?:\D|$)/),dashed=raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
@@ -4318,7 +4319,7 @@ const definitions = [
   { name: 'cms.exams.classes', desc: '替代 ZL_Exam_ClassView', path: 'xiaoshu/cms/exams/classes', code: cmsReadCode('exam-classes'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
   { name: 'cms.guest.bar', desc: '替代 ZL_Guest_BarView', path: 'xiaoshu/cms/guest/bar', code: cmsReadCode('guest-bar'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
   { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
-  { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode + supportRequestsAppCode + examHistoryCode + teacherEarningsSupport(operationsGatewayCode,payrollIntegrityCode) + teacherEarningsCode + accountDeletionCode + accountSecurityCode + reviewRewardCode + enrollmentAppCode + mobileTeachingCode + lessonWorkflowCode + lessonAttachmentsCode + wordAudioCode + lessonCoursesCode + lessonUnitProgressCode + lessonStudyCode + legacyAppointmentDefaults(operationsGatewayCode) + legacyWritingStageSupport(payrollIntegrityCode) + legacyAppointmentCode, params: [{ name: 'action', type: 'String', required: true }] },
+  { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode + supportRequestsAppCode + examHistoryCode + teacherEarningsSupport(operationsGatewayCode,payrollIntegrityCode) + teacherEarningsCode + accountDeletionCode + accountSecurityCode + reviewRewardCode + enrollmentAppCode + mobileTeachingCode + studentLessonStatisticsCode + lessonWorkflowCode + lessonAttachmentsCode + wordAudioCode + lessonCoursesCode + lessonUnitProgressCode + lessonStudyCode + legacyAppointmentDefaults(operationsGatewayCode) + legacyWritingStageSupport(payrollIntegrityCode) + legacyAppointmentCode, params: [{ name: 'action', type: 'String', required: true }] },
 ];
 
 function validateDefinition(definition) {

+ 40 - 0
scripts/tests/student-lesson-statistics.test.mjs

@@ -0,0 +1,40 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';import {execFileSync} from 'node:child_process';import {PGlite} from '@electric-sql/pglite';
+const deploy=process.env.STATS_SOURCE_REV?execFileSync('git',['show',process.env.STATS_SOURCE_REV+':scripts/deploy-admin-functions.mjs'],{encoding:'utf8',maxBuffer:10*1024*1024}):fs.readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');const start=deploy.indexOf('async function orderStatistics('),fn=deploy.slice(start,deploy.indexOf('\nfunction appOverviewDateKey',start));
+test('homepage counts completed lesson records even while appointments retain pending states; mixed and trial durations use actual minutes without writes',async()=>{
+ const db=new PGlite();try{await db.exec(fs.readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8')+';ALTER TABLE "CourseAppointment" ADD dszt text;');await db.exec(`CREATE FUNCTION xiaoshu_safe_shanghai_timestamp(text) RETURNS timestamptz LANGUAGE sql IMMUTABLE AS $$SELECT NULL::timestamptz$$;CREATE TABLE "PracticeRecord"(company text,yhid text);INSERT INTO "PracticeRecord" VALUES('co','20');ALTER TABLE "CourseAppointment" ADD dslx text;
+ INSERT INTO "LessonRecord"("objectId",company,id,xymz,kclx,yyds) VALUES('a','co',1,'20','2','101'),('b','co',2,'20','1','102'),('trial','co',3,'20','3','103'),('duplicate','co',4,'20','2','101'),('void','co',5,'20','2','104'),('foreign','other',6,'20','2','105');
+ INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status) VALUES('ca','co',54,11,101,99),('cb','co',54,12,102,99),('ct','co',54,13,103,99),('cv','co',59,5,104,-2);
+ INSERT INTO "CourseAppointment"("objectId",company,id,szyh,"durationMinutes",dszt,dslx) VALUES('pa','co',11,'20',60,'0','2'),('pb','co',12,'20',30,'10','1'),('pt','co',13,'20',30,'30','3');`);
+ const calls=[],query=async(s,p)=>{assert.match(s,/^\s*(SELECT|WITH)\b/);calls.push(s);return(await db.query(s,p)).rows};const ctx=vm.createContext({DEFAULT_COMPANY_ID:'co',number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,authorizeRequestedUser:async()=>20,ownLegacyId:()=>20,Psql:{query,one:async(s,p)=>(await query(s,p))[0]}});
+ vm.runInContext(fs.readFileSync(new URL('../cloud/student-lesson-statistics.js',import.meta.url),'utf8')+'\n'+fn,ctx);
+ const before=(await db.query('SELECT * FROM "CourseAppointment" ORDER BY id')).rows;
+ const r=await ctx.orderStatistics({uid:20},{get:k=>k==='legacyGroupId'?1:undefined});
+ assert.equal(r.t_shichang,'2');assert.equal(r.t30,'1');assert.equal(r.t60,'1');assert.equal(r.t_tiyan,'1');assert.equal(r.t_total,'1');assert.deepEqual((await db.query('SELECT * FROM "CourseAppointment" ORDER BY id')).rows,before);
+ }finally{await db.close();}
+});
+
+test('18 one-hour completed lessons plus a one-hour trial produce 19 hours independently of stale appointment states',async()=>{
+ const db=new PGlite();try{await db.exec(fs.readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8')+';ALTER TABLE "CourseAppointment" ADD dszt text;');await db.exec(`CREATE FUNCTION xiaoshu_safe_shanghai_timestamp(text) RETURNS timestamptz LANGUAGE sql IMMUTABLE AS $$SELECT NULL::timestamptz$$;INSERT INTO "LessonRecord"("objectId",company,id,xymz,kclx,yyds) SELECT 'lesson'||i,'co',i,'1850',CASE WHEN i=19 THEN '3' ELSE '2' END,(100+i)::text FROM generate_series(1,19)i;`);const ctx=vm.createContext({Psql:{query:async(s,p)=>(await db.query(s,p)).rows}});vm.runInContext(fs.readFileSync(new URL('../cloud/student-lesson-statistics.js',import.meta.url),'utf8'),ctx);const r=await ctx.studentLessonStatistics('co',1850);assert.equal(r.total,19);assert.equal(r.t60,18);assert.equal(r.tiyan,1);assert.equal(Number(r.totalMinutes),1140);}finally{await db.close();}
+});
+
+test('unresolved durations and ended appointments without lesson facts are unknown; empty new accounts are zero',async()=>{
+ const db=new PGlite();try{await db.exec(fs.readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8')+';ALTER TABLE "CourseAppointment" ADD dszt text;');await db.exec(`CREATE FUNCTION xiaoshu_safe_shanghai_timestamp(text) RETURNS timestamptz LANGUAGE sql IMMUTABLE AS $$SELECT NULL::timestamptz$$;
+ INSERT INTO "LessonRecord"("objectId",company,id,xymz,kclx,yyds) VALUES('unknown','co',1,'20','4','100');INSERT INTO "CourseAppointment"("objectId",company,szyh,dszt) VALUES('no-facts','co','21','30');`);const ctx=vm.createContext({Psql:{query:async(s,p)=>(await db.query(s,p)).rows}});vm.runInContext(fs.readFileSync(new URL('../cloud/student-lesson-statistics.js',import.meta.url),'utf8'),ctx);
+ assert.equal((await ctx.studentLessonStatistics('co',20)).totalMinutes,null);assert.equal((await ctx.studentLessonStatistics('co',21)).totalMinutes,null);assert.equal((await ctx.studentLessonStatistics('co',22)).totalMinutes,0);
+ }finally{await db.close();}
+});
+
+test('same-student completion report outranks reservation duration; foreign reports and invalid numbers cannot inflate hours',async()=>{
+ const db=new PGlite();try{await db.exec(fs.readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8')+';ALTER TABLE "CourseAppointment" ADD dszt text;');await db.exec(`CREATE FUNCTION xiaoshu_safe_shanghai_timestamp(text) RETURNS timestamptz LANGUAGE sql IMMUTABLE AS $$SELECT NULL::timestamptz$$;
+ INSERT INTO "LessonRecord"("objectId",company,id,xymz,kclx,yyds) VALUES('a','co',1,'20','2','101'),('b','co',2,'20','1','102'),('c','co',3,'20','4','103');
+ INSERT INTO "CourseAppointment"("objectId",company,id,szyh,"durationMinutes") VALUES('pa','co',11,'20',60),('pb','co',12,'99',60);
+ INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId") VALUES('ac','co',54,11,101),('bc','co',54,12,102);
+ INSERT INTO "LessonCompletionReport"("objectId",company,"lessonId","studentId","appointmentGeneralId","durationMinutes") VALUES('valid','co','a',20,101,30),('foreign','co','b',99,102,60),('invalid','co','c',20,103,'NaN');`);const ctx=vm.createContext({Psql:{query:async(s,p)=>(await db.query(s,p)).rows}});vm.runInContext(fs.readFileSync(new URL('../cloud/student-lesson-statistics.js',import.meta.url),'utf8'),ctx);const r=await ctx.studentLessonStatistics('co',20);assert.equal(r.t30,2);assert.equal(r.unknownDurationCount,1);assert.equal(r.totalMinutes,null);
+ }finally{await db.close();}
+});
+
+test('partially missing lesson facts remain unknown while cancelled appointments do not count as missing',async()=>{
+ const db=new PGlite();try{await db.exec(fs.readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8')+';ALTER TABLE "CourseAppointment" ADD dszt text;');await db.exec(`CREATE FUNCTION xiaoshu_safe_shanghai_timestamp(text) RETURNS timestamptz LANGUAGE sql IMMUTABLE AS $$SELECT NULL::timestamptz$$;
+ INSERT INTO "LessonRecord"("objectId",company,id,xymz,kclx,yyds) VALUES('a','co',1,'20','2','101');INSERT INTO "CourseAppointment"("objectId",company,id,szyh,dszt) VALUES('a','co',11,'20','30'),('b','co',12,'20','30'),('c','co',13,'20','30');INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status) VALUES('a','co',54,11,101,99),('b','co',54,12,102,99),('c','co',54,13,103,-2);`);const ctx=vm.createContext({Psql:{query:async(s,p)=>(await db.query(s,p)).rows}});vm.runInContext(fs.readFileSync(new URL('../cloud/student-lesson-statistics.js',import.meta.url),'utf8'),ctx);const r=await ctx.studentLessonStatistics('co',20);assert.equal(r.total,1);assert.equal(r.unknownDurationCount,1);assert.equal(r.totalMinutes,null);
+ }finally{await db.close();}
+});