|
@@ -12,9 +12,31 @@ function payrollDateIso(value) {
|
|
|
function payrollTenant(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'工资结算必须指定帐套');return co;}
|
|
function payrollTenant(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'工资结算必须指定帐套');return co;}
|
|
|
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*/,''));}}
|
|
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);}
|
|
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){
|
|
|
|
|
+ const checkedAt=new Date().toISOString(),datasets=PAYROLL_SYNC_DATASETS.map(key=>({key,state:'never_synced',lastSuccessAt:'',lagSeconds:null,pendingFailures:0,conflicts:0,legacyCount:null,localCount:null})),config=syncConfig();
|
|
|
|
|
+ const result={state:'not_configured',settlementReady:false,message:'旧系统持续同步尚未配置,工资统计可能缺少最新记录',checkedAt,lastSuccessAt:'',lagSeconds:null,datasets};
|
|
|
|
|
+ if(!config.baseUrl||!config.keyId||!config.secret)return result;
|
|
|
|
|
+ try{
|
|
|
|
|
+ const co=payrollTenant(context,input),[checkpoints,issues,localCounts,manifest]=await Promise.all([
|
|
|
|
|
+ Psql.query('SELECT "dataset","state","cursor","lastSuccessAt","lastWatermark" FROM "LegacySyncCheckpoint" WHERE "company"=$1 AND "dataset"=ANY($2::text[]) ORDER BY "updatedAt" DESC',[co,PAYROLL_SYNC_DATASETS]),
|
|
|
|
|
+ Psql.query('SELECT "dataset","kind",COUNT(*)::int AS total FROM (SELECT "dataset",\'failure\' AS kind FROM "LegacySyncDeadLetter" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE UNION ALL SELECT "dataset",\'conflict\' AS kind FROM "LegacySyncConflict" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE) x WHERE "dataset"=ANY($2::text[]) GROUP BY "dataset","kind"',[co,PAYROLL_SYNC_DATASETS]),
|
|
|
|
|
+ Psql.query('SELECT "modelId"::int AS model,ARRAY_AGG("generalId"::bigint ORDER BY "generalId"::bigint) AS keys FROM "CommonModel" WHERE "company"=$1 AND "modelId" IN (54,59,60) AND COALESCE("status",99)<>-2 AND ("sourceKey" LIKE \'legacy:model:%\' OR "sourceKey" LIKE \'legacy-sync:%\') GROUP BY "modelId"',[co]),
|
|
|
|
|
+ syncBridgeRequest('/v1/manifest')
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const byKey=new Map();for(const row of checkpoints)if(!byKey.has(text(row.dataset)))byKey.set(text(row.dataset),row);
|
|
|
|
|
+ const manifestByKey=new Map((manifest.datasets||[]).map(row=>[text(row.key),row])),modelByKey={'appointments':54,'lessons':59,'memory-records':60},localByModel=new Map(localCounts.map(row=>[number(row.model),Array.isArray(row.keys)?row.keys.map(text):[]]));
|
|
|
|
|
+ for(const item of datasets){const checkpoint=byKey.get(item.key),remote=manifestByKey.get(item.key),keys=localByModel.get(modelByKey[item.key])||[];item.state=text(checkpoint?.state)||'never_synced';item.lastSuccessAt=payrollDateIso(checkpoint?.lastSuccessAt);item.lagSeconds=item.lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(item.lastSuccessAt).getTime())/1000)):null;item.legacyCount=remote?number(remote.count):null;item.localCount=keys.length;item.localChecksum=await sha256(keys.length?keys.join('\n')+'\n':'');item.pendingFailures=issues.filter(row=>row.dataset===item.key&&row.kind==='failure').reduce((sum,row)=>sum+number(row.total),0);item.conflicts=issues.filter(row=>row.dataset===item.key&&row.kind==='conflict').reduce((sum,row)=>sum+number(row.total),0);item.watermarkCurrent=Boolean(remote&&checkpoint&&number(checkpoint.lastWatermark)>=number(remote.watermark));item.complete=Boolean(checkpoint?.cursor&&item.state==='healthy'&&item.watermarkCurrent&&item.legacyCount!==null&&item.localCount===item.legacyCount&&item.localChecksum===text(remote?.checksum));}
|
|
|
|
|
+ result.lastSuccessAt=datasets.map(row=>row.lastSuccessAt).filter(Boolean).sort()[0]||'';result.lagSeconds=result.lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(result.lastSuccessAt).getTime())/1000)):null;
|
|
|
|
|
+ result.settlementReady=manifest.consistent===true&&datasets.every(row=>row.complete&&row.lagSeconds!==null&&row.lagSeconds<=120&&!row.pendingFailures&&!row.conflicts);
|
|
|
|
|
+ result.state=result.settlementReady?'synced':manifest.consistent===true?'delayed':'inconsistent';
|
|
|
|
|
+ result.message=result.settlementReady?'旧系统预约、上课和抗遗忘记录已追平':manifest.consistent!==true?'旧系统数据在核对期间发生变化,请刷新重试':datasets.filter(row=>!row.complete||row.lagSeconds===null||row.lagSeconds>120||row.pendingFailures||row.conflicts).map(row=>row.key+':'+(row.pendingFailures||row.conflicts?'有失败或冲突记录':row.state==='catching_up'||row.state==='running'?'仍在追赶':row.state!=='healthy'?'同步未完成':row.localCount!==row.legacyCount||row.localChecksum!==text(manifestByKey.get(row.key)?.checksum)?'旧库与新库记录主键不一致':!row.watermarkCurrent?'增量水位未追平':'最近同步超过两分钟')).join(';');
|
|
|
|
|
+ }catch(error){result.state='error';result.message='旧系统同步状态无法核验:'+text(error?.message).slice(0,160);}
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
async function payrollCanonicalSnapshot(context,input) {
|
|
async function payrollCanonicalSnapshot(context,input) {
|
|
|
const co=payrollTenant(context,input);
|
|
const co=payrollTenant(context,input);
|
|
|
- const snapshot=await payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]);
|
|
|
|
|
|
|
+ const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]),payrollSettlementSyncStatus(context,input)]);
|
|
|
const users=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1',[co]);
|
|
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 map=new Map(users.map(u=>[number(u.legacyUserId||legacyOf(u).UserID),u]));
|
|
|
const items=(snapshot.rows||[]).map(row=>{
|
|
const items=(snapshot.rows||[]).map(row=>{
|
|
@@ -24,7 +46,7 @@ async function payrollCanonicalSnapshot(context,input) {
|
|
|
studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
|
|
studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
|
|
|
teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
|
|
teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
|
|
|
});
|
|
});
|
|
|
- return {items,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:items.length,refreshedAt:new Date().toISOString(),complete:true,stale:false,cacheHit:false}};
|
|
|
|
|
|
|
+ return {items,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:items.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)};}
|
|
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){
|
|
function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
|
|
@@ -55,10 +77,15 @@ async function payrollOverviewData(context,input){
|
|
|
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};
|
|
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};
|
|
|
}
|
|
}
|
|
|
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),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){
|
|
|
|
|
+ requireRole(context,['ops-manager']);const sync=await payrollSettlementSyncStatus(context,input);if(!sync.settlementReady)fail(503,'旧系统数据尚未追平,暂不能核对复习工资:'+sync.message);
|
|
|
|
|
+ const ids=Array.isArray(payload.rewardIds)?[...new Set(payload.rewardIds.map(text).filter(Boolean))]:[];if(!ids.length||ids.length>100)fail(400,'请选择 1 至 100 条待核对凭据');
|
|
|
|
|
+ const result=await payrollSql('SELECT xs_review_reward_confirm($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(ids),reasonOf(input)]);return result.result;
|
|
|
|
|
+}
|
|
|
async function payrollExecute(context,input,op,payload={}){
|
|
async function payrollExecute(context,input,op,payload={}){
|
|
|
requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
|
|
requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
|
|
|
if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
|
|
if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
|
|
|
- if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;}
|
|
|
|
|
|
|
+ if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);if(!preview.sourceStatus.sync?.settlementReady)fail(503,'工资来源尚未追平,暂不能生成或审核:'+text(preview.sourceStatus.sync?.message));req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;}
|
|
|
const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
|
|
const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
|
|
|
return {...payrollSummary(result.result),version:result.result.version};
|
|
return {...payrollSummary(result.result),version:result.result.version};
|
|
|
}
|
|
}
|