// Read-only teacher view of the SAME canonical sources/calculator as operations payroll. async function teacherLessonEarnings(companyId,coachId,appointmentIds=null) { const calc=teacherEarningsCalculator(); const [sources,storedRates,storedRules,mappings]=await Promise.all([ Psql.query('SELECT s FROM xs_payroll_sources($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]), Psql.query('SELECT * FROM "CoachPayRate" WHERE company=$1 ORDER BY "createdAt" DESC,"objectId" DESC',[companyId]), Psql.query('SELECT * FROM "TeachingCompensationRule" WHERE company=$1 ORDER BY revision DESC,"createdAt" DESC,"objectId"',[companyId]), Psql.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]) ]); 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))unique.get(row.standardLessonKey).duplicate=true;else unique.set(row.standardLessonKey,row);} const lines=[...unique.values()].map(r=>calc.calculate(r,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 teacherIncomeSummary(companyId,coachId) { const [lessons,reviewIncome]=await Promise.all([teacherLessonEarnings(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)};}