Selaa lähdekoodia

fix(data): sync legacy review plans in real time

彭峰 4 viikkoa sitten
vanhempi
commit
715b837a56

+ 2 - 1
scripts/cloud/mobile-teaching.js

@@ -22,6 +22,7 @@ async function appLessonReport(input,current) {
 }
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
+  const reviewTarget=coach&&number(input.studentId)?await appLearningTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined;
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
   const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
   const students=coach?await Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',args):[];
@@ -37,7 +38,7 @@ async function appReviewPage(input,current) {
   const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
   const rows=await Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",COALESCE(u."realName",u."nickname",u."username") AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewReward" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+' ORDER BY m."kywsj" DESC,m."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
-  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,income:coach?await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0},dates,students,...mobileSource(),...await appLiveVersionSnapshot(versionTarget,['review','account'],current)};
+  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,income:coach?await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0},dates,students,legacyReviewSync,...mobileSource(),...await appLiveVersionSnapshot(versionTarget,['review','account'],current)};
 }
 
 function mobileCommandKey(input) {const key=String(input.requestKey||'');return /^[A-Za-z0-9:_-]{10,160}$/.test(key)?key:null;}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 4 - 0
scripts/deploy-admin-functions.mjs


+ 1 - 1
scripts/sql/mobile-teaching.sql

@@ -21,7 +21,7 @@ BEGIN
   END IF;
   FOR op IN SELECT value FROM jsonb_array_elements(p_operations) LOOP
     tab := op->>'table';
-    IF tab NOT IN ('PracticeRecord','DailyStudyRecord','CommonModel','UnitLearningRecord','AssessmentProfile','SurveyItem','SurveyLog','DesignAnswer','LessonCompletionReport','LessonRecord') THEN RAISE EXCEPTION 'Teaching table not allowed'; END IF;
+    IF tab NOT IN ('PracticeRecord','DailyStudyRecord','MemoryPracticeRecord','CommonModel','UnitLearningRecord','AssessmentProfile','SurveyItem','SurveyLog','DesignAnswer','LessonCompletionReport','LessonRecord') THEN RAISE EXCEPTION 'Teaching table not allowed'; END IF;
     data := op->'data'; criteria := op->'match';
     IF criteria IS NULL OR criteria='{}' THEN RAISE EXCEPTION 'Missing business key'; END IF;
     IF op ? 'reference' THEN

+ 2 - 2
scripts/sql/review-rewards.sql

@@ -144,7 +144,7 @@ BEGIN
      NEW."fxzt" := OLD."fxzt"; NEW."wcsj" := OLD."wcsj"; NEW."plid" := OLD."plid";
      RETURN NEW;
    END IF;
-   IF NEW."fxzt"::text='1' THEN
+   IF NEW."fxzt"::text='1' AND NOT (COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL) THEN
      NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
    END IF;
  ELSE
@@ -156,7 +156,7 @@ CREATE OR REPLACE FUNCTION xiaoshu_review_after_write() RETURNS trigger LANGUAGE
 BEGIN
  IF NEW."fxzt"::text='1' THEN
    IF TG_OP='INSERT' THEN PERFORM xiaoshu_record_review_reward(NEW,true);
-   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1'); END IF;
+   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%'); END IF;
  END IF;
  RETURN NEW;
 END $$;

+ 108 - 0
scripts/tests/legacy-review-pull.test.mjs

@@ -0,0 +1,108 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import { readFileSync } from 'node:fs';
+
+const gatewaySource = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+const source = gatewaySource.match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]*?\nconst APP_LIVE_SCOPES=/)?.[0]
+  .replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/, '');
+assert.ok(source, '未找到旧系统抗遗忘记录拉取实现');
+
+function contextFor(rows) {
+  let sequence = 0;
+  let stored = [];
+  const writes = [];
+  const context = {
+    console,
+    URLSearchParams,
+    AbortSignal,
+    Map,
+    Date,
+    JSON,
+    String,
+    Math,
+    Promise,
+    DEFAULT_COMPANY_ID: 'company',
+    number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; },
+    firstValue(...values) { return values.find((value) => value !== undefined && value !== null && value !== ''); },
+    legacyLearningIso(value) { const parsed = new Date(String(value).replace(' ', 'T') + '+08:00'); return parsed.toISOString(); },
+    randomObjectId() { sequence += 1; return `object${String(sequence).padStart(14, '0')}`; },
+    async appLegacyConfig() { return { url: 'https://legacy.example/api', apiId: 'id', apiKey: 'key' }; },
+    async fetch(url) {
+      assert.match(String(url), /myfield2=yhid%3D42/);
+      return { ok: true, async text() { return JSON.stringify({ retcode: 1, result: JSON.stringify(rows), page: { itemCount: rows.length, pageCount: 1 } }); } };
+    },
+    Psql: {
+      async one(sql) {
+        if (sql.includes('COUNT(*)') && sql.includes('MemoryPracticeRecord')) return { total: stored.length };
+        throw new Error(`unexpected one sql: ${sql}`);
+      },
+      async query(sql) {
+        if (sql.includes('SELECT c."generalId"')) return stored;
+        if (sql.includes('SELECT CAST("id" AS text)')) return stored.map((row) => ({ id: row.itemId }));
+        throw new Error(`unexpected query sql: ${sql}`);
+      },
+    },
+    async mobileAtomicWrite(_input, _current, operations) {
+      writes.push(operations);
+      for (let index = 0; index < operations.length; index += 2) {
+        const addon = operations[index].data;
+        const common = operations[index + 1].data;
+        stored = stored.filter((item) => Number(item.generalId) !== Number(common.generalId));
+        stored.push({ ...common, ...addon, itemId: addon.id });
+      }
+      return operations.map((operation) => operation.data);
+    },
+  };
+  vm.runInNewContext(`${source};this.normalize=legacyReviewRecord;this.sync=performLegacyReviewSync`, context);
+  return { context, writes };
+}
+
+test('旧系统抗遗忘记录只接收当前学员并保留计划与完成状态', () => {
+  const { context } = contextFor([]);
+  assert.equal(context.normalize({ GeneralID: 1, yhid: 7 }, 42), null);
+  const row = context.normalize({
+    GeneralID: 866548,
+    CreateTime: '2026-09-24 20:11:52',
+    Title: '学员@21天抗遗忘@2026-09-24',
+    yhid: 42,
+    plid: 155,
+    orderId: 859224,
+    kcid: 171,
+    kywsj: '2026-09-24 19:00',
+    kywrq: '2026-09-24',
+    xxjlid: 866545,
+    fxzt: '1',
+    wcsj: '2026-09-24 20:11',
+  }, 42);
+  assert.equal(row.generalId, 866548);
+  assert.equal(row.addon.yhid, 42);
+  assert.equal(row.addon.xxjlid, 866545);
+  assert.equal(row.addon.fxzt, '1');
+  assert.equal(row.addon.wcsj, '2026-09-24 20:11');
+});
+
+test('抗遗忘记录首次补齐后重复核对不再写入', async () => {
+  const rows = [{
+    GeneralID: 866548,
+    CreateTime: '2026-09-24 20:11:52',
+    Title: '学员@21天抗遗忘@2026-09-24',
+    yhid: 42,
+    plid: 155,
+    orderId: 859224,
+    kcid: 171,
+    kywsj: '2026-09-24 19:00',
+    kywrq: '2026-09-24',
+    xxjlid: 866545,
+    fxzt: '1',
+    wcsj: '2026-09-24 20:11',
+  }];
+  const { context, writes } = contextFor(rows);
+  const first = await context.sync(42, { id: 'actor' }, true);
+  const second = await context.sync(42, { id: 'actor' }, true);
+  assert.equal(first.written, 1);
+  assert.equal(second.written, 0);
+  assert.equal(writes.length, 1);
+  assert.equal(writes[0][0].table, 'MemoryPracticeRecord');
+  assert.equal(writes[0][1].match.generalId, 866548);
+});

+ 2 - 1
scripts/tests/mobile-teaching.test.mjs

@@ -3,12 +3,13 @@ const sql=readFileSync(new URL('../sql/mobile-teaching.sql',import.meta.url),'ut
 const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
 test('教学事务:重复及并发请求只增一次,关联记录同事务,失败整体回滚',async()=>{
  const db=new PGlite();try{
- await db.exec(`CREATE TABLE "PracticeRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"yhid" int,"scid" int,"xxcs" int);CREATE TABLE "DailyStudyRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,id bigint,"sourceKey" text);CREATE TABLE "CommonModel"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"itemId" bigint,"generalId" bigint,"sourceKey" text);`);await db.exec(sql);
+ await db.exec(`CREATE TABLE "PracticeRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"yhid" int,"scid" int,"xxcs" int);CREATE TABLE "DailyStudyRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,id bigint,"sourceKey" text);CREATE TABLE "MemoryPracticeRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,id bigint,"sourceKey" text,"yhid" int,"fxzt" text);CREATE TABLE "CommonModel"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"itemId" bigint,"generalId" bigint,"sourceKey" text);`);await db.exec(sql);
  const run=(key,ops,fingerprint='same')=>db.query('select xs_mobile_teaching_write($1,$2,$3,$4,$5::jsonb) result',['co','learner',key,fingerprint,JSON.stringify(ops)]);
  const op={table:'PracticeRecord',match:{company:'co',yhid:1,scid:2},data:{objectId:'word',createdAt:new Date(),company:'co',yhid:1,scid:2,xxcs:1},increment:['xxcs']};
  await Promise.all(Array.from({length:8},()=>run('request-0001',[op])));assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord"')).rows[0].xxcs,1);
  await run('request-0002',[op]);assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord"')).rows[0].xxcs,2);
  await assert.rejects(run('request-0001',[op],'changed'),/different content/);
+ const review={table:'MemoryPracticeRecord',match:{company:'co',id:88},data:{objectId:'review',createdAt:new Date(),company:'co',id:88,sourceKey:'legacy:model:60:general:88',yhid:1,fxzt:'0'}};await run('request-review-0001',[review]);assert.equal((await db.query('SELECT COUNT(*) n FROM "MemoryPracticeRecord"')).rows[0].n,1);
  await assert.rejects(run('request-0003',[op,{table:'Wrong',match:{id:1},data:{}}]),/not allowed/);assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord"')).rows[0].xxcs,2);
  const pair=[{table:'DailyStudyRecord',match:{company:'co',sourceKey:'study'},data:{objectId:'d',createdAt:new Date(),company:'co',id:77,sourceKey:'study'}},{table:'CommonModel',match:{company:'co',sourceKey:'study'},data:{objectId:'c',createdAt:new Date(),company:'co',itemId:999,generalId:88,sourceKey:'study'},reference:{itemId:'id'}}];
  await run('request-0004',pair);await run('request-0005',pair);assert.equal((await db.query('SELECT "itemId" FROM "CommonModel"')).rows[0].itemId,77);assert.equal((await db.query('SELECT COUNT(*) n FROM "DailyStudyRecord"')).rows[0].n,1);

+ 9 - 3
scripts/tests/review-rewards.test.mjs

@@ -9,14 +9,14 @@ const query = async (sql, params=[]) => (await db.query(sql,params)).rows;
 const one = async (sql,params=[]) => (await query(sql,params))[0];
 const complete = (id) => query('SELECT xiaoshu_complete_review($1,$2)',['c',id]);
 async function task(id, day, extra={}) {
- const {coach='10',status='0',study='100',student='20'}=extra;
- await query('INSERT INTO "MemoryPracticeRecord" ("objectId","company","id","plid","yhid","xxjlid","kywrq","kywsj","kcid","fxzt","wcsj") VALUES ($1,\'c\',1,$2,$3,$4,$5,$5,30,$6,\'2026-08-01 10:00:00\')',[id,coach,student,study,day,status]);
+ const {coach='10',status='0',study='100',student='20',sourceKey=''}=extra;
+ await query('INSERT INTO "MemoryPracticeRecord" ("objectId","company","sourceKey","id","plid","yhid","xxjlid","kywrq","kywsj","kcid","fxzt","wcsj") VALUES ($1,\'c\',$2,1,$3,$4,$5,$6,$6,30,$7,\'2026-08-01 10:00:00\')',[id,sourceKey,coach,student,study,day,status]);
 }
 before(async()=>{
  await db.exec(`CREATE TABLE "_User" ("objectId" text,"company" text,"legacyUserId" double precision,"legacyUserData" jsonb,"realName" text,"nickname" text,"username" text);
  CREATE TABLE "Node" ("company" text,"nodeId" double precision,"nodeName" text);
  CREATE TABLE "CommonModel" ("objectId" text,"company" text,"modelId" double precision,"generalId" double precision);
- CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"plid" text,"yhid" text,"xxjlid" text,"kywrq" text,"kywsj" text,"kcid" double precision,"fxzt" text,"wcsj" text,"updatedAt" timestamptz);
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text,"id" double precision,"plid" text,"yhid" text,"xxjlid" text,"kywrq" text,"kywsj" text,"kcid" double precision,"fxzt" text,"wcsj" text,"updatedAt" timestamptz);
  CREATE TABLE "PayrollBatch" ("objectId" text PRIMARY KEY,"company" text,"month" text,"status" text,"baseAmount" double precision,"adjustmentAmount" double precision,"payableAmount" double precision,"totalHours" double precision,"lessonCount" double precision);
  CREATE TABLE "PayrollLine" ("objectId" text PRIMARY KEY,"company" text,"batchId" text,"kind" text,"excluded" boolean,"coachId" double precision,"coachName" text,"studentName" text,"courseName" text,"storeId" double precision,"storeName" text,"amount" double precision,"rateAmount" double precision,"durationHours" double precision,"originalMonth" text,"settlementMonth" text,"lessonAt" timestamptz,"exception" text,"classType" double precision,"courseCategoryName" text);
  INSERT INTO "_User" VALUES ('teacher','c',10,'{"ParentUserID":40}','老师','','teacher'),('student','c',20,'{}','学生','','student'),('store','c',40,'{}','门店','','store');
@@ -51,6 +51,12 @@ test('历史重复来源和缺失老师、来源的记录不计薪',async()=>{
 test('已完成导入任务进入历史待核对',async()=>{
  await task('import','2026-09-06',{status:'1'});await complete('import');assert.equal((await one('SELECT "status" FROM "ReviewReward" WHERE "taskId"=\'import\'')).status,'historical');
 });
+test('旧系统同步完成状态保留原完成时间且不自动计薪',async()=>{
+ await task('legacy-sync','2026-09-08',{sourceKey:'legacy:model:60:general:800'});
+ await query('UPDATE "MemoryPracticeRecord" SET "fxzt"=\'1\',"wcsj"=\'2026-09-09 20:30:00\' WHERE "objectId"=\'legacy-sync\'');
+ const reward=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'legacy-sync\''),record=await one('SELECT "wcsj" FROM "MemoryPracticeRecord" WHERE "objectId"=\'legacy-sync\'');
+ assert.equal(reward.status,'historical');assert.equal(Number(reward.amount),0);assert.equal(record.wcsj,'2026-09-09 20:30:00');
+});
 test('奖励插入失败时完成状态一起回滚',async()=>{
  await task('rollback','2026-09-07');
  await db.exec(`CREATE FUNCTION test_fail_reward() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW."taskId"='rollback' THEN RAISE EXCEPTION 'injected failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_fail_reward BEFORE INSERT ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION test_fail_reward();`);

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä