// 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)};}