|
|
@@ -15,10 +15,10 @@ function payrollTenant(context,input){requireRole(context,['ops-manager','ops-st
|
|
|
async function payrollSql(sql,args){try{return await Psql.one(sql,args);}catch(error){fail(/does not exist/.test(text(error.message))?503:409,text(error.message).replace(/^.*ERROR:\s*/,''));}}
|
|
|
function payrollIdentity(item){return text(item.standardLessonKey)||(number(item.legacyGeneralId)?'legacy:'+number(item.legacyGeneralId):'lesson:'+item.objectId);}
|
|
|
const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
|
|
|
-async function payrollSettlementSyncStatus(context,input){
|
|
|
+async function payrollSettlementSyncStatus(context,input,readAcceleration=false){
|
|
|
const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7);
|
|
|
- try {const s=(await payrollSql('SELECT xs_payroll_cutoff_snapshot($1,$2) AS result',[co,month])).result;
|
|
|
- return {state:s.approved?'cutoff_verified':'batch_verified',settlementReady:s.approved,cutoffVersion:s.version,monthClosed:s.monthClosed,lastSuccessAt:s.cutoffAt||'',lagSeconds:s.cutoffAt?Math.max(0,Math.floor((Date.now()-new Date(s.cutoffAt).getTime())/1000)):null,datasets:s.datasets,message:s.approved?(s.monthClosed?'该月份来源已核验;源数据变化后须重新确认':'已核验截至同步时间的来源,可生成草稿;整月同步后才能审核'):'旧接口来源截止核验待完成:'+(!s.complete?'凭据缺失或业务编号不一致':s.pending+' 条计薪差异待核对')};
|
|
|
+ try {const s=(await payrollSql('SELECT '+(readAcceleration?'xs_payroll_cutoff_snapshot_fast':'xs_payroll_cutoff_snapshot')+'($1,$2) AS result',[co,month])).result;
|
|
|
+ return {...(readAcceleration?{readSourceVersion:s.sourceVersion}:{}),state:s.approved?'cutoff_verified':'batch_verified',settlementReady:s.approved,cutoffVersion:s.version,monthClosed:s.monthClosed,lastSuccessAt:s.cutoffAt||'',lagSeconds:s.cutoffAt?Math.max(0,Math.floor((Date.now()-new Date(s.cutoffAt).getTime())/1000)):null,datasets:s.datasets,message:s.approved?(s.monthClosed?'该月份来源已核验;源数据变化后须重新确认':'已核验截至同步时间的来源,可生成草稿;整月同步后才能审核'):'旧接口来源截止核验待完成:'+(!s.complete?'凭据缺失或业务编号不一致':s.pending+' 条计薪差异待核对')};
|
|
|
}catch(error){return {state:'error',settlementReady:false,message:'工资来源核验暂不可用:'+text(error.message).slice(0,140),datasets:[]};}
|
|
|
}
|
|
|
async function payrollCutoff(context,input,payload,confirm=false){
|
|
|
@@ -31,8 +31,9 @@ async function payrollCanonicalSnapshot(context,input,options={}) {
|
|
|
const co=payrollTenant(context,input);
|
|
|
// A display-only estimate cannot authorize settlement. Financial reads and all
|
|
|
// commands keep the full source version and cutoff checks by default.
|
|
|
- const forDashboard=options.forDashboard===true;
|
|
|
- const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,'+((forDashboard||options.omitSourceVersion===true)?"''::text":'xs_payroll_source_version($1)')+' AS version FROM xs_payroll_sources($1) s',[co]),forDashboard?Promise.resolve({state:'not_checked',settlementReady:false,datasets:[],message:'金额已按实时完课记录计算,结算资格仍需完整核验'}):payrollSettlementSyncStatus(context,input)]);
|
|
|
+ const forDashboard=options.forDashboard===true,readAcceleration=options.readAcceleration===true;
|
|
|
+ const [snapshot,syncResult]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,'+((forDashboard||readAcceleration||options.omitSourceVersion===true)?"''::text":(readAcceleration?'xs_payroll_source_version_fast($1)':'xs_payroll_source_version($1)'))+' AS version FROM '+(readAcceleration?'xs_payroll_sources_cached':'xs_payroll_sources')+'($1) s',[co]),forDashboard?Promise.resolve({state:'not_checked',settlementReady:false,datasets:[],message:'金额已按实时完课记录计算,结算资格仍需完整核验'}):payrollSettlementSyncStatus(context,input,readAcceleration)]);
|
|
|
+ const {readSourceVersion,...sync}=syncResult;
|
|
|
const users=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1',[co]);
|
|
|
const map=new Map(users.map(u=>[number(u.legacyUserId||legacyOf(u).UserID),u]));
|
|
|
const items=(snapshot.rows||[]).map(row=>{
|
|
|
@@ -42,7 +43,7 @@ async function payrollCanonicalSnapshot(context,input,options={}) {
|
|
|
studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
|
|
|
teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
|
|
|
});
|
|
|
- const scopedItems=context.storeId?items.filter(i=>i.storeId===context.storeId):items;return {items:scopedItems,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:scopedItems.length,refreshedAt:new Date().toISOString(),complete:sync.settlementReady,stale:!sync.settlementReady,cacheHit:false,sync}};
|
|
|
+ const scopedItems=context.storeId?items.filter(i=>i.storeId===context.storeId):items;return {items:scopedItems,version:readAcceleration&&!forDashboard&&options.omitSourceVersion!==true?text(readSourceVersion):snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:scopedItems.length,refreshedAt:new Date().toISOString(),complete:sync.settlementReady,stale:!sync.settlementReady,cacheHit:false,sync}};
|
|
|
}
|
|
|
async function payrollSourceRows(context,input,month,requireLive=false){const data=await payrollCanonicalSnapshot(context,input);return {...data,items:data.items.filter(i=>payrollBusinessDate(i.lessonAt).slice(0,7)===month)};}
|
|
|
function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
|
|
|
@@ -51,9 +52,19 @@ function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
|
|
|
return {...item,kind:'lesson',standardLessonKey:payrollIdentity(item),lessonId:item.objectId,lessonAt:payrollDateIso(item.lessonAt),originalMonth:date.slice(0,7),settlementMonth,classType:type,classTypeLabel:categoryName||classType(type),courseCategoryKey:categoryKey,courseCategoryName:categoryName,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),durationMinutes:minutes,durationHours:configured?minutes/60:type===4?0:number(rule?.durationHours,minutes/60),teachingRuleId:text(item.teachingRuleId||rule?.objectId),rateAmount:amount,amount:exception?0:amount,description:snapshot?'按已保存的完课/预约工资快照':'按上课日期匹配历史工资规则',exception};
|
|
|
}
|
|
|
async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false,options={}){
|
|
|
+ // Generation, refresh, review and forced-live callers retain uncached financial checks.
|
|
|
+ if(!includeUsed||requireLive||input.targetId)return payrollPreviewUncached(context,input,month,includeUsed,requireLive,options);
|
|
|
+ const co=payrollTenant(context,input),key='preview-v1:'+JSON.stringify([month,context.storeId||0,number(input.storeId||(input.filters||{}).storeId),text(input.search),options.forDashboard===true,options.omitSourceVersion===true]);
|
|
|
+ let cached;try{cached=(await Psql.one('SELECT xs_payroll_preview_cache_read($1,$2) AS result',[co,key])).result;}catch{ /* Missing/failed cache always falls back to the authoritative path. */ }
|
|
|
+ if(cached?.payload){const result=cached.payload,refreshedAt=new Date().toISOString(),sync={...result.sourceStatus.sync};if(sync.lastSuccessAt)sync.lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(sync.lastSuccessAt).getTime())/1000));return {...result,refreshedAt,sourceStatus:{...result.sourceStatus,refreshedAt,cacheHit:true,sync}};}
|
|
|
+ const result=await payrollPreviewUncached(context,input,month,includeUsed,requireLive,{...options,readAcceleration:Boolean(cached?.stamp)});
|
|
|
+ if(cached?.stamp&&result.sourceStatus?.sync?.state!=='error')try{await Psql.one('SELECT xs_payroll_preview_cache_write($1,$2,$3,$4::jsonb) AS saved',[co,key,cached.stamp,JSON.stringify({snapshot:cached.snapshot,result})]);}catch{ /* Cache availability must not change financial results. */ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+async function payrollPreviewUncached(context,input,month,includeUsed=false,requireLive=false,options={}){
|
|
|
if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');
|
|
|
if(context.storeId)input={...input,storeId:context.storeId};
|
|
|
- const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,{...input,month},{forDashboard:options.forDashboard===true&&includeUsed&&!requireLive,omitSourceVersion:options.omitSourceVersion===true&&includeUsed&&!requireLive}),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
|
|
|
+ const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,{...input,month},{forDashboard:options.forDashboard===true&&includeUsed&&!requireLive,omitSourceVersion:options.omitSourceVersion===true&&includeUsed&&!requireLive,readAcceleration:options.readAcceleration===true&&includeUsed&&!requireLive&&!input.targetId}),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
|
|
|
const other=new Set(used.filter(r=>r.batchId!==input.targetId).map(r=>r.standardLessonKey)),mappingMap=new Map(mappings.filter(r=>r.confirmed).map(r=>[r.courseId,r]));
|
|
|
const previous=month.endsWith('-01')?(Number(month.slice(0,4))-1)+'-12':month.slice(0,5)+String(Number(month.slice(5))-1).padStart(2,'0');
|
|
|
const allowLate=!includeUsed&&Boolean((await Psql.one('SELECT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE "company"=$1 AND "month"=$2 AND COALESCE("scopeStoreId",0)=$3 AND "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous,context.storeId||0])).value);
|
|
|
@@ -72,9 +83,10 @@ async function payrollOverviewData(context,input){
|
|
|
const batch=batches[0],settlement=batch?await payrollDetailData(context,{},batch.objectId):null;
|
|
|
const visible=payrollResult(month,income.lines,income.sourceStatus,input),settlementTotals=settlement?payrollResult(month,settlement.lines,income.sourceStatus,input):null;
|
|
|
const stores=context.storeId?[{storeId:context.storeId,storeName:storeNameOf((context.store||context.current).toJSON())}]:await Psql.query('SELECT "legacyUserId" AS "storeId",COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"username") AS "storeName" FROM "_User" WHERE "company"=$1 AND ("identityType"=\'store\' OR "legacyGroupId"=2) ORDER BY "legacyUserId"',[co]);
|
|
|
- return {...visible,batches,stores,incomeAmount:visible.payableAmount,settlementAmount:settlementTotals?.payableAmount??null,settlementAdjustmentAmount:settlementTotals?.adjustmentAmount??0,settlementCarryAmount:settlementTotals?settlementTotals.lines.filter(l=>l.kind!=='adjustment'&&l.originalMonth!==month&&!l.exception).reduce((a,l)=>a+l.amount,0):0};
|
|
|
+ const detailLines=input.summaryOnly===true?[]:number(input.lineCoachId)>0?visible.lines.filter(line=>line.coachId===number(input.lineCoachId)):visible.lines;
|
|
|
+ return {...visible,lines:detailLines,exceptions:detailLines.filter(line=>line.exception&&!line.excluded),batches,stores,incomeAmount:visible.payableAmount,settlementAmount:settlementTotals?.payableAmount??null,settlementAdjustmentAmount:settlementTotals?.adjustmentAmount??0,settlementCarryAmount:settlementTotals?settlementTotals.lines.filter(l=>l.kind!=='adjustment'&&l.originalMonth!==month&&!l.exception).reduce((a,l)=>a+l.amount,0):0};
|
|
|
}
|
|
|
-async function payrollExportData(context,input){const data=input.targetId?await payrollDetailData(context,input,input.targetId):await payrollOverviewData(context,input),filtered=payrollVisibleLines(data.lines,input).filter(l=>!number(input.coachId)||l.coachId===number(input.coachId)),included=filtered.filter(l=>!l.excluded),excluded=filtered.filter(l=>l.excluded),totals=payrollResult(data.month,included,data.sourceStatus||{refreshedAt:new Date().toISOString()},{});return{fileName:'小树陪练工资_'+data.month+'.xlsx',summary:totals,coachItems:totals.coachItems,lines:included.filter(l=>!l.exception),exceptions:included.filter(l=>l.exception),excluded};}
|
|
|
+async function payrollExportData(context,input){const data=input.targetId?await payrollDetailData(context,input,input.targetId):await payrollOverviewData(context,{...input,summaryOnly:false,lineCoachId:0}),filtered=payrollVisibleLines(data.lines,input).filter(l=>!number(input.coachId)||l.coachId===number(input.coachId)),included=filtered.filter(l=>!l.excluded),excluded=filtered.filter(l=>l.excluded),totals=payrollResult(data.month,included,data.sourceStatus||{refreshedAt:new Date().toISOString()},{});return{fileName:'小树陪练工资_'+data.month+'.xlsx',summary:totals,coachItems:totals.coachItems,lines:included.filter(l=>!l.exception),exceptions:included.filter(l=>l.exception),excluded};}
|
|
|
async function confirmLegacyReviewRewards(context,input,payload){
|
|
|
// Confirmation validates the selected immutable receipts in SQL; bridge configuration
|
|
|
// is unrelated to whether these particular legacy tasks were completed and unpaid.
|