payroll-cutoff-bulk.test.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { after, afterEach, before, beforeEach, test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import { PGlite } from '@electric-sql/pglite';
  5. const read = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8');
  6. const originalSql = read('../sql/payroll-cutoff.sql');
  7. const extract = (source, name) => {
  8. const match = source.match(new RegExp('CREATE OR REPLACE FUNCTION ' + name + '\\([\\s\\S]*?\\$\\$;'));
  9. assert.ok(match, `Missing actual SQL function ${name}`);
  10. return match[0];
  11. };
  12. const oracleSql = extract(originalSql, 'xs_payroll_cutoff_conflicts');
  13. const authorityInstall = read('../sql/payroll-read-snapshot.sql').match(/DO \$install\$[\s\S]*?\$install\$;/)[0];
  14. const db = new PGlite();
  15. const query = async (sql, params = []) => (await db.query(sql, params)).rows;
  16. const one = async (sql, params = []) => (await query(sql, params))[0];
  17. const tables = { 54: 'CourseAppointment', 59: 'LessonRecord', 60: 'MemoryPracticeRecord' };
  18. const month = '2026-11';
  19. let nextId = 1;
  20. before(async () => {
  21. await db.exec(`
  22. CREATE TABLE "LegacySyncConflict" ("objectId" text PRIMARY KEY, company text, dataset text, "legacyKey" text, resolved boolean, message text);
  23. CREATE TABLE "CommonModel" ("objectId" text, company text, "generalId" numeric, "modelId" int, "itemId" numeric, title text, subtitle text);
  24. CREATE TABLE "CourseAppointment" (company text, id numeric, yysj text, "teacherPaySnapshot" numeric, extension jsonb);
  25. CREATE TABLE "LessonRecord" (company text, id numeric, "lessonAt" text, plpjsj text, "teacherPaySnapshot" numeric, extension jsonb);
  26. CREATE TABLE "MemoryPracticeRecord" (company text, id numeric, wcsj text, kywrq text, "teacherPaySnapshot" numeric, extension jsonb);
  27. CREATE TABLE "LegacyDirectRun" (company text, "runId" text, status text, "finishedAt" timestamptz);
  28. CREATE TABLE "PayrollCutoffDecision" (company text, month text, "conflictId" text, fingerprint text, PRIMARY KEY(company,month,"conflictId"));
  29. CREATE FUNCTION xs_payroll_authority_ready() RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT true $$;
  30. SET timezone='UTC';
  31. `);
  32. await db.exec(extract(read('../sql/payroll-integrity.sql'), 'xs_payroll_time'));
  33. await db.exec(oracleSql);
  34. await db.exec(authorityInstall);
  35. await db.exec(read('../sql/payroll-cutoff-bulk.sql'));
  36. });
  37. beforeEach(async () => {
  38. nextId = 1;
  39. await db.exec('BEGIN');
  40. await db.exec(`INSERT INTO "LegacyDirectRun" VALUES
  41. ('co','latest','completed-with-preserved-conflicts','2026-11-09T00:00:00Z'),
  42. ('co','old','completed','2026-11-01T00:00:00Z'),
  43. ('co','not-complete','running','2026-11-10T00:00:00Z'),
  44. ('other','other-latest','completed','2026-12-01T00:00:00Z');`);
  45. });
  46. afterEach(() => db.exec('ROLLBACK'));
  47. after(() => db.close());
  48. async function add({ id, dataset = 59, company = 'co', resolved = false, message = '{"fields":["teacherPaySnapshot"],"reason":"金额待核验"}', date = '2026-11-01 12:00:00', fallback = null, amount = 40.5, missingMapping = false, missingAddon = false, title = '学员', subtitle = '课程' } = {}) {
  49. const key = nextId++, conflictId = id ?? `conflict-${String(key).padStart(3, '0')}`;
  50. await query('INSERT INTO "LegacySyncConflict" VALUES ($1,$2,$3,$4,$5,$6)', [conflictId, company, String(dataset), String(key), resolved, message]);
  51. if (!missingMapping) await query('INSERT INTO "CommonModel" VALUES ($1,$2,$3,$4,$5,$6,$7)', ['cm-' + key, company, key, dataset, key, title, subtitle]);
  52. if (!missingAddon && !missingMapping && tables[dataset]) {
  53. const columns = dataset === 54 ? 'yysj' : dataset === 59 ? '"lessonAt",plpjsj' : 'wcsj,kywrq';
  54. const dates = dataset === 54 ? [date] : [date, fallback];
  55. const values = [company, key, ...dates, amount, JSON.stringify({ source: 'native', arbitrary: [null, 0, '保留'] })];
  56. await query(`INSERT INTO "${tables[dataset]}" (company,id,${columns},"teacherPaySnapshot",extension) VALUES (${values.map((_, i) => '$' + (i + 1)).join(',')})`, values);
  57. }
  58. return { id: conflictId, key, dataset };
  59. }
  60. const serialized = async (fn, co = 'co', mon = month) => (await one(`SELECT COALESCE(jsonb_agg(value ORDER BY ord),'[]'::jsonb)::text AS value FROM ${fn}($1,$2) WITH ORDINALITY AS c(value,ord)`, [co, mon])).value;
  61. const rows = async (fn, co = 'co', mon = month) => JSON.parse(await serialized(fn, co, mon));
  62. async function same(co = 'co', mon = month) {
  63. const beforeText = await serialized('xs_payroll_cutoff_conflicts', co, mon);
  64. const afterText = await serialized('xs_payroll_cutoff_conflicts_bulk', co, mon);
  65. assert.equal(afterText, beforeText, 'Exact ordered PostgreSQL JSON must match');
  66. const before = JSON.parse(beforeText), after = JSON.parse(afterText);
  67. // Includes current payment amounts, every unknown column, fields order, output
  68. // order, fingerprint, cosmetic classification and the financial blocking state.
  69. assert.deepEqual(after, before);
  70. assert.deepEqual(after.map(row => row.fingerprint), before.map(row => row.fingerprint));
  71. return after;
  72. }
  73. // Observe the fallback branch without replacing the oracle's semantics. The
  74. // accepted authority delegates to the actual original PL/pgSQL function.
  75. async function trackLegacyCalls() {
  76. await db.exec(oracleSql.replace('FUNCTION xs_payroll_cutoff_conflicts(', 'FUNCTION xs_payroll_cutoff_conflicts_oracle('));
  77. await db.exec(`CREATE OR REPLACE FUNCTION xs_payroll_cutoff_conflicts(co text,mon text) RETURNS SETOF jsonb LANGUAGE plpgsql AS $$
  78. BEGIN
  79. PERFORM set_config('test.legacy_calls',(COALESCE(NULLIF(current_setting('test.legacy_calls',true),''),'0')::int+1)::text,true);
  80. RETURN QUERY SELECT s FROM xs_payroll_cutoff_conflicts_oracle(co,mon) s;
  81. END $$;
  82. DROP FUNCTION xs_payroll_read_snapshot_authority_ready();`);
  83. await db.exec(authorityInstall);
  84. assert.equal((await one('SELECT xs_payroll_read_snapshot_authority_ready() ready')).ready, true);
  85. }
  86. async function compareFallback(expectedCalls) {
  87. const expected = await rows('xs_payroll_cutoff_conflicts');
  88. await query("SELECT set_config('test.legacy_calls','0',true)");
  89. assert.deepEqual(await rows('xs_payroll_cutoff_conflicts_bulk'), expected);
  90. assert.equal(Number((await one("SELECT current_setting('test.legacy_calls') n")).n), expectedCalls);
  91. }
  92. test('actual original and bulk preserve ordered conflicts, all three payment payloads, missing addons and tenant/status filters', async () => {
  93. await add({ id: 'z-review', dataset: 60, amount: 1.25 });
  94. await add({ id: 'a-appointment', dataset: 54, amount: 0 });
  95. await add({ id: 'm-lesson', dataset: 59, amount: 40.5 });
  96. await add({ id: 'b-missing-addon', missingAddon: true });
  97. await add({ id: 'c-missing-mapping', missingMapping: true });
  98. await add({ id: 'ignored-resolved', resolved: true });
  99. await add({ id: 'ignored-dataset', dataset: 52 });
  100. await add({ id: 'ignored-tenant', company: 'other' });
  101. const result = await same();
  102. assert.deepEqual(result.map(row => row.id), ['a-appointment', 'b-missing-addon', 'c-missing-mapping', 'm-lesson', 'z-review']);
  103. assert.equal(result[0].current.teacherPaySnapshot, 0);
  104. assert.equal(result[3].current.teacherPaySnapshot, 40.5);
  105. assert.equal(result[4].current.teacherPaySnapshot, 1.25);
  106. assert.equal(result[1].current, null); assert.equal(result[2].current, null);
  107. assert.equal(result.filter(row => !row.resolved).length, 5);
  108. assert.ok(result.every(row => /^[a-f0-9]{32}$/.test(row.fingerprint)));
  109. await trackLegacyCalls(); await compareFallback(0); // Prove the ordinary case uses bulk SQL.
  110. });
  111. test('malformed, null and scalar messages/fields retain authoritative cosmetic and pending decisions', async () => {
  112. const cases = [
  113. [null, false], ['not json', false], ['null', false], ['42', false], ['"text"', false], ['[]', false],
  114. ['{}', false], ['{"fields":null}', false], ['{"fields":true}', false], ['{"fields":"title"}', false],
  115. ['{"fields":{}}', false], ['{"fields":[]}', false], ['{"fields":["title"]}', true],
  116. ['{"fields":["inputer","title","inputer"],"reason":17}', true], ['{"fields":[null]}', true],
  117. ['{"fields":["title",null]}', true], ['{"fields":["title","teacherPaySnapshot"]}', false],
  118. ['{"fields":[{},2,true]}', false],
  119. ];
  120. for (const [index, [message]] of cases.entries()) await add({ id: String(index).padStart(2, '0'), message });
  121. const result = await same();
  122. assert.deepEqual(result.map(row => row.cosmetic), cases.map(([, cosmetic]) => cosmetic));
  123. assert.deepEqual(result.map(row => row.resolved), cases.map(([, cosmetic]) => cosmetic));
  124. assert.equal(result[13].reason, '17');
  125. });
  126. test('future months alone are excluded, with the real Shanghai parser, fallback dates and malformed dates', async () => {
  127. const fixtures = [
  128. { id: 'old', date: '2026-01-01' },
  129. { id: 'local-end', date: '2026-11-30 23:59:59' },
  130. { id: 'utc-before', date: '2026-11-30T15:59:59.999Z' },
  131. { id: 'utc-next', date: '2026-11-30T16:00:00Z' },
  132. { id: 'local-next', date: '2026-12-01 00:00:00' },
  133. { id: 'invalid-future', date: '2099-02-30' },
  134. { id: 'invalid', date: 'not-a-date' },
  135. { id: 'unknown', date: null },
  136. { id: 'empty', date: '' },
  137. { id: 'whitespace', date: ' ' },
  138. ];
  139. for (const dataset of [54, 59, 60]) for (const item of fixtures) await add({ ...item, id: `${dataset}-${item.id}`, dataset });
  140. await add({ id: 'lesson-fallback-old', date: '', fallback: '2026-10-01' });
  141. await add({ id: 'lesson-fallback-future', date: null, fallback: '2026-12-01' });
  142. await add({ id: 'review-fallback-old', dataset: 60, date: '', fallback: '2026-10-01' });
  143. await add({ id: 'review-fallback-future', dataset: 60, date: null, fallback: '2026-12-01' });
  144. // Nonempty but invalid primary dates must not silently use a valid fallback.
  145. await add({ id: 'invalid-primary-kept', date: 'invalid', fallback: '2026-12-01' });
  146. const result = await same();
  147. assert.equal(result.length, 27);
  148. assert.ok(result.every(row => !row.id.includes('utc-next') && !row.id.includes('local-next') && !row.id.includes('fallback-future')));
  149. assert.ok(result.some(row => row.id === 'invalid-primary-kept'));
  150. assert.equal(result.find(row => row.id === 'lesson-fallback-old').date, '2026-10-01');
  151. });
  152. test('exact conflict decisions resolve only their company, month and current fingerprint; fresh changes revoke them', async () => {
  153. const item = await add({ id: 'financial-conflict', dataset: 59, amount: 40.5 });
  154. const initial = (await same())[0];
  155. await query('INSERT INTO "PayrollCutoffDecision" VALUES ($1,$2,$3,$4)', ['other', month, item.id, initial.fingerprint]);
  156. await query('INSERT INTO "PayrollCutoffDecision" VALUES ($1,$2,$3,$4)', ['co', '2026-10', item.id, initial.fingerprint]);
  157. assert.equal((await same())[0].resolved, false);
  158. await query('INSERT INTO "PayrollCutoffDecision" VALUES ($1,$2,$3,$4)', ['co', month, item.id, initial.fingerprint]);
  159. assert.equal((await same())[0].resolved, true);
  160. const edits = [
  161. ['UPDATE "LessonRecord" SET "teacherPaySnapshot"=41.75 WHERE id=$1', [item.key]],
  162. ['UPDATE "CommonModel" SET title=$1 WHERE "itemId"=$2', ['姓名改正', item.key]],
  163. ['UPDATE "CommonModel" SET subtitle=$1 WHERE "itemId"=$2', ['课程改正', item.key]],
  164. ['UPDATE "LegacySyncConflict" SET message=$1 WHERE "objectId"=$2', ['{"fields":["amount"],"reason":"新差异"}', item.id]],
  165. ['INSERT INTO "LegacyDirectRun" VALUES($1,$2,$3,$4)', ['co', 'new-complete', 'completed', '2026-11-11T00:00:00Z']],
  166. ];
  167. let prior = initial.fingerprint;
  168. for (const [sql, params] of edits) {
  169. await query(sql, params);
  170. const changed = (await same())[0];
  171. assert.notEqual(changed.fingerprint, prior); assert.equal(changed.resolved, false);
  172. await query('UPDATE "PayrollCutoffDecision" SET fingerprint=$1 WHERE company=$2 AND month=$3 AND "conflictId"=$4', [changed.fingerprint, 'co', month, item.id]);
  173. assert.equal((await same())[0].resolved, true);
  174. prior = changed.fingerprint;
  175. }
  176. assert.equal((await same())[0].current.teacherPaySnapshot, 41.75);
  177. });
  178. for (const dataset of [54, 59, 60]) test(`duplicate addon id in dataset ${dataset} delegates to original SELECT INTO semantics`, async () => {
  179. await add({ id: 'subject', dataset });
  180. await db.exec(`INSERT INTO "${tables[dataset]}" SELECT * FROM "${tables[dataset]}"`);
  181. // Make the duplicate materially different: a bulk join must neither multiply
  182. // payable amounts nor silently pick a different row from the original.
  183. await db.exec(`UPDATE "${tables[dataset]}" SET "teacherPaySnapshot"=999.75,extension='{"duplicate":"different payment"}' WHERE ctid=(SELECT max(ctid) FROM "${tables[dataset]}")`);
  184. await trackLegacyCalls();
  185. await compareFallback(1);
  186. assert.equal((await same()).length, 1);
  187. });
  188. test('duplicate addon ids are scoped to the tenant and null ids cannot match', async () => {
  189. await add({ id: 'subject' });
  190. await db.exec(`INSERT INTO "LessonRecord"(company,id) VALUES('other',5),('other',5),('co',NULL),('co',NULL)`);
  191. await trackLegacyCalls(); await compareFallback(0);
  192. });
  193. test('missing completed runs and null resolved flags keep unresolved financial rows', async () => {
  194. await db.exec('DELETE FROM "LegacyDirectRun"');
  195. await add({ id: 'pending-without-run', resolved: null, amount: 0 });
  196. const result = await same();
  197. assert.equal(result.length, 1); assert.equal(result[0].resolved, false);
  198. assert.equal(result[0].current.teacherPaySnapshot, 0);
  199. const fingerprint = result[0].fingerprint;
  200. await db.exec(`INSERT INTO "LegacyDirectRun" VALUES('co','not-final','running','2026-11-10T00:00:00Z')`);
  201. assert.equal((await same())[0].fingerprint, fingerprint);
  202. });
  203. test('duplicate CommonModel general ids delegate to original ordering instead of choosing a new ambiguous tie order', async () => {
  204. await add({ id: 'shared-conflict', dataset: 59 });
  205. await db.exec(`INSERT INTO "CommonModel" SELECT 'duplicate-map',company,"generalId","modelId","itemId",'另一个学员','另一个课程' FROM "CommonModel"`);
  206. await trackLegacyCalls();
  207. // Both original rows share the same conflict id; ORDER BY conflict id alone
  208. // does not define their relative order. Equality in one plan is insufficient.
  209. await compareFallback(1);
  210. const result = await same();
  211. assert.equal(result.length, 2);
  212. assert.equal(new Set(result.map(row => row.fingerprint)).size, 2);
  213. });
  214. test('authority drift falls back to the newly authoritative output and reinstall cannot reauthorize stale bulk semantics', async () => {
  215. await add();
  216. await db.exec(`CREATE OR REPLACE FUNCTION xs_payroll_cutoff_conflicts(co text,mon text) RETURNS SETOF jsonb LANGUAGE sql AS $$
  217. SELECT jsonb_build_object('authorityRevision',2,'company',co,'month',mon,'resolved',false,'current',jsonb_build_object('teacherPaySnapshot',73.25))
  218. $$;`);
  219. assert.equal((await one('SELECT xs_payroll_read_snapshot_authority_ready() ready')).ready, false);
  220. assert.deepEqual(await same(), [{ authorityRevision: 2, company: 'co', month, resolved: false, current: { teacherPaySnapshot: 73.25 } }]);
  221. await db.exec(authorityInstall);
  222. await db.exec(read('../sql/payroll-cutoff-bulk.sql'));
  223. assert.equal((await one('SELECT xs_payroll_read_snapshot_authority_ready() ready')).ready, false);
  224. await same();
  225. });
  226. test('date parser drift delegates to original new date decisions', async () => {
  227. await add({ id: 'future-under-old-parser', date: '2026-12-01' });
  228. assert.equal((await same()).length, 0);
  229. await db.exec(`CREATE OR REPLACE FUNCTION xs_payroll_time(v text) RETURNS timestamptz LANGUAGE sql IMMUTABLE AS $$ SELECT NULL::timestamptz $$;`);
  230. assert.equal((await one('SELECT xs_payroll_read_snapshot_authority_ready() ready')).ready, false);
  231. const changed = await same();
  232. assert.equal(changed.length, 1); assert.equal(changed[0].resolved, false);
  233. });
  234. test('evidence membership preserves textual IDs, numeric grouping, duplicates and missing addon rows',async()=>{
  235. const original=`WITH local_rows AS MATERIALIZED (SELECT c."generalId" FROM "CommonModel" c JOIN "MemoryPracticeRecord" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=60), wanted AS MATERIALIZED (SELECT DISTINCT jsonb_array_elements_text($2::jsonb) AS id) SELECT count(*)::int value FROM (SELECT l."generalId" FROM local_rows l JOIN wanted w ON w.id=l."generalId"::text GROUP BY l."generalId" HAVING count(*)=1) x`;
  236. await db.exec(`INSERT INTO "CommonModel"("objectId",company,"generalId","modelId","itemId") VALUES('a','co',1,60,1),('b','co',1.0,60,2),('c','co',2,60,3),('d','co',3,60,99),('e','other',4,60,4);INSERT INTO "MemoryPracticeRecord"(company,id) VALUES('co',1),('co',2),('co',3),('co',3),('other',4);`);
  237. for(const ids of [['1'],['1.0'],['1','1.0'],['1','1'],['01'],['2','3','4'],[],[null,'1']]){
  238. const args=['co',JSON.stringify(ids)];const expected=(await one(original,args)).value;
  239. assert.equal((await one('SELECT xs_payroll_evidence_matches($1,60,$2::jsonb) value',args)).value,expected);
  240. }
  241. });