payroll-source-index.sql 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. -- A transactionally maintained projection index, validated against CURRENT source
  2. -- dependencies at every read. It never defines row existence or financial authority.
  3. -- Writes are best effort and never require a company-wide lock. Missing/stale rows
  4. -- are rebuilt in the reader's snapshot; extra rows cannot become payable sources.
  5. BEGIN;
  6. CREATE TABLE IF NOT EXISTS "PayrollSourceIndex" (
  7. kind text NOT NULL,company text NOT NULL,"objectId" text NOT NULL,
  8. row_data jsonb NOT NULL,row_text text NOT NULL,input_signature text,
  9. generation text NOT NULL,selection jsonb NOT NULL DEFAULT '{}'::jsonb,PRIMARY KEY(kind,company,"objectId")
  10. );
  11. ALTER TABLE "PayrollSourceIndex" ADD COLUMN IF NOT EXISTS selection jsonb NOT NULL DEFAULT '{}'::jsonb;
  12. CREATE TABLE IF NOT EXISTS "PayrollSourceIndexMeta" (
  13. singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton),generation text NOT NULL,
  14. signature text NOT NULL,valid boolean NOT NULL DEFAULT false
  15. );
  16. REVOKE ALL ON "PayrollSourceIndex","PayrollSourceIndexMeta" FROM PUBLIC;
  17. -- Only model 59 participates in canonical source selection; avoid scanning the
  18. -- much larger combined CommonModel digest for models 54 and 60.
  19. CREATE INDEX IF NOT EXISTS payroll_source_index_common59_digest
  20. ON "PayrollReadDigest" (company,"objectId") INCLUDE(hash) WHERE kind='common' AND model=59;
  21. -- Conservative selectors reproduce the gateway transport rule. Unknown dates
  22. -- remain candidates. Identity keys retain the existing SQL COALESCE text semantics.
  23. CREATE OR REPLACE FUNCTION xs_payroll_source_index_metadata(data jsonb) RETURNS jsonb LANGUAGE sql STABLE AS $$
  24. SELECT jsonb_build_object('standard_key',data->>'standardLessonKey',
  25. 'month_candidate_key',CASE WHEN data->>'lessonAt' ~ '^\d{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]{1,3})?(Z|[+-][0-9]{2}:[0-9]{2})$'
  26. AND xs_payroll_time(data->>'lessonAt') IS NOT NULL THEN to_char(xs_payroll_time(data->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM') END,
  27. 'scope_data',jsonb_build_array(data->'coachId',data->'jsmz',data->'storeId',data->'szmdid'),
  28. 'coach_key',COALESCE(data->>'coachId',data->>'jsmz'),
  29. 'appointment_key',COALESCE(data->>'appointmentGeneralId',data->>'yyds'));
  30. $$;
  31. CREATE OR REPLACE FUNCTION xs_payroll_source_index_signature() RETURNS text LANGUAGE sql STABLE AS $$
  32. SELECT md5(COALESCE((SELECT string_agg(c.oid||':'||c.relfilenode||':'||a.attnum||':'||a.attname||':'||a.atttypid||':'||a.atttypmod,'|' ORDER BY c.oid,a.attnum)
  33. FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_attribute a ON a.attrelid=c.oid
  34. WHERE n.nspname='public' AND a.attnum>0 AND NOT a.attisdropped
  35. AND c.relname IN ('CommonModel','CourseAppointment','LessonRecord','LessonRecordFact','PayrollSourceIndex','PayrollSourceIndexMeta')),'')||'|'||
  36. COALESCE((SELECT string_agg(t.tgrelid||':'||t.tgname||':'||t.xmin||':'||t.ctid||':'||t.tgenabled::text||':'||pg_get_triggerdef(t.oid),'|' ORDER BY t.tgrelid,t.tgname)
  37. FROM pg_trigger t WHERE t.tgrelid IN ('"CommonModel"'::regclass,'"CourseAppointment"'::regclass,'"LessonRecord"'::regclass,'"LessonRecordFact"'::regclass,'"PayrollSourceIndexMeta"'::regclass) AND t.tgname IN ('xs_payroll_source_index_row','xs_payroll_source_index_truncate','xs_payroll_source_index_meta_guard')),'')||'|'||
  38. COALESCE((SELECT string_agg(pg_get_functiondef(p.oid),'|' ORDER BY p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
  39. WHERE n.nspname='public' AND p.prokind='f' AND (p.proname LIKE 'xs_payroll_source_index_%' OR p.proname IN ('xs_payroll_source_entries','xs_payroll_source_entries_scoped','xs_payroll_sources_indexed','xs_payroll_source_version_indexed','xs_payroll_time'))),''));
  40. $$;
  41. CREATE OR REPLACE FUNCTION xs_payroll_source_index_ready() RETURNS boolean LANGUAGE sql STABLE AS $$
  42. SELECT xs_payroll_authority_ready() AND xs_payroll_digest_ready()
  43. AND COALESCE((SELECT valid AND signature=xs_payroll_source_index_signature() FROM "PayrollSourceIndexMeta" WHERE singleton),false)
  44. AND (SELECT count(*) FROM pg_trigger WHERE tgrelid IN ('"CommonModel"'::regclass,'"CourseAppointment"'::regclass,'"LessonRecord"'::regclass,'"LessonRecordFact"'::regclass)
  45. AND tgname IN ('xs_payroll_source_index_row','xs_payroll_source_index_truncate') AND tgenabled='A')=8;
  46. $$;
  47. CREATE OR REPLACE FUNCTION xs_payroll_source_index_meta_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  48. BEGIN
  49. IF NEW.valid AND (TG_OP='INSERT' OR NEW IS DISTINCT FROM OLD) AND current_setting('xiaoshu.source_index_reseed',true) IS DISTINCT FROM pg_current_xact_id()::text THEN
  50. RAISE EXCEPTION 'source index requires a full reseed and new generation';
  51. END IF;
  52. RETURN NEW;
  53. END $$;
  54. DROP TRIGGER IF EXISTS xs_payroll_source_index_meta_guard ON "PayrollSourceIndexMeta";
  55. CREATE TRIGGER xs_payroll_source_index_meta_guard BEFORE INSERT OR UPDATE ON "PayrollSourceIndexMeta" FOR EACH ROW EXECUTE FUNCTION xs_payroll_source_index_meta_guard();
  56. ALTER TABLE "PayrollSourceIndexMeta" ENABLE ALWAYS TRIGGER xs_payroll_source_index_meta_guard;
  57. -- Only a single target source is projected. Hashes and output are produced by the
  58. -- same SELECT snapshot, never by a digest row which another trigger may update later.
  59. CREATE OR REPLACE FUNCTION xs_payroll_source_index_target(co text,source_kind text,source_id text)
  60. RETURNS TABLE(row_data jsonb,row_text text,input_signature text) LANGUAGE plpgsql VOLATILE SET jit=off AS $$
  61. BEGIN
  62. IF source_kind='lesson' THEN
  63. RETURN QUERY WITH projected AS MATERIALIZED (SELECT
  64. (to_jsonb(l)-'kzsj')||jsonb_build_object(
  65. 'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
  66. 'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
  67. 'source','canonical','commonStatus',COALESCE(c."status",99),
  68. 'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
  69. 'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
  70. 'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
  71. 'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
  72. 'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
  73. 'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
  74. 'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
  75. ) AS data,
  76. md5(jsonb_build_array(md5(l::text),CASE WHEN c."objectId" IS NOT NULL THEN md5(c::text) END,CASE WHEN a."objectId" IS NOT NULL THEN md5(a::text) END)::text) AS signature
  77. FROM "LessonRecord" l
  78. LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c."company"=co AND c."modelId"=59 AND c."itemId"=l."id" ORDER BY c."objectId" LIMIT 1) c ON true
  79. LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=l."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true
  80. WHERE l."company"=co AND l."objectId"=source_id) SELECT p.data,p.data::text,p.signature FROM projected p;
  81. ELSIF source_kind='fact' THEN
  82. RETURN QUERY WITH projected AS MATERIALIZED (SELECT
  83. (to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
  84. 'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END) AS data,
  85. md5(jsonb_build_array(md5(f::text),CASE WHEN a."objectId" IS NOT NULL THEN md5(a::text) END)::text) AS signature
  86. FROM "LessonRecordFact" f LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=f."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true WHERE f."company"=co AND f."objectId"=source_id) SELECT p.data,p.data::text,p.signature FROM projected p;
  87. END IF;
  88. END $$;
  89. CREATE OR REPLACE FUNCTION xs_payroll_source_index_put(co text,source_kind text,source_id text) RETURNS text LANGUAGE plpgsql VOLATILE SET jit=off AS $$
  90. DECLARE projected record; current_generation text;
  91. BEGIN
  92. IF co IS NULL OR source_id IS NULL OR NOT pg_try_advisory_xact_lock(hashtext('payroll-source-index'),hashtext(co||':'||source_kind||':'||source_id)) THEN RETURN NULL;END IF;
  93. SELECT generation INTO current_generation FROM "PayrollSourceIndexMeta" WHERE singleton;
  94. SELECT * INTO projected FROM xs_payroll_source_index_target(co,source_kind,source_id);
  95. IF NOT FOUND THEN DELETE FROM "PayrollSourceIndex" WHERE company=co AND kind=source_kind AND "objectId"=source_id;RETURN NULL;END IF;
  96. INSERT INTO "PayrollSourceIndex"(kind,company,"objectId",row_data,row_text,input_signature,generation,selection) VALUES(source_kind,co,source_id,projected.row_data,projected.row_text,projected.input_signature,current_generation,xs_payroll_source_index_metadata(projected.row_data))
  97. ON CONFLICT(kind,company,"objectId") DO UPDATE SET row_data=EXCLUDED.row_data,row_text=EXCLUDED.row_text,input_signature=EXCLUDED.input_signature,generation=EXCLUDED.generation,selection=EXCLUDED.selection;
  98. RETURN projected.row_data->>'legacyGeneralId';
  99. END $$;
  100. -- Canonical changes alter fact visibility, not a fact's projection. The current
  101. -- reader decides membership; keeping hidden fact entries is safe and avoids
  102. -- rewriting historical facts for every lesson/comment update.
  103. CREATE OR REPLACE FUNCTION xs_payroll_source_index_lesson(co text,source_id text) RETURNS void LANGUAGE plpgsql VOLATILE SET jit=off AS $$
  104. BEGIN PERFORM xs_payroll_source_index_put(co,'lesson',source_id);END $$;
  105. CREATE OR REPLACE FUNCTION xs_payroll_source_index_appointment(co text,appointment_key text) RETURNS void LANGUAGE plpgsql VOLATILE SET jit=off AS $$
  106. DECLARE r record;
  107. BEGIN
  108. FOR r IN SELECT 'lesson' AS kind,"objectId" FROM "LessonRecord" WHERE company=co AND yyds::text=appointment_key
  109. UNION ALL SELECT 'fact',"objectId" FROM "LessonRecordFact" WHERE company=co AND yyds::text=appointment_key ORDER BY kind,"objectId" LOOP
  110. PERFORM xs_payroll_source_index_put(co,r.kind,r."objectId");
  111. END LOOP;
  112. END $$;
  113. CREATE OR REPLACE FUNCTION xs_payroll_source_index_row() RETURNS trigger LANGUAGE plpgsql
  114. SET TimeZone='Etc/GMT-8' SET DateStyle='ISO, MDY' SET IntervalStyle='postgres' SET extra_float_digits=1 SET bytea_output='hex' AS $$
  115. DECLARE item jsonb; r record; changes jsonb[]:=ARRAY[]::jsonb[]; seen text[]:=ARRAY[]::text[]; changed_key text;
  116. BEGIN
  117. -- Review/model-60 synchronization can touch hundreds of thousands of rows.
  118. -- It cannot affect lesson projections: skip before catalog/meta reads or JSON.
  119. IF TG_TABLE_NAME='CommonModel' THEN
  120. IF TG_OP='INSERT' AND NOT COALESCE(NEW."modelId" IN (54,59),false) THEN RETURN NULL;END IF;
  121. IF TG_OP='DELETE' AND NOT COALESCE(OLD."modelId" IN (54,59),false) THEN RETURN NULL;END IF;
  122. IF TG_OP='UPDATE' AND NOT COALESCE(OLD."modelId" IN (54,59),false) AND NOT COALESCE(NEW."modelId" IN (54,59),false) THEN RETURN NULL;END IF;
  123. END IF;
  124. IF NOT EXISTS(SELECT 1 FROM "PayrollSourceIndexMeta" WHERE singleton AND valid) THEN RETURN NULL;END IF;
  125. IF TG_OP<>'INSERT' THEN changes:=array_append(changes,to_jsonb(OLD));END IF;
  126. IF TG_OP<>'DELETE' THEN changes:=array_append(changes,to_jsonb(NEW));END IF;
  127. FOREACH item IN ARRAY changes LOOP
  128. IF item->>'company' IS NULL THEN CONTINUE;END IF;
  129. changed_key:=CASE WHEN TG_TABLE_NAME IN ('LessonRecord','LessonRecordFact') THEN jsonb_build_array(item->>'company',item->>'objectId')::text
  130. WHEN TG_TABLE_NAME='CourseAppointment' THEN jsonb_build_array(item->>'company',item->>'id')::text
  131. WHEN (item->>'modelId')::numeric=59 THEN jsonb_build_array(item->>'company','59',item->>'itemId')::text
  132. ELSE jsonb_build_array(item->>'company',item->>'modelId',item->>'generalId')::text END;
  133. IF changed_key=ANY(seen) THEN CONTINUE;END IF;
  134. seen:=array_append(seen,changed_key);
  135. IF TG_TABLE_NAME='LessonRecord' THEN PERFORM xs_payroll_source_index_lesson(item->>'company',item->>'objectId');
  136. ELSIF TG_TABLE_NAME='LessonRecordFact' THEN PERFORM xs_payroll_source_index_put(item->>'company','fact',item->>'objectId');
  137. ELSIF TG_TABLE_NAME='CommonModel' AND (item->>'modelId')::numeric=59 THEN
  138. FOR r IN SELECT "objectId" FROM "LessonRecord" WHERE company=item->>'company' AND id=(item->>'itemId')::numeric ORDER BY "objectId" LOOP
  139. PERFORM xs_payroll_source_index_lesson(item->>'company',r."objectId");
  140. END LOOP;
  141. ELSIF TG_TABLE_NAME='CommonModel' AND (item->>'modelId')::numeric=54 THEN
  142. PERFORM xs_payroll_source_index_appointment(item->>'company',item->>'generalId');
  143. ELSIF TG_TABLE_NAME='CourseAppointment' THEN
  144. FOR r IN SELECT DISTINCT "generalId"::text AS key FROM "CommonModel" WHERE company=item->>'company' AND "modelId"=54 AND "itemId"=(item->>'id')::numeric ORDER BY key LOOP
  145. PERFORM xs_payroll_source_index_appointment(item->>'company',r.key);
  146. END LOOP;
  147. END IF;
  148. END LOOP;
  149. RETURN NULL;
  150. -- Projection upkeep must not prevent a real source write. The reader verifies
  151. -- every signature and recomputes missing/stale rows in its own current snapshot.
  152. EXCEPTION WHEN OTHERS THEN RETURN NULL;
  153. END $$;
  154. CREATE OR REPLACE FUNCTION xs_payroll_source_index_truncate() RETURNS trigger LANGUAGE plpgsql AS $$
  155. BEGIN UPDATE "PayrollSourceIndexMeta" SET valid=false WHERE singleton;RETURN NULL;END $$;
  156. CREATE OR REPLACE FUNCTION xs_payroll_source_index_scan(co text,reuse_index boolean,include_data boolean,mon text,coach_id text,appointment_ids text[],with_text boolean)
  157. RETURNS TABLE(kind text,"objectId" text,row_data jsonb,row_text text,input_signature text,ordinal bigint,standard_key text,month_candidate_key text,scope_data jsonb)
  158. LANGUAGE sql STABLE AS $$
  159. WITH common59 AS MATERIALIZED (
  160. SELECT DISTINCT ON (c."itemId") c.* FROM "CommonModel" c
  161. WHERE c.company=co AND c."modelId"=59
  162. ORDER BY c."itemId",c."objectId"
  163. ), appointment_map AS MATERIALIZED (
  164. -- Join before choosing: the earliest CommonModel can refer to a missing appointment.
  165. SELECT DISTINCT ON (ac."generalId"::text) ac."generalId"::text AS join_key,a AS appointment
  166. FROM "CommonModel" ac JOIN "CourseAppointment" a ON a.company=co AND a.id=ac."itemId"
  167. WHERE ac.company=co AND ac."modelId"=54
  168. ORDER BY ac."generalId"::text,ac."objectId"
  169. ), lessons AS MATERIALIZED (
  170. SELECT l AS lesson,row_number() OVER () AS ord FROM "LessonRecord" l WHERE l.company=co
  171. ), canonical_info AS MATERIALIZED (
  172. SELECT l.lesson,l.ord,c AS common,
  173. to_jsonb(COALESCE(c."generalId",(l.lesson)."legacyGeneralId",0))#>>'{}' AS legacy_text,
  174. CASE WHEN COALESCE(c."generalId",(l.lesson)."legacyGeneralId",0)>0
  175. THEN 'legacy:'||COALESCE(c."generalId",(l.lesson)."legacyGeneralId")::text
  176. ELSE 'lesson:'||(l.lesson)."objectId" END AS standard_key
  177. FROM lessons l LEFT JOIN common59 c ON c."itemId"=(l.lesson).id
  178. ), canonical_keys AS MATERIALIZED (
  179. SELECT legacy_text FROM canonical_info
  180. ), facts AS MATERIALIZED (
  181. SELECT f AS fact,row_number() OVER () AS ord,
  182. CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END AS standard_key
  183. FROM "LessonRecordFact" f WHERE f.company=co AND NOT EXISTS (
  184. SELECT 1 FROM canonical_keys k WHERE k.legacy_text=f."legacyGeneralId"::text AND COALESCE(f."legacyGeneralId",0)>0
  185. )
  186. ), ambiguous AS MATERIALIZED (
  187. SELECT EXISTS (SELECT 1 FROM (
  188. SELECT standard_key FROM canonical_info UNION ALL SELECT standard_key FROM facts
  189. ) k GROUP BY standard_key HAVING count(*)>1) AS value
  190. ), canonical_dependencies AS MATERIALIZED (
  191. SELECT x.*,am.appointment,
  192. md5(jsonb_build_array(COALESCE(ld.hash,md5((x.lesson)::text)),CASE WHEN (x.common)."objectId" IS NOT NULL THEN COALESCE(cd.hash,md5((x.common)::text)) END,CASE WHEN (am.appointment)."objectId" IS NOT NULL THEN COALESCE(ad.hash,md5((am.appointment)::text)) END)::text) AS signature
  193. FROM canonical_info x LEFT JOIN appointment_map am ON am.join_key=(x.lesson).yyds::text
  194. LEFT JOIN "PayrollReadDigest" ld ON ld.kind='lesson' AND ld.company=co AND ld."objectId"=(x.lesson)."objectId"
  195. LEFT JOIN "PayrollReadDigest" cd ON cd.kind='common' AND cd.model=59 AND cd.company=co AND cd."objectId"=(x.common)."objectId"
  196. LEFT JOIN "PayrollReadDigest" ad ON ad.kind='appointment' AND ad.company=co AND ad."objectId"=(am.appointment)."objectId"
  197. ), fact_dependencies AS MATERIALIZED (
  198. SELECT x.*,am.appointment,
  199. md5(jsonb_build_array(md5((x.fact)::text),CASE WHEN (am.appointment)."objectId" IS NOT NULL THEN COALESCE(ad.hash,md5((am.appointment)::text)) END)::text) AS signature
  200. FROM facts x LEFT JOIN appointment_map am ON am.join_key=(x.fact).yyds::text
  201. LEFT JOIN "PayrollReadDigest" ad ON ad.kind='appointment' AND ad.company=co AND ad."objectId"=(am.appointment)."objectId"
  202. ), projected AS (
  203. SELECT 'lesson'::text AS kind,l."objectId",0 AS source_group,x.ord,x.signature,
  204. CASE WHEN include_data AND (mon IS NULL OR m.selection->>'month_candidate_key' IS NULL OR m.selection->>'month_candidate_key'=mon) AND (coach_id IS NULL OR m.selection->>'coach_key' IS NULL OR m.selection->>'coach_key'=coach_id) AND (appointment_ids IS NULL OR m.selection->>'appointment_key' IS NULL OR m.selection->>'appointment_key'=ANY(appointment_ids)) THEN CASE WHEN i.input_signature=x.signature THEN i.row_data ELSE
  205. (to_jsonb(l)-'kzsj')||jsonb_build_object(
  206. 'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
  207. 'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
  208. 'source','canonical','commonStatus',COALESCE(c."status",99),
  209. 'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
  210. 'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
  211. 'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
  212. 'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
  213. 'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
  214. 'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
  215. 'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
  216. ) END END AS data,
  217. CASE WHEN with_text THEN CASE WHEN i.input_signature=x.signature THEN i.row_text||'' ELSE ((to_jsonb(l)-'kzsj')||jsonb_build_object(
  218. 'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
  219. 'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
  220. 'source','canonical','commonStatus',COALESCE(c."status",99),
  221. 'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
  222. 'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
  223. 'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
  224. 'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
  225. 'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
  226. 'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
  227. 'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
  228. ))::text END END AS text_value,m.selection
  229. FROM canonical_dependencies x CROSS JOIN LATERAL (SELECT (x.lesson).*) l
  230. CROSS JOIN LATERAL (SELECT (x.common).*) c CROSS JOIN LATERAL (SELECT (x.appointment).*) a
  231. LEFT JOIN "PayrollSourceIndex" i ON reuse_index AND i.kind='lesson' AND i.company=co AND i."objectId"=l."objectId" AND i.generation=(SELECT generation FROM "PayrollSourceIndexMeta" WHERE singleton)
  232. CROSS JOIN LATERAL (SELECT CASE WHEN i.input_signature=x.signature THEN i.selection ELSE xs_payroll_source_index_metadata((to_jsonb(l)-'kzsj')||jsonb_build_object(
  233. 'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
  234. 'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
  235. 'source','canonical','commonStatus',COALESCE(c."status",99),
  236. 'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
  237. 'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
  238. 'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
  239. 'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
  240. 'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
  241. 'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
  242. 'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
  243. )) END AS selection OFFSET 0) m
  244. WHERE NOT (SELECT value FROM ambiguous)
  245. UNION ALL
  246. SELECT 'fact',f."objectId",1,x.ord,x.signature,
  247. CASE WHEN include_data AND (mon IS NULL OR m.selection->>'month_candidate_key' IS NULL OR m.selection->>'month_candidate_key'=mon) AND (coach_id IS NULL OR m.selection->>'coach_key' IS NULL OR m.selection->>'coach_key'=coach_id) AND (appointment_ids IS NULL OR m.selection->>'appointment_key' IS NULL OR m.selection->>'appointment_key'=ANY(appointment_ids)) THEN CASE WHEN i.input_signature=x.signature THEN i.row_data ELSE
  248. (to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
  249. 'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END) END END,
  250. CASE WHEN with_text THEN CASE WHEN i.input_signature=x.signature THEN i.row_text||'' ELSE ((to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
  251. 'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END))::text END END,m.selection
  252. FROM fact_dependencies x CROSS JOIN LATERAL (SELECT (x.fact).*) f CROSS JOIN LATERAL (SELECT (x.appointment).*) a
  253. LEFT JOIN "PayrollSourceIndex" i ON reuse_index AND i.kind='fact' AND i.company=co AND i."objectId"=f."objectId" AND i.generation=(SELECT generation FROM "PayrollSourceIndexMeta" WHERE singleton)
  254. CROSS JOIN LATERAL (SELECT CASE WHEN i.input_signature=x.signature THEN i.selection ELSE xs_payroll_source_index_metadata((to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
  255. 'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END)) END AS selection OFFSET 0) m
  256. WHERE NOT (SELECT value FROM ambiguous)
  257. UNION ALL
  258. SELECT CASE WHEN s.row_data->>'source'='canonical' THEN 'lesson' ELSE 'fact' END,s.row_data->>'lessonObjectId',0,s.ord,NULL,CASE WHEN include_data THEN s.row_data END,CASE WHEN with_text THEN s.row_data::text END,xs_payroll_source_index_metadata(s.row_data)
  259. FROM ambiguous guard CROSS JOIN LATERAL xs_payroll_sources(co) WITH ORDINALITY AS s(row_data,ord) WHERE guard.value
  260. ) SELECT p.kind,p."objectId",p.data,p.text_value,p.signature,CASE WHEN include_data THEN CASE WHEN p.source_group=0 THEN p.ord ELSE (SELECT count(*) FROM lessons)+p.ord END END,p.selection->>'standard_key',p.selection->>'month_candidate_key',p.selection->'scope_data'
  261. FROM projected p;
  262. $$;
  263. CREATE OR REPLACE FUNCTION xs_payroll_source_index_entries(co text,reuse_index boolean,include_data boolean)
  264. RETURNS TABLE(kind text,"objectId" text,row_data jsonb,row_text text,input_signature text,ordinal bigint)
  265. LANGUAGE sql STABLE AS $$
  266. SELECT e.kind,e."objectId",e.row_data,e.row_text,e.input_signature,e.ordinal
  267. FROM xs_payroll_source_index_scan(co,reuse_index,include_data,NULL,NULL,NULL,true) e
  268. ORDER BY CASE WHEN include_data THEN e.ordinal END;
  269. $$;
  270. -- Compatibility path keeps complete ordered rows. The three-argument function
  271. -- can inline at a version-only call, eliminating JSON and ordinal-sort branches.
  272. CREATE OR REPLACE FUNCTION xs_payroll_source_index_entries(co text,reuse_index boolean DEFAULT true)
  273. RETURNS TABLE(kind text,"objectId" text,row_data jsonb,row_text text,input_signature text,ordinal bigint)
  274. LANGUAGE sql STABLE AS $$ SELECT * FROM xs_payroll_source_index_entries(co,reuse_index,true) $$;
  275. CREATE OR REPLACE FUNCTION xs_payroll_source_entries(co text)
  276. RETURNS TABLE(row_data jsonb,row_text text,ordinal bigint) LANGUAGE plpgsql STABLE SET jit=off SET work_mem='64MB' AS $$
  277. BEGIN
  278. IF NOT xs_payroll_source_index_ready() OR EXISTS(SELECT 1 FROM "CourseAppointment" WHERE company=co AND id IS NOT NULL GROUP BY id HAVING count(*)>1) THEN
  279. RETURN QUERY SELECT s.data,s.data::text,s.ord FROM xs_payroll_sources(co) WITH ORDINALITY s(data,ord);RETURN;
  280. END IF;
  281. RETURN QUERY SELECT e.row_data,e.row_text,e.ordinal FROM xs_payroll_source_index_entries(co,true,true) e;
  282. END $$;
  283. CREATE OR REPLACE FUNCTION xs_payroll_sources_indexed(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$
  284. SELECT row_data FROM xs_payroll_source_entries(co) ORDER BY ordinal;
  285. $$;
  286. -- Every current source contributes narrow metadata and (optionally) exact text.
  287. -- Only selected candidates fetch their full JSON. Callers apply their final exact
  288. -- financial filters; unknown metadata and ambiguous source identities fail open
  289. -- for transport inclusion, while authority/schema failures use original sources.
  290. CREATE OR REPLACE FUNCTION xs_payroll_source_entries_scoped(co text,mon text DEFAULT NULL,coach_id text DEFAULT NULL,appointment_ids text[] DEFAULT NULL,with_text boolean DEFAULT true)
  291. RETURNS TABLE(row_data jsonb,row_text text,ordinal bigint,standard_key text,month_candidate_key text,scope_data jsonb)
  292. LANGUAGE plpgsql STABLE SET jit=off SET work_mem='64MB' AS $$
  293. BEGIN
  294. IF NOT xs_payroll_source_index_ready() OR EXISTS(SELECT 1 FROM "CourseAppointment" WHERE company=co AND id IS NOT NULL GROUP BY id HAVING count(*)>1) THEN
  295. RETURN QUERY SELECT s.data,CASE WHEN with_text THEN s.data::text END,s.ord,s.data->>'standardLessonKey',m.selection->>'month_candidate_key',m.selection->'scope_data'
  296. FROM xs_payroll_sources(co) WITH ORDINALITY s(data,ord)
  297. CROSS JOIN LATERAL (SELECT xs_payroll_source_index_metadata(s.data) AS selection) m;
  298. RETURN;
  299. END IF;
  300. RETURN QUERY SELECT e.row_data,e.row_text,e.ordinal,e.standard_key,e.month_candidate_key,e.scope_data
  301. FROM xs_payroll_source_index_scan(co,true,true,mon,coach_id,appointment_ids,with_text) e;
  302. END $$;
  303. CREATE OR REPLACE FUNCTION xs_payroll_source_version_indexed(co text) RETURNS text LANGUAGE plpgsql STABLE SET jit=off SET work_mem='64MB' AS $$
  304. BEGIN
  305. IF NOT xs_payroll_source_index_ready() OR EXISTS(SELECT 1 FROM "CourseAppointment" WHERE company=co AND id IS NOT NULL GROUP BY id HAVING count(*)>1) THEN RETURN xs_payroll_source_version(co);END IF;
  306. RETURN (
  307. SELECT md5(COALESCE((SELECT string_agg(row_text,'|' ORDER BY row_text) FROM xs_payroll_source_index_entries(co,true,false)),'')||
  308. COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
  309. COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||
  310. COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CoachPayRate" p WHERE p."company"=co),'')||
  311. COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "ReviewRewardIncome" r WHERE r."company"=co AND r."status"='earned'),'')));
  312. END $$;
  313. CREATE OR REPLACE FUNCTION xs_payroll_source_index_rebuild() RETURNS void LANGUAGE plpgsql SET jit=off SET work_mem='64MB' AS $$
  314. DECLARE new_generation text:=md5(clock_timestamp()::text||random()::text); co record; prior_setting text:=current_setting('xiaoshu.source_index_reseed',true);
  315. BEGIN
  316. IF NOT xs_payroll_authority_ready() OR NOT xs_payroll_digest_ready() THEN RAISE EXCEPTION 'authoritative source and digest prerequisites required for source index rebuild';END IF;
  317. LOCK TABLE "CommonModel","CourseAppointment","LessonRecord","LessonRecordFact" IN SHARE ROW EXCLUSIVE MODE;
  318. PERFORM set_config('xiaoshu.source_index_reseed',pg_current_xact_id()::text,true);
  319. INSERT INTO "PayrollSourceIndexMeta" VALUES(true,new_generation,'',false) ON CONFLICT(singleton) DO UPDATE SET generation=EXCLUDED.generation,signature='',valid=false;
  320. DELETE FROM "PayrollSourceIndex";
  321. FOR co IN SELECT DISTINCT company FROM "LessonRecord" WHERE company IS NOT NULL UNION SELECT DISTINCT company FROM "LessonRecordFact" WHERE company IS NOT NULL LOOP
  322. INSERT INTO "PayrollSourceIndex"(kind,company,"objectId",row_data,row_text,input_signature,generation,selection) SELECT e.kind,co.company,e."objectId",e.row_data,e.row_text,e.input_signature,new_generation,xs_payroll_source_index_metadata(e.row_data) FROM xs_payroll_source_index_entries(co.company,false,true) e;
  323. END LOOP;
  324. UPDATE "PayrollSourceIndexMeta" SET signature=xs_payroll_source_index_signature(),valid=true WHERE singleton;
  325. PERFORM set_config('xiaoshu.source_index_reseed',COALESCE(prior_setting,''),true);
  326. END $$;
  327. DO $$ DECLARE tab text;BEGIN
  328. FOREACH tab IN ARRAY ARRAY['CommonModel','CourseAppointment','LessonRecord','LessonRecordFact'] LOOP
  329. EXECUTE format('LOCK TABLE %I IN SHARE ROW EXCLUSIVE MODE',tab);
  330. EXECUTE format('DROP TRIGGER IF EXISTS xs_payroll_source_index_row ON %I',tab);
  331. EXECUTE format('DROP TRIGGER IF EXISTS xs_payroll_source_index_truncate ON %I',tab);
  332. EXECUTE format('CREATE TRIGGER xs_payroll_source_index_row AFTER INSERT OR UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION xs_payroll_source_index_row()',tab);
  333. EXECUTE format('CREATE TRIGGER xs_payroll_source_index_truncate AFTER TRUNCATE ON %I FOR EACH STATEMENT EXECUTE FUNCTION xs_payroll_source_index_truncate()',tab);
  334. EXECUTE format('ALTER TABLE %I ENABLE ALWAYS TRIGGER xs_payroll_source_index_row',tab);
  335. EXECUTE format('ALTER TABLE %I ENABLE ALWAYS TRIGGER xs_payroll_source_index_truncate',tab);
  336. END LOOP;
  337. END $$;
  338. SELECT xs_payroll_source_index_rebuild();
  339. COMMIT;