| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889 |
- // Read-only teacher view of the SAME canonical sources/calculator as operations payroll.
- async function teacherEarningsSourceRows(db,companyId,coachId,appointmentIds){
- const available=await db.one("SELECT to_regprocedure('xs_payroll_source_entries_scoped(text,text,text,text[],boolean)') IS NOT NULL AS scoped,to_regprocedure('xs_payroll_sources_indexed(text)') IS NOT NULL AS available");
- // The source selector is deliberately conservative; preserve exact ownership/appointment predicates here.
- if(available.scoped)return db.query('SELECT row_data AS s FROM xs_payroll_source_entries_scoped($1,NULL,$2,$3,false) WHERE row_data IS NOT NULL AND COALESCE(row_data->>\'coachId\',row_data->>\'jsmz\')=$2 AND ($3::text[] IS NULL OR COALESCE(row_data->>\'appointmentGeneralId\',row_data->>\'yyds\')=ANY($3)) ORDER BY ordinal',[companyId,String(coachId),appointmentIds]);
- const source=available.available?'xs_payroll_sources_indexed':'xs_payroll_sources';
- return db.query('SELECT s FROM '+source+'($1) s WHERE COALESCE(s->>\'coachId\',s->>\'jsmz\')=$2 AND ($3::text[] IS NULL OR COALESCE(s->>\'appointmentGeneralId\',s->>\'yyds\')=ANY($3))',[companyId,String(coachId),appointmentIds]);
- }
- async function teacherEarningsInputs(companyId,coachId,appointmentIds=null,db=Psql) {
- if(db===Psql&&typeof Psql.tx==='function')return Psql.tx(async tx=>{await tx.none('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return teacherEarningsInputs(companyId,coachId,appointmentIds,tx);});
- const [sources,storedRates,storedRules,mappings]=await Promise.all([
- teacherEarningsSourceRows(db,companyId,coachId,appointmentIds),
- db.query('SELECT * FROM "CoachPayRate" WHERE company=$1 ORDER BY "createdAt" DESC,"objectId" DESC',[companyId]),
- db.query('SELECT * FROM "TeachingCompensationRule" WHERE company=$1 ORDER BY revision DESC,"createdAt" DESC,"objectId"',[companyId]),
- db.query('WITH RECURSIVE ancestors AS (SELECT n."nodeId" AS course_id,n."nodeId",n."parentId",n."nodeName",0 AS depth FROM "Node" n WHERE company=$1 UNION ALL SELECT a.course_id,n."nodeId",n."parentId",n."nodeName",a.depth+1 FROM ancestors a JOIN "Node" n ON n.company=$1 AND n."nodeId"=a."parentId" WHERE a.depth<12) SELECT p.*,n."nodeName" AS "courseName",array_agg(a."nodeName" ORDER BY a.depth) AS ancestor_names FROM "CourseTeachingProfile" p LEFT JOIN "Node" n ON n.company=p.company AND n."nodeId"=p."courseId" LEFT JOIN ancestors a ON a.course_id=p."courseId" WHERE p.company=$1 GROUP BY p."objectId",n."nodeName"',[companyId])
- ]);
- return {sources,storedRates,storedRules,mappings};
- }
- function teacherPrepareEarnings(input,calc){
- const {sources,storedRates,storedRules,mappings}=input;
- const rateKeys=new Set(),rates=[];
- for(const row of storedRates){const key=row.classType+':'+row.effectiveFrom;if(rateKeys.has(key))continue;rateKeys.add(key);rates.push({...row,classType:Number(row.classType),amount:Number(row.amount),durationHours:Number(row.durationHours),active:row.active!==false,isDefault:false});}
- rates.push(...calc.defaultRates().filter(r=>!rateKeys.has(r.classType+':'+r.effectiveFrom)));
- const ruleKeys=new Set(),rules=[];
- for(const row of storedRules){const key=[row.categoryKey,row.durationMinutes,row.deliveryMode,row.effectiveFrom].join(':');const superseded=ruleKeys.has(key);ruleKeys.add(key);rules.push({...calc.rule(row),active:row.active!==false&&!superseded});}
- rules.push(...calc.defaultRules().filter(r=>!ruleKeys.has([r.categoryKey,r.durationMinutes,r.deliveryMode,r.effectiveFrom].join(':'))));
- const mappingMap=new Map(mappings.filter(r=>calc.category(r)).map(r=>[Number(r.courseId),r]));
- const unique=new Map();
- for(const {s:raw} of sources){const row={...calc.project(raw,'cache'),standardLessonKey:raw.standardLessonKey,commonStatus:Number(raw.commonStatus??99),teacherPaySnapshot:raw.teacherPaySnapshot==null?null:Number(raw.teacherPaySnapshot)};if(row.commonStatus===-2)continue;if(unique.has(row.standardLessonKey)){const group=unique.get(row.standardLessonKey);group.item.duplicate=true;group.raw.push(raw);}else unique.set(row.standardLessonKey,{item:row,raw:[raw]});}
- return {calc,rates,rules,mappingMap,groups:[...unique.values()]};
- }
- function teacherCalculatePreparedEarnings(prepared){
- const {calc,rates,rules,mappingMap,groups}=prepared,lines=groups.map(group=>calc.calculate(group.item,rates,'',rules,mappingMap));
- return {lines,lessonIncome:lines.reduce((sum,l)=>sum+Math.round(l.amount*100),0)/100,pendingPayCount:lines.filter(l=>l.exception).length};
- }
- async function teacherLessonEarnings(companyId,coachId,appointmentIds=null){
- return teacherCalculatePreparedEarnings(teacherPrepareEarnings(await teacherEarningsInputs(companyId,coachId,appointmentIds),teacherEarningsCalculator()));
- }
- async function teacherEarningsFingerprint(value){
- if(typeof crypto==='undefined'||!crypto.subtle||typeof TextEncoder==='undefined')return null;
- const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(value)));
- return Array.from(new Uint8Array(hash),byte=>byte.toString(16).padStart(2,'0')).join('');
- }
- async function teacherMonthlyEarningsInputs(companyId,coachId){
- // Read derived generations in the SAME snapshot as their fresh source inputs.
- // Otherwise an old reader can observe a newer summary and CAS-write old input over it.
- if(typeof crypto==='undefined'||!crypto.subtle||typeof TextEncoder==='undefined'||typeof Psql.tx!=='function')return teacherEarningsInputs(companyId,coachId);
- try{return await Psql.tx(async db=>{
- await db.none('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
- const input=await teacherEarningsInputs(companyId,coachId,null,db),available=await db.one("SELECT to_regprocedure('xs_teacher_earned_month_read(text,numeric,jsonb)') IS NOT NULL AS available");
- const monthlyStore=available.available?(await db.one('SELECT xs_teacher_earned_month_read($1,$2,NULL) AS rows',[companyId,Number(coachId)])).rows||[]:[];
- return {...input,monthlyStore,monthlyStoreAvailable:available.available};
- });}catch{return teacherEarningsInputs(companyId,coachId);}
- }
- async function teacherMonthlyLessonEarnings(companyId,coachId){
- const input=await teacherMonthlyEarningsInputs(companyId,coachId),prepared=teacherPrepareEarnings(input,teacherEarningsCalculator()),months=new Map();
- // Preserve the original global representative/duplicate decision BEFORE month partitioning.
- for(const group of prepared.groups){const month=prepared.calc.businessDate(group.item.lessonAt).slice(0,7)||'undated';if(!months.has(month))months.set(month,[]);months.get(month).push(group);}
- if(!months.size)return{lessonIncome:0,pendingPayCount:0,monthlyReuse:{available:true,matched:0,computed:0}};
- let dependencies;
- try{dependencies=await teacherEarningsFingerprint({calculator:teacherEarningsCalculator.toString(),implementation:[teacherEarningsSourceRows,teacherEarningsInputs,teacherPrepareEarnings,teacherCalculatePreparedEarnings,teacherEarningsFingerprint,teacherMonthlyEarningsInputs,teacherMonthlyLessonEarnings].map(fn=>fn.toString()),rates:input.storedRates,rules:input.storedRules,mappings:input.mappings});}catch{dependencies=null;}
- if(!dependencies)return{...teacherCalculatePreparedEarnings(prepared),monthlyReuse:{available:false,matched:0,computed:months.size}};
- let requests;
- try{requests=await Promise.all([...months].map(async([month,groups])=>({month,signature:await teacherEarningsFingerprint({dependencies,raw:groups.map(group=>group.raw)}),lineCount:groups.length})));}catch{requests=null;}
- if(!requests||requests.some(request=>!request.signature))return{...teacherCalculatePreparedEarnings(prepared),monthlyReuse:{available:false,matched:0,computed:months.size}};
- const stored=input.monthlyStore||[],available=input.monthlyStoreAvailable===true;
- const saved=new Map(stored.map(row=>[row.month,row])),writes=[];let amountCents=0,pendingPayCount=0,matched=0,computed=0;
- for(const request of requests){const previous=saved.get(request.month);let result;
- if(previous?.signature===request.signature&&previous.lineCount===request.lineCount&&Number.isSafeInteger(previous.amountCents)&&Number.isSafeInteger(previous.pendingPayCount)){result=previous;matched++;}
- else{const groups=months.get(request.month),lines=groups.map(group=>prepared.calc.calculate(group.item,prepared.rates,'',prepared.rules,prepared.mappingMap));result={amountCents:lines.reduce((sum,line)=>sum+Math.round(line.amount*100),0),pendingPayCount:lines.filter(line=>line.exception).length,lineCount:groups.length};computed++;writes.push({...request,...result,expectedSignature:previous?.signature??null});}
- amountCents+=result.amountCents;pendingPayCount+=result.pendingPayCount;
- if(!Number.isSafeInteger(result.amountCents)||!Number.isSafeInteger(amountCents))return{...teacherCalculatePreparedEarnings(prepared),monthlyReuse:{available:false,matched:0,computed:months.size}};
- }
- if(available&&writes.length)try{await Psql.one('SELECT xs_teacher_earned_month_write($1,$2,$3::jsonb) AS saved',[companyId,Number(coachId),JSON.stringify(writes)]);}catch{ /* Derived storage never changes the calculated response. */ }
- return{lessonIncome:amountCents/100,pendingPayCount,monthlyReuse:{available,matched,computed}};
- }
- async function teacherIncomeSummary(companyId,coachId) {
- const [lessons,reviewIncome]=await Promise.all([teacherMonthlyLessonEarnings(companyId,coachId),reviewIncomeTotal(companyId,coachId)]);
- return {lessonIncome:lessons.lessonIncome,reviewIncome,totalIncome:(Math.round(lessons.lessonIncome*100)+Math.round(reviewIncome*100))/100,pendingPayCount:lessons.pendingPayCount};
- }
- async function teacherSchedulePay(rows,coachId) {
- const ids=rows.filter(r=>Number(r.dszt)>=30&&Number(r.commonStatus)!==-2).map(r=>String(r.generalId));
- if(!ids.length)return rows;
- const [earnings,reports]=await Promise.all([teacherLessonEarnings(DEFAULT_COMPANY_ID,coachId,ids),Psql.query('SELECT "appointmentGeneralId"::text AS id,"feedbackStatus" FROM "LessonCompletionReport" WHERE company=$1 AND "coachId"=$2 AND "appointmentGeneralId"::text=ANY($3::text[])',[DEFAULT_COMPANY_ID,Number(coachId),ids])]);
- return rows.map(row=>{const matches=earnings.lines.filter(l=>String(l.appointmentGeneralId)===String(row.generalId)),line=matches[0],valid=matches.length===1&&!line.exception;return {...row,teacherPay:valid?line.amount:null,payStatus:valid?'earned':'pending',payReason:line?.exception||'工资待核对',feedbackStatus:reports.find(r=>r.id===String(row.generalId))?.feedbackStatus||'pending'};});
- }
- async function teacherIncomeLegacySummary(companyId,coachId){const result=await teacherIncomeSummary(companyId,coachId);return {...result,t_yongji:String(result.totalIncome)};}
|