legacy-review-sync.sql 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. -- Apply after legacy-direct-sync.sql and review-reconciliation.sql.
  2. -- Source-owned versions are separate from human transfers; original receipts stay immutable.
  3. BEGIN;
  4. SET LOCAL lock_timeout='10s';
  5. CREATE TABLE IF NOT EXISTS "LegacyReviewSyncState" (
  6. company text NOT NULL,"taskId" text NOT NULL,"assignmentVersion" numeric NOT NULL,
  7. fingerprint text NOT NULL,"pendingIncome" boolean NOT NULL DEFAULT false,
  8. "lastError" text NOT NULL DEFAULT '',"updatedAt" timestamptz NOT NULL DEFAULT now(),
  9. PRIMARY KEY(company,"taskId")
  10. );
  11. CREATE INDEX IF NOT EXISTS legacy_review_income_pending ON "LegacyReviewSyncState"(company,"taskId") WHERE "pendingIncome";
  12. CREATE TABLE IF NOT EXISTS "LegacyReviewSyncAudit" (
  13. "objectId" text PRIMARY KEY,company text NOT NULL,"taskId" text NOT NULL,
  14. before_data jsonb NOT NULL,after_data jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now()
  15. );
  16. REVOKE ALL ON "LegacyReviewSyncState","LegacyReviewSyncAudit" FROM PUBLIC;
  17. CREATE OR REPLACE FUNCTION xs_legacy_review_owned(co text, task_id text) RETURNS boolean LANGUAGE sql STABLE AS $$
  18. SELECT COALESCE((COALESCE(m."assignmentVersion",0)=0 OR EXISTS(
  19. SELECT 1 FROM "LegacyReviewSyncState" s WHERE s.company=m.company AND s."taskId"=m."objectId"
  20. AND s."assignmentVersion"=m."assignmentVersion" AND s.fingerprint=md5(to_jsonb(m)::text)))
  21. AND COALESCE(m."sourceKey",'') LIKE 'legacy:model:60:general:%'
  22. AND m.fxzt::text<>'1' AND NOT EXISTS(SELECT 1 FROM "ReviewReward" r WHERE r.company=m.company AND r."taskId"=m."objectId"),false)
  23. FROM "MemoryPracticeRecord" m WHERE m.company=co AND m."objectId"=task_id
  24. $$;
  25. CREATE OR REPLACE FUNCTION xs_legacy_review_track(co text, task_id text, expected_version numeric DEFAULT 0) RETURNS void LANGUAGE plpgsql AS $$
  26. DECLARE m jsonb; c jsonb; pending boolean;
  27. BEGIN
  28. SELECT to_jsonb(t) INTO m FROM "MemoryPracticeRecord" t WHERE t.company=co AND t."objectId"=task_id FOR UPDATE;
  29. SELECT to_jsonb(t) INTO c FROM "CommonModel" t WHERE t.company=co AND t."modelId"=60 AND t."itemId"=(m->>'id')::numeric;
  30. IF COALESCE((m->>'assignmentVersion')::numeric,0)<>expected_version THEN RETURN; END IF;
  31. IF m IS NULL OR c IS NULL OR m->>'sourceKey' NOT LIKE 'legacy:model:60:general:%' THEN RAISE EXCEPTION '旧复习来源不完整'; END IF;
  32. SELECT EXISTS(SELECT 1 FROM "ReviewRewardIncome" r WHERE r.company=co AND r."taskId"=task_id AND r."incomeMonth">='2026-09' AND r.status IN ('historical','exception')) INTO pending;
  33. INSERT INTO "LegacyReviewSyncState"(company,"taskId","assignmentVersion",fingerprint,"pendingIncome")
  34. VALUES(co,task_id,COALESCE((m->>'assignmentVersion')::numeric,0),md5(m::text),pending)
  35. ON CONFLICT(company,"taskId") DO UPDATE SET "assignmentVersion"=EXCLUDED."assignmentVersion",fingerprint=EXCLUDED.fingerprint,"pendingIncome"=EXCLUDED."pendingIncome","lastError"='',"updatedAt"=now();
  36. INSERT INTO "LegacyDirectBaseline"(company,model,gid,fingerprint) VALUES(co,60,(c->>'generalId')::numeric,md5(jsonb_build_array(c,m)::text))
  37. ON CONFLICT(company,model,gid) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,"updatedAt"=now();
  38. END $$;
  39. -- Bootstrap only records whose complete business fields still match the live old source.
  40. -- Once a baseline exists, an unexplained local modification can never be re-adopted.
  41. CREATE OR REPLACE FUNCTION xs_legacy_review_observe(co text, source_rows jsonb) RETURNS integer LANGUAGE plpgsql AS $$
  42. DECLARE row_data record; n integer:=0;
  43. BEGIN
  44. IF jsonb_typeof(source_rows)<>'array' OR jsonb_array_length(source_rows)>1000 THEN RAISE EXCEPTION '无效旧复习来源页'; END IF;
  45. FOR row_data IN SELECT m."objectId" FROM jsonb_array_elements(source_rows) s
  46. JOIN "CommonModel" c ON c.company=co AND c."modelId"=60 AND c."generalId"=(s->>'GeneralID')::numeric
  47. JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId"
  48. LEFT JOIN "LegacyDirectBaseline" b ON b.company=c.company AND b.model=60 AND b.gid=c."generalId"
  49. WHERE m.fxzt='0' AND COALESCE(m."assignmentVersion",0)=0 AND m."sourceKey" LIKE 'legacy:model:60:general:%'
  50. AND NOT COALESCE(c."teachingManaged",false) AND COALESCE(c.status,99)<>-2
  51. AND NOT EXISTS(SELECT 1 FROM "ReviewReward" r WHERE r.company=m.company AND r."taskId"=m."objectId")
  52. AND NOT EXISTS(SELECT 1 FROM "LegacyReviewSyncState" t WHERE t.company=m.company AND t."taskId"=m."objectId")
  53. AND (b.fingerprint IS NULL OR b.fingerprint=md5(jsonb_build_array(to_jsonb(c),to_jsonb(m))::text))
  54. AND jsonb_build_array(m.yhid::text,m.plid::text,m.xxjlid::text,m."orderId"::text,m.kcid::text,COALESCE(m.fxzt,''),COALESCE(m.wcsj,''),COALESCE(m.kywrq,''),COALESCE(m.kywsj,''))=
  55. jsonb_build_array(s->>'yhid',s->>'plid',s->>'xxjlid',s->>'orderId',s->>'kcid',COALESCE(s->>'fxzt',''),COALESCE(s->>'wcsj',''),COALESCE(s->>'kywrq',''),COALESCE(s->>'kywsj',''))
  56. FOR UPDATE OF m,c
  57. LOOP PERFORM xs_legacy_review_track(co,row_data."objectId");n:=n+1;END LOOP;
  58. RETURN n;
  59. END $$;
  60. REVOKE ALL ON FUNCTION xs_legacy_review_observe(text,jsonb) FROM PUBLIC;
  61. CREATE OR REPLACE FUNCTION xs_legacy_review_patch(co text, task_id text, values_json jsonb, expected jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
  62. DECLARE m "MemoryPracticeRecord"; before_row jsonb; after_row jsonb; c jsonb; incoming jsonb; f text; baseline text;
  63. prior text:=COALESCE(current_setting('xiaoshu.review_transfer',true),'');
  64. BEGIN
  65. SELECT * INTO m FROM "MemoryPracticeRecord" WHERE company=co AND "objectId"=task_id FOR UPDATE;
  66. IF NOT FOUND THEN RETURN jsonb_build_object('applied',false,'reason','复习任务不存在'); END IF;
  67. before_row:=to_jsonb(m);
  68. IF before_row IS DISTINCT FROM expected OR NOT COALESCE(xs_legacy_review_owned(co,task_id),false) THEN RETURN jsonb_build_object('applied',false,'reason','保留新系统转交、完成或计薪凭据'); END IF;
  69. IF jsonb_typeof(values_json)<>'object' OR EXISTS(SELECT 1 FROM jsonb_object_keys(values_json) k WHERE k NOT IN ('plid','fxzt','wcsj')) THEN RETURN jsonb_build_object('applied',false,'reason','保留复习来源身份和计划'); END IF;
  70. SELECT to_jsonb(t) INTO c FROM "CommonModel" t WHERE t.company=co AND t."modelId"=60 AND t."itemId"=m.id FOR UPDATE;
  71. IF c IS NULL OR COALESCE((c->>'teachingManaged')::boolean,false) OR c->>'status'='-2' THEN RETURN jsonb_build_object('applied',false,'reason','来源主表受保护'); END IF;
  72. SELECT fingerprint INTO baseline FROM "LegacyDirectBaseline" WHERE company=co AND model=60 AND gid=(c->>'generalId')::numeric;
  73. IF (baseline IS NOT NULL AND baseline<>md5(jsonb_build_array(c,before_row)::text)) OR
  74. (baseline IS NULL AND (m."createdAt" IS NULL OR c->>'createdAt' IS NULL OR m."createdAt" IS DISTINCT FROM m."updatedAt" OR c->>'createdAt' IS DISTINCT FROM c->>'updatedAt')) THEN
  75. RETURN jsonb_build_object('applied',false,'reason','新系统已有修改或来源基线不可确认');
  76. END IF;
  77. incoming:=before_row||values_json;
  78. IF incoming->>'fxzt' NOT IN ('0','1') OR NULLIF(incoming->>'plid','') IS NULL THEN RAISE EXCEPTION '无效复习来源'; END IF;
  79. IF incoming->>'plid' IS DISTINCT FROM before_row->>'plid' THEN PERFORM xs_review_target(co,incoming->>'plid'); END IF;
  80. IF incoming->>'fxzt'='1' AND xiaoshu_safe_shanghai_timestamp(incoming->>'wcsj') IS NULL THEN RETURN jsonb_build_object('applied',false,'reason','缺少旧系统完成时间'); END IF;
  81. PERFORM set_config('xiaoshu.review_transfer','legacy-sync',true);
  82. UPDATE "MemoryPracticeRecord" SET plid=(incoming->>'plid')::numeric,fxzt=incoming->>'fxzt',wcsj=COALESCE(incoming->>'wcsj',''),
  83. "assignmentVersion"=COALESCE("assignmentVersion",0)+CASE WHEN plid::text IS DISTINCT FROM incoming->>'plid' THEN 1 ELSE 0 END,"updatedAt"=clock_timestamp()
  84. WHERE company=co AND "objectId"=task_id RETURNING to_jsonb("MemoryPracticeRecord".*) INTO after_row;
  85. PERFORM set_config('xiaoshu.review_transfer',prior,true);
  86. INSERT INTO "LegacyReviewSyncAudit"("objectId",company,"taskId",before_data,after_data)
  87. VALUES(md5(co||task_id||before_row::text||after_row::text),co,task_id,before_row,after_row) ON CONFLICT DO NOTHING;
  88. PERFORM xs_legacy_review_track(co,task_id,(after_row->>'assignmentVersion')::numeric);
  89. RETURN jsonb_build_object('applied',true);
  90. EXCEPTION WHEN OTHERS THEN PERFORM set_config('xiaoshu.review_transfer',prior,true);RAISE;
  91. END $$;
  92. CREATE OR REPLACE FUNCTION xs_legacy_review_income(co text, actor text, batch_limit integer DEFAULT 100, after_task text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
  93. DECLARE item record; m "MemoryPracticeRecord"; r "ReviewReward"; best record; expired record; outcome text; err text; n integer:=0; earned integer:=0; excluded integer:=0; amount numeric:=0; pending integer; errors jsonb:='[]'; cursor_id text:=after_task; more boolean; saved_n integer; saved_earned integer; saved_excluded integer; saved_amount numeric;
  94. BEGIN
  95. PERFORM xs_review_manager(co,actor);
  96. IF batch_limit NOT BETWEEN 1 AND 500 THEN RAISE EXCEPTION '无效复习核定批量'; END IF;
  97. LOCK TABLE "MemoryPracticeRecord","ReviewReward" IN SHARE ROW EXCLUSIVE MODE;
  98. PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
  99. FOR item IN SELECT s.* FROM "LegacyReviewSyncState" s WHERE s.company=co AND s."pendingIncome" AND s."taskId">after_task ORDER BY s."taskId" LIMIT batch_limit FOR UPDATE LOOP
  100. cursor_id:=item."taskId";saved_n:=n;saved_earned:=earned;saved_excluded:=excluded;saved_amount:=amount;
  101. BEGIN
  102. SELECT * INTO m FROM "MemoryPracticeRecord" WHERE company=co AND "objectId"=item."taskId" FOR UPDATE;
  103. SELECT * INTO r FROM "ReviewReward" WHERE company=co AND "taskId"=item."taskId" FOR UPDATE;
  104. IF m."objectId" IS NULL OR r."objectId" IS NULL OR item.fingerprint<>md5(to_jsonb(m)::text) OR COALESCE(m."assignmentVersion",0)<>item."assignmentVersion" THEN RAISE EXCEPTION '来源同步后任务已改变'; END IF;
  105. IF EXISTS(SELECT 1 FROM "ReviewRewardIncome" WHERE company=co AND "objectId"=r."objectId" AND status IN ('earned','ineligible')) THEN
  106. UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";n:=n+1;CONTINUE;
  107. END IF;
  108. IF r."incomeMonth"<'2026-09' OR r."completedAt">now() OR r."completionRole" IS DISTINCT FROM 'historical_unknown' OR NULLIF(r."completedBy",'') IS NOT NULL THEN RAISE EXCEPTION '不符合已确认的历史核定范围'; END IF;
  109. IF EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."coachId" AND concat_ws('',u."realName",u.nickname,u.username) LIKE '%自习%') THEN RAISE EXCEPTION '自习账号需单独核对,不能自动计薪'; END IF;
  110. -- An overdue normal copy must not disqualify an on-time duplicate copy merely
  111. -- because its raw receipt is historical while the valid copy is exception.
  112. IF xiaoshu_review_eligibility(r."plannedDate",r."completedAt")='eligible' THEN
  113. FOR expired IN SELECT v."objectId",v."taskId" FROM "ReviewRewardIncome" v
  114. JOIN "ReviewReward" raw ON raw."objectId"=v."objectId" AND raw.company=v.company
  115. JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId"
  116. WHERE v.company=co AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10)
  117. AND v.status IN ('historical','exception') AND xiaoshu_review_eligibility(v."plannedDate",v."completedAt")='overdue'
  118. AND raw."completionRole"='historical_unknown' AND NULLIF(raw."completedBy",'') IS NULL AND v."completedAt"<=now()
  119. AND t."sourceKey" LIKE 'legacy:model:60:general:%'
  120. LOOP
  121. PERFORM xs_review_resolve(co,actor,jsonb_build_array(jsonb_build_object('rewardId',expired."objectId",'reasonCode','overdue')),'旧来源重复复习核对:先排除已核实逾期副本,保留计划当天完成副本的计薪资格。',true);
  122. UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=expired."taskId";
  123. excluded:=excluded+1;n:=n+1;
  124. END LOOP;
  125. END IF;
  126. SELECT v.* INTO best FROM "ReviewRewardIncome" v JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId"
  127. WHERE v.company=co AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10) AND v.status IN ('earned','historical','exception')
  128. ORDER BY (v.status='earned') DESC,(v.status='historical') DESC,v."completedAt",v."objectId" LIMIT 1;
  129. IF xiaoshu_review_eligibility(r."plannedDate",r."completedAt")='eligible' AND best.status<>'earned' AND best."objectId"<>r."objectId" AND (
  130. best."completedAt" IS NULL OR best."completedAt">now() OR xiaoshu_review_eligibility(best."plannedDate",best."completedAt")<>'eligible'
  131. OR xs_review_duration(co,best."taskId") IS NULL
  132. OR NOT EXISTS(SELECT 1 FROM "ReviewReward" raw WHERE raw.company=co AND raw."objectId"=best."objectId" AND raw."completionRole"='historical_unknown' AND NULLIF(raw."completedBy",'') IS NULL AND raw.exception IN ('','来源复习任务重复'))
  133. OR EXISTS(SELECT 1 FROM "PayrollLine" l WHERE l.company=co AND l."reviewRewardId"=best."objectId" AND NOT COALESCE(l.excluded,false)))
  134. THEN RAISE EXCEPTION '重复候选的完成日期或计薪依据需核对,不能剔除有效当天完成'; END IF;
  135. IF xiaoshu_review_eligibility(r."plannedDate",r."completedAt")='overdue' THEN outcome:='overdue';
  136. ELSIF best."objectId"<>r."objectId" THEN outcome:='duplicate_review';
  137. ELSE outcome:='legacy_assignment_policy'; END IF;
  138. PERFORM xs_review_resolve(co,actor,jsonb_build_array(jsonb_build_object('rewardId',r."objectId",'reasonCode',outcome,'canonicalRewardId',best."objectId")),
  139. '旧系统来源自动核对:按已确认历史绑定老师、计划当天完成和来源课时时长口径;保留实际完成身份未知,排除逾期和重复。',true);
  140. SELECT * INTO best FROM "ReviewRewardIncome" WHERE company=co AND "objectId"=r."objectId";
  141. IF best.status='earned' THEN earned:=earned+1;amount:=amount+best.amount;ELSE excluded:=excluded+1;END IF;
  142. UPDATE "LegacyReviewSyncState" SET "pendingIncome"=false,"lastError"='',"updatedAt"=now() WHERE company=co AND "taskId"=item."taskId";
  143. n:=n+1;
  144. EXCEPTION WHEN OTHERS THEN
  145. GET STACKED DIAGNOSTICS err=MESSAGE_TEXT;
  146. n:=saved_n;earned:=saved_earned;excluded:=saved_excluded;amount:=saved_amount;
  147. UPDATE "LegacyReviewSyncState" SET "lastError"=err,"updatedAt"=clock_timestamp() WHERE company=co AND "taskId"=item."taskId";
  148. IF jsonb_array_length(errors)<25 THEN errors:=errors||jsonb_build_array(jsonb_build_object('taskId',item."taskId",'reason',err));END IF;
  149. END;
  150. END LOOP;
  151. SELECT count(*)::int INTO pending FROM "LegacyReviewSyncState" WHERE company=co AND "pendingIncome";
  152. SELECT EXISTS(SELECT 1 FROM "LegacyReviewSyncState" WHERE company=co AND "pendingIncome" AND "taskId">cursor_id) INTO more;
  153. RETURN jsonb_build_object('processed',n,'earned',earned,'excluded',excluded,'amount',amount,'pending',pending,'errors',errors,'afterTaskId',cursor_id,'hasMore',more);
  154. END $$;
  155. REVOKE ALL ON FUNCTION xs_legacy_review_owned(text,text),xs_legacy_review_track(text,text,numeric),xs_legacy_review_patch(text,text,jsonb,jsonb),xs_legacy_review_income(text,text,integer,text) FROM PUBLIC;
  156. CREATE OR REPLACE FUNCTION xs_legacy_review_common_insert() RETURNS trigger LANGUAGE plpgsql AS $$
  157. DECLARE task_id text;
  158. BEGIN
  159. SELECT "objectId" INTO task_id FROM "MemoryPracticeRecord" WHERE company=NEW.company AND id=NEW."itemId" AND "sourceKey" LIKE 'legacy:model:60:general:%' AND COALESCE("assignmentVersion",0)=0;
  160. IF task_id IS NOT NULL THEN PERFORM xs_legacy_review_track(NEW.company,task_id); END IF;
  161. RETURN NEW;
  162. END $$;
  163. DROP TRIGGER IF EXISTS legacy_review_source_insert ON "CommonModel";
  164. CREATE TRIGGER legacy_review_source_insert AFTER INSERT ON "CommonModel" FOR EACH ROW WHEN (NEW."modelId"=60) EXECUTE FUNCTION xs_legacy_review_common_insert();
  165. REVOKE ALL ON FUNCTION xs_legacy_review_common_insert() FROM PUBLIC;
  166. -- The two audited historical repairs came from the old source, not a human transfer.
  167. -- Preserve their version numbers and audit rows, and only adopt an unchanged repair version.
  168. DO $$ DECLARE a record;
  169. BEGIN
  170. IF to_regclass('"ReviewTransferCommand"') IS NOT NULL AND to_regclass('"ReviewTransferAudit"') IS NOT NULL THEN
  171. FOR a IN SELECT m.company,m."objectId",m."assignmentVersion" FROM "MemoryPracticeRecord" m
  172. JOIN "ReviewTransferAudit" t ON t.company=m.company AND t."taskId"=m."objectId" AND t."newVersion"=m."assignmentVersion" AND t."newCoachId"=m.plid::text
  173. JOIN "ReviewTransferCommand" c ON c.company=t.company AND c."objectId"=t."commandId"
  174. WHERE c.key IN ('repair-zeng-1761-review-2026-11-02','repair-legacy-review-transfers-2026-11-02')
  175. AND m."sourceKey" LIKE 'legacy:model:60:general:%'
  176. AND NOT EXISTS(SELECT 1 FROM "LegacyReviewSyncState" s WHERE s.company=m.company AND s."taskId"=m."objectId")
  177. LOOP PERFORM xs_legacy_review_track(a.company,a."objectId",a."assignmentVersion"::numeric);END LOOP;
  178. END IF;
  179. END $$;
  180. COMMIT;