import {test,before,after} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';import {PGlite} from '@electric-sql/pglite'; const db=new PGlite(),source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8'); const q=async(sql,args=[])=>(await db.query(sql,args)).rows,one=async(sql,args=[])=>(await q(sql,args))[0]; const migration=readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8'); const context=vm.createContext({Date,Psql:{query:q,one,oneOrNone:one}}); vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),context); context.companyIdOf=()=> 'co'; const manager={roles:['ops-manager'],current:{id:'teacher'}}; async function command(op,target,req,id){return(await one('SELECT xs_payroll_command(\'co\',\'teacher\',$1,$2,$3::jsonb,$4) AS result',[op,target,JSON.stringify({reason:'测试工资',...req}),id])).result;} async function version(){return(await one('SELECT xs_payroll_source_version(\'co\') AS value')).value;} before(async()=>{ const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n });/)[1]+')'); for(const [name,fields] of Object.entries(schemas)){if(name==='CommonModel')continue;const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);} await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz); CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text); CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text); CREATE TABLE "DailyStudyRecord" ("objectId" text); CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,id numeric,plid text,yhid text,xxjlid text,kywrq text,kywsj text,kcid numeric,fxzt text,wcsj text,"updatedAt" timestamptz); CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric); CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,company text,"sourceKey" text NOT NULL UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,cdate timestamptz,cname text,type1 text,type2 text,type3 text,clevel numeric,"cadminId" numeric,detail text,content1 text,content2 text); ALTER TABLE "CourseAppointment" ADD COLUMN szyh text,ADD COLUMN kcid text,ADD COLUMN pl text,ADD COLUMN fxpl text,ADD COLUMN dszt text,ADD COLUMN dslx text,ADD COLUMN jffs text,ADD COLUMN jssj text,ADD COLUMN kssj text,ADD COLUMN yysj text,ADD COLUMN bxrq text,ADD COLUMN sdsd text,ADD COLUMN plxm text,ADD COLUMN yykcid text; INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyGroupId","identityType","legacyUserData","realName",username) VALUES ('teacher','co',30,3,'coach','{"ParentUserID":10}','老师','teacher'),('student','co',20,1,'member','{"UserPoint":2,"Purse":2}','学员','student'),('store','co',10,2,'store','{}','门店','store');`); for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,company text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" numeric,score numeric,"scoreBefore" numeric,"hisTime" timestamptz,operator numeric,"scoreType" numeric,detail text,remark text,"payMethod" text)`); await db.exec(readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8')); await db.exec(readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8')); await db.exec(migration); await db.exec(readFileSync(new URL('../sql/store-administrators.sql',import.meta.url),'utf8').split('CREATE OR REPLACE FUNCTION xs_store_support_process')[0]); await db.exec(readFileSync(new URL('../sql/store-payroll.sql',import.meta.url),'utf8')); await db.exec(`INSERT INTO "_User" ("objectId",company,"legacyUserId","identityType","legacyUserData",username) VALUES ('store-b','co',11,'store','{}','store-b'),('admin-a','co',NULL,'store-admin','{}','admin-a'),('admin-b','co',NULL,'store-admin','{}','admin-b'),('teacher-b','co',31,'coach','{"ParentUserID":11}','teacher-b'); INSERT INTO "StoreAdministrator" ("objectId",company,"userId","storeObjectId","createdBy") VALUES ('bind-a','co','admin-a','store','hq'),('bind-b','co','admin-b','store-b','hq'); INSERT INTO "LessonRecord" ("objectId",company,id,jsmz,xymz,"legacyGeneralId","lessonAt","teacherPaySnapshot") VALUES ('lesson-a','co',701,'30','20',701,'2026-09-01 10:00+08',40),('lesson-b','co',702,'31','20',702,'2026-09-01 11:00+08',40);`); context.teachingCategoryRows=async()=>[];context.courseTeachingRows=async()=>[];context.payRateRows=async()=>context.defaultPayRates(); });after(()=>db.close()); const ownLine={kind:'lesson',standardLessonKey:'legacy:701',legacyGeneralId:701,storeId:10,coachId:30,coachName:'老师',amount:40,rateAmount:40,durationHours:1,originalMonth:'2026-09',lessonAt:'2026-09-01T02:00:00Z',exception:''}; const otherLine={...ownLine,standardLessonKey:'legacy:702',legacyGeneralId:702,storeId:11,coachId:31}; const run=async(actor,scope,op,target,payload,id)=>(await one('SELECT xs_payroll_command(\'co\',$1,$2,$3,$4::jsonb,$5) result',[actor,op,target,JSON.stringify({scopeStoreId:scope,reason:'测试门店独立工资',...payload}),id])).result; let a,b; test('同月门店各自建单、不能伪造门店或跨店明细',async()=>{ const v=await version(); await assert.rejects(run('admin-a',11,'create','',{month:'2026-09',lines:[],sourceVersion:v},'bad-store'),/归属/); await assert.rejects(run('admin-a',10,'create','',{month:'2026-09',lines:[otherLine],sourceVersion:v},'bad-lines'),/不属于/); await assert.rejects(run('admin-a',10,'create','',{month:'2026-09',lines:[{...otherLine,storeId:10}],sourceVersion:v},'spoof-lines'),/不属于/); a=await run('admin-a',10,'create','',{month:'2026-09',lines:[ownLine],sourceVersion:v},'create-a'); b=await run('admin-b',11,'create','',{month:'2026-09',lines:[otherLine],sourceVersion:v},'create-b'); assert.equal(Number(a.scopeStoreId),10);assert.equal(Number(b.scopeStoreId),11);assert.equal(a.payableAmount,40); assert.equal((await run('admin-a',10,'create','',{month:'2026-09',lines:[ownLine],sourceVersion:v},'create-a')).objectId,a.objectId); await assert.rejects(run('admin-a',10,'create','',{month:'2026-09',lines:[],sourceVersion:v},'duplicate-a'),/unique|重复/); await assert.rejects(run('teacher',0,'create','',{month:'2026-09',lines:[ownLine],sourceVersion:v},'hq-duplicate'),/unique|重复/); }); test('跨店批次与幂等重放均拒绝,门店只能调整本店老师',async()=>{ await assert.rejects(run('admin-b',11,'adjustment',a.objectId,{version:a.version,coachId:31,amount:5},'other-adjust'),/不属于/); await assert.rejects(run('admin-a',10,'adjustment',a.objectId,{version:a.version,coachId:31,amount:5},'other-coach'),/不属于/); a=await run('admin-a',10,'adjustment',a.objectId,{version:a.version,coachId:30,amount:5},'own-adjust');assert.equal(a.payableAmount,45); }); test('本店审核与登记发放,保持来源校验和审核锁定',async()=>{ await assert.rejects(run('admin-a',10,'review',a.objectId,{version:a.version,sourceVersion:'stale',lines:[ownLine]},'stale-review'),/来源已更新/); a=await run('admin-a',10,'review',a.objectId,{version:a.version,sourceVersion:await version(),lines:[ownLine]},'review-a');assert.equal(a.status,'reviewed'); await assert.rejects(run('admin-a',10,'adjustment',a.objectId,{version:a.version,coachId:30,amount:1},'locked-adjust'),/草稿/); a=await run('admin-a',10,'mark-paid',a.objectId,{version:a.version,paymentMethod:'测试转账',paymentReference:'测试凭据'},'paid-store-a');assert.equal(a.status,'paid'); await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='store'`); await assert.rejects(run('admin-a',10,'mark-paid',a.objectId,{version:a.version,paymentMethod:'测试转账',paymentReference:'测试凭据'},'paid-store-a'),/停用/); }); test('工资展示可省去未使用的来源版本计算,仍保留结算状态;默认命令读取保留完整版本',async()=>{ const c=vm.createContext({Date,Psql:{query:async()=>[]}});vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),c);c.companyIdOf=()=> 'co';let sql='',checks=0;c.payrollSql=async q=>{sql=q;return{rows:[],version:''};};c.payrollSettlementSyncStatus=async()=>{checks++;return{settlementReady:false,message:'待核验'};}; const display=await c.payrollCanonicalSnapshot(manager,{}, {omitSourceVersion:true});assert.equal(display.sourceStatus.sync.settlementReady,false);assert.ok(!sql.includes('xs_payroll_source_version')); await c.payrollCanonicalSnapshot(manager,{});assert.ok(sql.includes('xs_payroll_source_version'));assert.equal(checks,2); });