Explorar el Código

test: cover member training count and reconciled credit balance

彭峰 hace 5 horas
padre
commit
c79cc853cf

+ 27 - 0
docs/operations/fan-wenhao-balance-and-training-count-2026-11-11.md

@@ -0,0 +1,27 @@
+# 樊文昊高中余额与课次数核对(2026-11-11)
+
+学员 1826 共有 35 次正式 60 分钟课和 1 次体验课,36 次均已完成,应剩余 0 小时。
+
+## 余额变成 −2 的原因与处理
+
+11 月 7 日凌晨的历史余额对账曾把 19 修正为 0。随后,旧系统在 11 月 7 日 21:33 人工扣除 2 次 60 分钟课(原流水 25756,备注“新系统已上2小时”),并扣除配套陪练时长 2 小时(原流水 27443)。新系统 11 月 1 日、6 日的预约 886079、886080 已各扣 1 小时;11 月 8 日同步将旧系统这笔人工补记再次计入,余额从 0 变成 −2。
+
+通过现有会员差错调整接口成套退回 2 次 60 分钟课和 2 小时陪练时长,两个余额均恢复 0。操作编号 `999715eb12c06e13ec98`,幂等键 `repair-1826-manual-25756-27443-20261111`。原 76 行流水逐字段保留,新增 2 行差错调整;36 条训练记录保留。没有修改旧系统原始数据或直接覆盖余额。
+
+用线上当前 `legacyCreditBalancePlan` 函数及正式来源状态、本地流水做只读演算:两个账户均返回 ready、余额 0、调整差额 0。本次未重跑完整夜间同步,后续对账继续保留已入账的本地差错调整。
+
+## 概览 37 与训练记录 36 的原因与修复
+
+概览原先直接计数 `DailyStudyRecord`。8 月 5 日预约 823395 有两条学习明细(824602、824603,分别记录 6 个和 36 个单词),同一课次被计为两条。训练记录页按预约归并,显示 36 节正确。
+
+`queryUsers` 的概览计数改为按 `LessonRecord` 的帐套、学员及课次归并,与训练记录页保持一致:同一预约只计一次,排除作废课次,没有预约号的历史课次各自保留。全部 37 条学习明细逐字段保留,不删除词汇或进度。接口字段仍为 `studyRecordCount`,无需发布前端。
+
+## 验证与发布
+
+- 新增 PostgreSQL 回归测试先复现 37≠36,再验证通过;涵盖多个学习明细、重复预约、作废记录、无预约课次及帐套隔离。
+- 会员概览、训练记录及门店人数相关 15 项测试通过;11 个云函数定义校验通过。
+- 两个运营网关只替换 `queryUsers` 中的计数查询;其他线上函数和该函数的其他查询逐项保留,发布后源码回读一致。
+- 两个正式运营网关的学员详情回读:学习记录 36、60 分钟课时 0、陪练时长 0;会员列表及训练记录接口一致。
+- 本任务修复的计数查询在共享分支的并发提交 `8cd39f2` 中已进入 Git;本次补交独立回归测试与核对记录,没有改写该提交或夹带其他工作区修改。
+
+私有核对、源数据、调整回执、发布前后代码与验证结果保存在 `~/.local/state/xiaoshu/fanwenhao-20261111/`。回退计数查询只需恢复该查询行;余额回退必须结合此后新增业务流水复核,不能覆盖账户快照。

+ 36 - 0
scripts/tests/member-training-count.test.mjs

@@ -0,0 +1,36 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite(),query=async(sql,args=[])=>(await db.query(sql,args)).rows;
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const start=source.indexOf('async function queryUsers(context,input)');
+const code=source.slice(start,source.indexOf('\nfunction pageInput(',start));
+const ctx={Psql:{query,one:async(sql,args)=>(await query(sql,args))[0]},companyIdOf:c=>c.company,text:v=>String(v??'').trim(),number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,legacyOf:r=>r.legacyUserData||{},displayName:r=>r.nickname,normalizeUser:r=>r,memberAppointmentStats:async()=>[],queryUsersLegacy:()=>{throw Error('Unexpected legacy request');}};
+vm.createContext(ctx);vm.runInContext(code,ctx);
+before(async()=>{
+ await db.exec(`CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"legacyUserData" jsonb,"identityType" text,"isDeleted" boolean,"isAdmin" boolean,username text,nickname text,"realName" text,"registeredAt" timestamptz,"createdAt" timestamptz);
+ CREATE TABLE "Group"(company text,"groupId" numeric,"groupName" text);CREATE TABLE "Manager"(company text,"addUserId" numeric,"userName" text,"adminName" text);
+ CREATE TABLE "CourseBinding"(company text,id numeric,yhid text,cksl text,yxx text);
+ CREATE TABLE "DailyStudyRecord"(company text,"userId" numeric,"objectId" text,dsid text);
+ CREATE TABLE "CommonModel"(company text,"objectId" text,"modelId" numeric,"itemId" numeric,"generalId" numeric,status numeric,"createdAt" timestamptz);
+ CREATE TABLE "CourseAppointment"(company text,id numeric,jssj text);
+ CREATE TABLE "LessonRecord"(company text,"objectId" text,id numeric,xymz text,yyds text,kclx text,"lessonAt" timestamptz,plpjsj text);
+ INSERT INTO "_User" VALUES('student','co',1826,1,'{}','member',false,false,'student','学员','',now(),now());
+ INSERT INTO "LessonRecord" SELECT 'co','lesson-'||i,i,'1826',(1000+i)::text,CASE WHEN i=1 THEN '3' ELSE '2' END,now(),'' FROM generate_series(1,36) i;
+ INSERT INTO "CommonModel" SELECT 'co','common-'||i,59,i,2000+i,99,now() FROM generate_series(1,36) i;
+ INSERT INTO "DailyStudyRecord" SELECT 'co',1826,'study-'||i,(1000+i)::text FROM generate_series(1,36) i;
+ INSERT INTO "DailyStudyRecord" VALUES('co',1826,'second-word-batch','1002');`);
+});
+after(()=>db.close());
+test('会员概览按已上课次统计:36节课有37条学习明细仍显示36,原明细保留',async()=>{
+ const result=await ctx.queryUsers({company:'co'},{});assert.equal(result[0].studyRecordCount,36);
+ assert.equal((await query('SELECT count(*)::int n FROM "DailyStudyRecord"'))[0].n,37);
+});
+test('概览课次数排除作废、合并重复预约,保留无预约的独立课次并隔离帐套',async()=>{
+ await db.exec(`INSERT INTO "LessonRecord" VALUES('co','duplicate',37,'1826','1002','2',now(),''),('co','void',38,'1826','9998','2',now(),''),('other','other-tenant',39,'1826','9999','2',now(),''),('co','unlinked-a',40,'1826',null,'2',now(),''),('co','unlinked-b',41,'1826','0','2',now(),'');
+ INSERT INTO "CommonModel" VALUES('co','void-common',59,38,9998,-2,now());`);
+ const result=await ctx.queryUsers({company:'co'},{});assert.equal(result[0].studyRecordCount,38);
+ assert.equal((await ctx.queryUsers({company:'empty'},{})).length,0);
+});