| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 |
- import { test, before, after } from 'node:test';
- import assert from 'node:assert/strict';
- import { readFileSync } from 'node:fs';
- import { PGlite } from '@electric-sql/pglite';
- import vm from 'node:vm';
- const db = new PGlite();
- const migration = readFileSync(new URL('../sql/review-rewards.sql', import.meta.url),'utf8');
- 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',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,"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');
- INSERT INTO "Node" VALUES ('c',30,'单词课'); INSERT INTO "CommonModel" VALUES ('study','c',56,100);`);
- await task('history','2026-08-01',{status:'1'});
- await task('dup1','2026-08-02');await task('dup2','2026-08-02');
- await db.exec(migration);
- });
- after(async()=>db.close());
- test('历史完成零金额,重复完成和重复迁移不补发', async()=>{
- await complete('history');await db.exec(migration);
- const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'history\'');assert.equal(r.status,'historical');assert.equal(Number(r.amount),0);
- assert.equal((await one('SELECT "wcsj" FROM "MemoryPracticeRecord" WHERE "objectId"=\'history\'')).wcsj,'2026-08-01 10:00:00');
- });
- test('首次完成一元,快照老师和北京时间月份;并发重试不改变完成时间',async()=>{
- await task('first','2026-09-01');await Promise.all([complete('first'),complete('first'),complete('first')]);
- const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');assert.equal(Number(r.amount),1);assert.equal(r.coachId,10);assert.equal(r.incomeMonth,new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}).slice(0,7));
- await query('UPDATE "MemoryPracticeRecord" SET "fxzt"=\'0\',"wcsj"=\'2099-01-01\',"plid"=\'999\' WHERE "objectId"=\'first\'');
- await complete('first');const again=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');assert.deepEqual(again,r);
- const m=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'first\'');assert.equal(m.fxzt,'1');assert.equal(m.plid,'10');assert.notEqual(m.wcsj,'2099-01-01');
- await assert.rejects(query('UPDATE "ReviewReward" SET "amount"=2 WHERE "taskId"=\'first\''),/不可改写/);
- });
- test('独立计划分别奖励;新任务生成重复被数据库拒绝',async()=>{
- await task('next','2026-09-03');await complete('next');
- await assert.rejects(task('next-copy','2026-09-03'),/unique/);
- assert.equal(Number((await one('SELECT SUM("amount") amount FROM "ReviewReward" WHERE "status"=\'earned\'')).amount),2);
- });
- test('历史重复来源和缺失老师、来源的记录不计薪',async()=>{
- await complete('dup1');await complete('dup2');await task('missing-coach','2026-09-04',{coach:'999'});await complete('missing-coach');await task('missing-source','2026-09-05',{study:'999'});await complete('missing-source');
- const rows=await query('SELECT * FROM "ReviewReward" WHERE "taskId"=ANY($1::text[])',[['dup1','dup2','missing-coach','missing-source']]);assert.equal(rows.length,4);assert.ok(rows.every(r=>r.status==='exception'&&Number(r.amount)===0));
- });
- 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();`);
- await assert.rejects(complete('rollback'),/injected failure/);assert.equal((await one('SELECT "fxzt" FROM "MemoryPracticeRecord" WHERE "objectId"=\'rollback\'')).fxzt,'0');
- await db.exec('DROP TRIGGER test_fail_reward ON "ReviewReward"');await complete('rollback');
- });
- test('跨月批次防重,数据库校正金额,审核后禁止变更和删除',async()=>{
- const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');
- await query('INSERT INTO "PayrollBatch" ("objectId","company","month","status") VALUES (\'batch1\',\'c\',$1,\'draft\'),(\'batch2\',\'c\',\'2099-01\',\'draft\')',[r.incomeMonth]);
- const add=(id,batch,reward=r.objectId)=>query('INSERT INTO "PayrollLine" ("objectId","company","batchId","kind","reviewRewardId","amount","coachId") VALUES ($1,\'c\',$2,\'review_reward\',$3,999,999)',[id,batch,reward]);
- await add('line1','batch1');await assert.rejects(add('line2','batch2'),/unique/);
- const line=await one('SELECT * FROM "PayrollLine" WHERE "objectId"=\'line1\'');assert.equal(line.amount,1);assert.equal(line.coachId,10);
- await query('UPDATE "PayrollBatch" SET "status"=\'reviewed\',"payableAmount"=999 WHERE "objectId"=\'batch1\'');
- const batch=await one('SELECT * FROM "PayrollBatch" WHERE "objectId"=\'batch1\'');assert.equal(batch.payableAmount,1);assert.equal(batch.reviewCount,1);assert.equal(batch.lessonCount,0);
- await assert.rejects(query('DELETE FROM "PayrollLine" WHERE "objectId"=\'line1\''),/已锁定/);
- await assert.rejects(query('UPDATE "PayrollLine" SET "excluded"=true WHERE "objectId"=\'line1\''),/已锁定/);
- await assert.rejects(query('UPDATE "PayrollBatch" SET "status"=\'draft\' WHERE "objectId"=\'batch1\''),/不可重新打开/);
- await query('UPDATE "PayrollBatch" SET "status"=\'paid\' WHERE "objectId"=\'batch1\'');
- await assert.rejects(query('DELETE FROM "PayrollBatch" WHERE "objectId"=\'batch1\''),/不能删除/);
- const history=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'history\'');await assert.rejects(add('history-line','batch2',history.objectId),/不可结算/);
- });
- test('老师端收入、奖励列表和工资来源同一凭据;分页筛选与跨月未结算',async()=>{
- const ctx={Psql:{query,one},Date};vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),ctx);
- const total=await ctx.reviewIncomeTotal('c',10);assert.equal(total,3);
- const page=await ctx.reviewRewardPage('c',{search:'老师',coachId:10,status:'earned',page:1,pageSize:2});assert.equal(page.total,3);assert.equal(page.items.length,2);assert.equal(page.summary.reviewAmount,3);
- const pending=await ctx.reviewRewardPayrollLines('c','2099-01',false);assert.equal(pending.length,2);assert.ok(pending.every(r=>r.reviewTaskId!=='first'));
- assert.equal(await ctx.reviewIncomeTotal('other',10),0);
- });
- test('工资汇总分开课酬、奖励与调整;筛选和导出不泄漏其他老师或排除项',()=>{
- const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
- const code=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
- const context={};vm.createContext(context);vm.runInContext(code,context);
- const base={coachId:10,coachName:'老师',storeId:40,storeName:'门店',durationHours:0,amount:0,classType:0,lessonAt:'2026-09-09T01:00:00.000Z'};
- const lines=[{...base,kind:'lesson',amount:40,durationHours:1,classType:2},{...base,kind:'review_reward',amount:1},{...base,kind:'adjustment',amount:-5},{...base,kind:'review_reward',amount:1,excluded:true},{...base,coachId:99,coachName:'其他',storeId:80,kind:'review_reward',amount:1}];
- const summary=context.payrollResult('2026-09',lines,{refreshedAt:''},{storeId:40});
- assert.equal(summary.baseAmount,40);assert.equal(summary.reviewAmount,1);assert.equal(summary.reviewCount,1);assert.equal(summary.adjustmentAmount,-5);assert.equal(summary.payableAmount,36);assert.equal(summary.lessonCount,1);assert.equal(summary.totalHours,1);assert.equal(summary.lines.length,3);assert.equal(summary.coachItems[0].payableAmount,36);
- });
- test('实际工资草稿刷新路径反复调用仍只有两条待结算奖励,审核不重复吸收',async()=>{
- const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
- const context={Date,Psql:{query,one},Parse:{}};vm.createContext(context);
- vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),context);
- let sequence=0;
- const columns={};for(const table of ['PayrollBatch','PayrollLine'])columns[table]=(await query('SELECT column_name FROM information_schema.columns WHERE table_name=$1',[table])).map(r=>r.column_name);
- class Model {
- constructor(table,data={}){this.table=table;this.values={...data};this.id=data.objectId;this.updatedAt=new Date();}
- get(key){return this.values[key];} set(key,value){this.values[key]=value;return this;}
- toJSON(){return {...this.values,objectId:this.id};}
- async save(){
- const exists=Boolean(this.id);if(!this.id)this.id='generated-line-'+(++sequence);
- const values={...this.values,objectId:this.id},keys=Object.keys(values).filter(key=>columns[this.table].includes(key)&&values[key]!==undefined);
- const args=keys.map(key=>values[key]);
- const statement=exists?`UPDATE "${this.table}" SET ${keys.map((key,i)=>`"${key}"=$${i+1}`).join(',')} WHERE "objectId"=$${keys.length+1} RETURNING *`:`INSERT INTO "${this.table}" (${keys.map(k=>'"'+k+'"').join(',')}) VALUES (${keys.map((_,i)=>'$'+(i+1)).join(',')}) RETURNING *`;
- this.values=await one(statement,exists?[...args,this.id]:args);return this;
- }
- }
- context.Parse.Object=Model;
- context.requireRole=()=>{};context.audit=async()=>{};context.companyIdOf=()=> 'c';context.companyPointer=()=> 'c';
- context.payrollBatchObject=async(_context,_input,id)=>new Model('PayrollBatch',await one('SELECT * FROM "PayrollBatch" WHERE "objectId"=$1',[id]));
- context.payrollLinesForBatch=async(_context,_input,id)=>(await query('SELECT * FROM "PayrollLine" WHERE "batchId"=$1',[id])).map(row=>new Model('PayrollLine',row));
- context.payrollPreviewData=async(_context,input,month,includeUsed)=>context.payrollResult(month,await context.reviewRewardPayrollLines('c',month,includeUsed),{refreshedAt:new Date().toISOString()},input);
- const input={targetId:'batch2',reason:'测试增量刷新'};
- await context.refreshPayrollDraft({},input);await context.refreshPayrollDraft({},input);
- assert.equal((await one('SELECT COUNT(*)::int count FROM "PayrollLine" WHERE "batchId"=\'batch2\'')).count,2);
- assert.equal((await one('SELECT "payableAmount" FROM "PayrollBatch" WHERE "objectId"=\'batch2\'')).payableAmount,2);
- await context.payrollWorkflow({current:{id:'manager'}},input,'review',{});
- assert.equal((await one('SELECT "status" FROM "PayrollBatch" WHERE "objectId"=\'batch2\'')).status,'reviewed');
- assert.equal((await context.reviewRewardPayrollLines('c','2099-02',false)).length,0);
- });
|