payroll-read-snapshot.sql 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. -- One fresh read snapshot; no result cache. Deploy after payroll-source-index and cutoff dependencies.
  2. -- Call inside REPEATABLE READ READ ONLY because downstream legacy read helpers may be VOLATILE.
  3. -- Capture the exact date parser used for conservative transport pruning. A later
  4. -- parser change fails closed rather than assuming it still agrees with JavaScript.
  5. DO $install$
  6. DECLARE expected text:=md5(pg_get_functiondef('xs_payroll_time(text)'::regprocedure));
  7. conflicts_expected text:=md5(pg_get_functiondef('xs_payroll_cutoff_conflicts(text,text)'::regprocedure));
  8. BEGIN
  9. IF to_regprocedure('xs_payroll_read_snapshot_authority_ready()') IS NOT NULL THEN RETURN; END IF;
  10. EXECUTE format('CREATE OR REPLACE FUNCTION xs_payroll_read_snapshot_authority_ready() RETURNS boolean LANGUAGE sql STABLE AS %L',
  11. 'SELECT xs_payroll_authority_ready() AND md5(pg_get_functiondef(''xs_payroll_time(text)''::regprocedure))='||quote_literal(expected)||
  12. ' AND md5(pg_get_functiondef(''xs_payroll_cutoff_conflicts(text,text)''::regprocedure))='||quote_literal(conflicts_expected));
  13. END $install$;
  14. CREATE OR REPLACE FUNCTION public.xs_payroll_cutoff_from_version(co text, mon text, provided_version text)
  15. RETURNS jsonb
  16. LANGUAGE plpgsql STABLE
  17. SET jit TO 'off'
  18. SET work_mem TO '64MB'
  19. SET max_parallel_workers_per_gather TO '2'
  20. AS $function$
  21. DECLARE run "LegacyDirectRun"; ev record; table_name text; matches integer; complete boolean:=true; conflicts jsonb; version text; approval "PayrollCutoffApproval"; data_version text; source_version text; pending integer; datasets jsonb:='[]'; ready boolean;
  22. BEGIN
  23. IF NOT xs_payroll_read_snapshot_authority_ready() THEN RETURN NULL;END IF;
  24. IF mon !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
  25. SELECT * INTO run FROM "LegacyDirectRun" WHERE company=co AND status IN ('completed','completed-with-preserved-conflicts') ORDER BY "finishedAt" DESC LIMIT 1;
  26. IF NOT FOUND THEN complete:=false; END IF;
  27. IF (SELECT count(*) FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60))<>3 THEN complete:=false; END IF;
  28. FOR ev IN SELECT * FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60) LOOP
  29. table_name:=CASE ev.model WHEN 54 THEN 'CourseAppointment' WHEN 59 THEN 'LessonRecord' ELSE 'MemoryPracticeRecord' END;
  30. matches:=xs_payroll_evidence_matches(co,ev.model,ev.ids);
  31. complete:=complete AND matches=ev."sourceCount" AND ev."sourceChecksum"=ev."targetChecksum" AND jsonb_array_length(ev.ids)=ev."sourceCount" AND ev."sourceChecksum"=md5((SELECT COALESCE(string_agg(v,E'\n' ORDER BY v::numeric),'') FROM jsonb_array_elements_text(ev.ids) v));
  32. datasets:=datasets||jsonb_build_array(jsonb_build_object('model',ev.model,'source',ev."sourceCount",'matched',matches,'checksum',ev."sourceChecksum"));
  33. END LOOP;
  34. SELECT COALESCE(jsonb_agg(c ORDER BY c->>'id'),'[]') INTO conflicts FROM xs_payroll_cutoff_conflicts_bulk(co,mon) c;
  35. SELECT count(*) INTO pending FROM jsonb_array_elements(conflicts) c WHERE NOT (c->>'resolved')::boolean;
  36. -- Native row serialization covers every column without constructing a JSON object per row.
  37. data_version:=xs_payroll_data_version(co);
  38. source_version:=provided_version;
  39. version:=md5(jsonb_build_array(data_version,source_version,run."runId",run."finishedAt",datasets,conflicts)::text);
  40. SELECT * INTO approval FROM "PayrollCutoffApproval" WHERE company=co AND month=mon;
  41. ready:=complete AND pending=0 AND approval.version=version AND NOT EXISTS(SELECT 1 FROM "LegacyDirectRun" WHERE company=co AND status='running');
  42. RETURN jsonb_build_object('month',mon,'version',version,'sourceVersion',source_version,'runId',run."runId",'cutoffAt',run."finishedAt",'complete',complete,'pending',pending,'conflicts',conflicts,'datasets',datasets,'approved',COALESCE(ready,false),'monthClosed',COALESCE(run."finishedAt">=((mon||'-01')::date+interval '1 month') AT TIME ZONE 'Asia/Shanghai',false),'approval',to_jsonb(approval));
  43. END $function$;
  44. CREATE OR REPLACE FUNCTION xs_payroll_read_snapshot(co text, mon text, for_dashboard boolean DEFAULT false, include_scope_rows boolean DEFAULT false)
  45. RETURNS jsonb LANGUAGE plpgsql STABLE SET jit=off SET work_mem='64MB' AS $$
  46. DECLARE result jsonb; cutoff jsonb;
  47. BEGIN
  48. IF NOT xs_payroll_read_snapshot_authority_ready() THEN RETURN NULL; END IF;
  49. IF mon !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
  50. WITH sources AS MATERIALIZED (
  51. SELECT row_data AS s,row_text,standard_key,scope_data,ordinal AS ord FROM xs_payroll_source_entries_scoped(co,mon,NULL,NULL,NOT for_dashboard)
  52. ), identity_state AS MATERIALIZED (
  53. SELECT EXISTS(SELECT 1 FROM sources GROUP BY standard_key HAVING count(*)>1) AS duplicates
  54. ), source_version AS MATERIALIZED (
  55. SELECT CASE WHEN for_dashboard THEN '' ELSE md5(
  56. COALESCE((SELECT string_agg(row_text,'|' ORDER BY row_text) FROM sources),'')||
  57. COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
  58. COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||
  59. COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CoachPayRate" p WHERE p."company"=co),'')||
  60. COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "ReviewRewardIncome" r WHERE r."company"=co AND r."status"='earned'),'')) END AS value
  61. ), candidates AS MATERIALIZED (
  62. SELECT s,ord FROM sources WHERE s IS NOT NULL
  63. ) SELECT jsonb_build_object(
  64. 'rows',COALESCE((SELECT jsonb_agg(s ORDER BY ord) FROM candidates),'[]'::jsonb),
  65. 'version',(SELECT value FROM source_version),
  66. 'total',(SELECT count(*) FROM sources),
  67. 'hasDuplicates',(SELECT duplicates FROM identity_state),
  68. 'scopeRows',CASE WHEN include_scope_rows THEN COALESCE((SELECT jsonb_agg(scope_data ORDER BY ord) FROM sources),'[]'::jsonb) ELSE NULL END
  69. ) INTO result;
  70. IF NOT for_dashboard THEN
  71. cutoff:=xs_payroll_cutoff_from_version(co,mon,result->>'version');
  72. IF cutoff IS NULL THEN RETURN NULL; END IF;
  73. -- Full conflicts/approval already participated in the cutoff hash; only sync metadata crosses the wire.
  74. cutoff:=jsonb_build_object('approved',cutoff->'approved','monthClosed',cutoff->'monthClosed','version',cutoff->'version',
  75. 'complete',cutoff->'complete','pending',cutoff->'pending','datasets',cutoff->'datasets',
  76. 'cutoffAt',cutoff->'cutoffAt','sourceVersion',cutoff->'sourceVersion');
  77. END IF;
  78. RETURN result||jsonb_build_object('cutoff',cutoff);
  79. END $$;
  80. -- Select only identity/display fields before JSON encoding. The catalog check
  81. -- preserves every supported alias even on schemas that omit optional columns.
  82. CREATE OR REPLACE FUNCTION xs_payroll_read_users(co text) RETURNS SETOF jsonb
  83. LANGUAGE plpgsql STABLE AS $$
  84. DECLARE columns text;
  85. BEGIN
  86. SELECT string_agg(quote_ident(attname),',' ORDER BY attnum) INTO columns FROM pg_attribute
  87. WHERE attrelid='"_User"'::regclass AND attnum>0 AND NOT attisdropped
  88. AND attname=ANY(ARRAY['objectId','legacyUserId','legacyUserData','legacyuserdata','realName','realname','nickname','nickName','nickname2','username','mobile','phone']);
  89. RETURN QUERY EXECUTE 'SELECT to_jsonb(u) FROM (SELECT '||columns||' FROM "_User" WHERE company=$1) u' USING co;
  90. END $$;