|
@@ -16,7 +16,7 @@ const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
|
|
|
async function payrollSettlementSyncStatus(context,input){
|
|
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 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};
|
|
const result={state:'not_configured',settlementReady:false,message:'旧系统持续同步尚未配置,工资统计可能缺少最新记录',checkedAt,lastSuccessAt:'',lagSeconds:null,datasets};
|
|
|
- if(!config.baseUrl||!config.keyId||!config.secret)return result;
|
|
|
|
|
|
|
+ if(!config.baseUrl||!config.keyId||!config.secret)return directPayrollSyncStatus(context,input,result);
|
|
|
try{
|
|
try{
|
|
|
const co=payrollTenant(context,input),[checkpoints,issues,localCounts,manifest]=await Promise.all([
|
|
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","state","cursor","lastSuccessAt","lastWatermark" FROM "LegacySyncCheckpoint" WHERE "company"=$1 AND "dataset"=ANY($2::text[]) ORDER BY "updatedAt" DESC',[co,PAYROLL_SYNC_DATASETS]),
|
|
@@ -78,7 +78,9 @@ async function payrollOverviewData(context,input){
|
|
|
}
|
|
}
|
|
|
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){
|
|
async function confirmLegacyReviewRewards(context,input,payload){
|
|
|
- requireRole(context,['ops-manager']);const sync=await payrollSettlementSyncStatus(context,input);if(!sync.settlementReady)fail(503,'旧系统数据尚未追平,暂不能核对复习工资:'+sync.message);
|
|
|
|
|
|
|
+ // Confirmation validates the selected immutable receipts in SQL; bridge configuration
|
|
|
|
|
+ // is unrelated to whether these particular legacy tasks were completed and unpaid.
|
|
|
|
|
+ requireRole(context,['ops-manager']);
|
|
|
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 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;
|
|
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;
|
|
|
}
|
|
}
|
|
@@ -111,7 +113,7 @@ async function historicalReservationPreview(context,input){const co=payrollTenan
|
|
|
async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const co=payrollTenant(context,input),selected=new Set((payload.appointmentIds||[]).map(text));if(!selected.size)fail(400,'请选择预约');const before=await Psql.one('SELECT xs_payroll_source_version($1) AS version',[co]),preview=await historicalReservationPreview(context,input),items=preview.items.filter(i=>selected.has(i.appointmentId));if(!items.length)return{reserved:0,held:0};return(await payrollSql('SELECT xs_payroll_reservations($1,$2,$3::jsonb,$4,$5) AS result',[co,context.current.id,JSON.stringify(items),reasonOf(input),before.version])).result;}
|
|
async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const co=payrollTenant(context,input),selected=new Set((payload.appointmentIds||[]).map(text));if(!selected.size)fail(400,'请选择预约');const before=await Psql.one('SELECT xs_payroll_source_version($1) AS version',[co]),preview=await historicalReservationPreview(context,input),items=preview.items.filter(i=>selected.has(i.appointmentId));if(!items.length)return{reserved:0,held:0};return(await payrollSql('SELECT xs_payroll_reservations($1,$2,$3::jsonb,$4,$5) AS result',[co,context.current.id,JSON.stringify(items),reasonOf(input),before.version])).result;}
|
|
|
async function creditReconciliationPreview(context,input){const rows=await Psql.query('SELECT s AS row FROM xs_payroll_refund_rows($1) s',[payrollTenant(context,input)]),items=rows.map(r=>r.row);return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter(i=>i.adjustment>0).length,refundTotal:items.reduce((s,i)=>s+Number(i.adjustment),0)},refreshedAt:new Date().toISOString()};}
|
|
async function creditReconciliationPreview(context,input){const rows=await Psql.query('SELECT s AS row FROM xs_payroll_refund_rows($1) s',[payrollTenant(context,input)]),items=rows.map(r=>r.row);return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter(i=>i.adjustment>0).length,refundTotal:items.reduce((s,i)=>s+Number(i.adjustment),0)},refreshedAt:new Date().toISOString()};}
|
|
|
async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);if(!payload.appointmentIds?.length)fail(400,'请选择要核对的预约');return(await payrollSql('SELECT xs_payroll_refunds($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(payload.appointmentIds.map(text)),reasonOf(input)])).result;}
|
|
async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);if(!payload.appointmentIds?.length)fail(400,'请选择要核对的预约');return(await payrollSql('SELECT xs_payroll_refunds($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(payload.appointmentIds.map(text)),reasonOf(input)])).result;}
|
|
|
-async function writingMappingPage(context,input){const co=payrollTenant(context,input),rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND a."dszt" IN ('0','10') AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co]);
|
|
|
|
|
|
|
+async function writingMappingPage(context,input){const co=payrollTenant(context,input),month=text(input.month);if(month&&!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');const rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND (a."dszt" IN ('0','10') OR ($2<>'' AND c."generalId"::text IN (SELECT source->>'yyds' FROM xs_payroll_sources($1) source WHERE to_char(xs_payroll_time(source->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM')=$2))) AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co,month]);
|
|
|
return{items:rows.map(r=>{const stage=teachingStageFromDatabase([r.grade,r.nickname,r.username]);return{...r,suggestedCategoryKey:stage?stage+'_writing':'',evidence:r.evidence||r.grade||'',categoryKey:r.categoryKey||'',studentName:r.studentName||r.nickname};})};}
|
|
return{items:rows.map(r=>{const stage=teachingStageFromDatabase([r.grade,r.nickname,r.username]);return{...r,suggestedCategoryKey:stage?stage+'_writing':'',evidence:r.evidence||r.grade||'',categoryKey:r.categoryKey||'',studentName:r.studentName||r.nickname};})};}
|
|
|
async function writingMappingCommit(context,input,payload){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',[payrollTenant(context,input),context.current.id,input.targetId,text(payload.categoryKey),text(payload.evidence),dateIso(input.expectedUpdatedAt)||null,reasonOf(input)])).result;}
|
|
async function writingMappingCommit(context,input,payload){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',[payrollTenant(context,input),context.current.id,input.targetId,text(payload.categoryKey),text(payload.evidence),dateIso(input.expectedUpdatedAt)||null,reasonOf(input)])).result;}
|
|
|
async function payRateRows(context,input){
|
|
async function payRateRows(context,input){
|
|
@@ -124,3 +126,22 @@ async function savePayRateUpsert(context,input,payload){
|
|
|
if(![1,2,3,4].includes(type)||!Number.isFinite(amount)||amount<0||amount>1000000000||Math.abs(Math.round(amount*100)-amount*100)>.000001||!Number.isFinite(hours)||hours<=0||hours>24||payrollBusinessDate(effectiveFrom)!==effectiveFrom)fail(400,'课型、单价、工时或生效日期无效');
|
|
if(![1,2,3,4].includes(type)||!Number.isFinite(amount)||amount<0||amount>1000000000||Math.abs(Math.round(amount*100)-amount*100)>.000001||!Number.isFinite(hours)||hours<=0||hours>24||payrollBusinessDate(effectiveFrom)!==effectiveFrom)fail(400,'课型、单价、工时或生效日期无效');
|
|
|
return(await payrollSql('SELECT xs_payroll_legacy_rate($1,$2,$3::jsonb) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify({classType:type,amount,durationHours:hours,effectiveFrom,active:payload.active!==false,reason:reasonOf(input)})])).result;
|
|
return(await payrollSql('SELECT xs_payroll_legacy_rate($1,$2,$3::jsonb) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify({classType:type,amount,durationHours:hours,effectiveFrom,active:payload.active!==false,reason:reasonOf(input)})])).result;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// A verified manual API import is useful evidence even when no continuous bridge exists.
|
|
|
|
|
+// It never substitutes for a payroll cutoff snapshot or enables settlement.
|
|
|
|
|
+async function directSyncReceipt(context,input){
|
|
|
|
|
+ const co=companyIdOf(context,input);if(!co)return null;
|
|
|
|
|
+ const rows=await Psql.query('SELECT "detail" FROM "SysLog" WHERE "company"=$1 AND "type2"=\'legacy-direct-sync-completed\' ORDER BY "createdAt" DESC LIMIT 1',[co]);
|
|
|
|
|
+ if(!rows.length)return null;let event;try{event=typeof rows[0].detail==='string'?JSON.parse(rows[0].detail):rows[0].detail;}catch{return null;}
|
|
|
|
|
+ const receipt=event?.after;if(receipt?.source!=='old-api/WXAPP'||!['completed','completed-with-preserved-conflicts'].includes(receipt.status)||!Array.isArray(receipt.datasets)||!receipt.datasets.length||!receipt.datasets.every(r=>r.reconciled===true&&number(r.failed)===0)||!payrollDateIso(receipt.completedAt))return null;
|
|
|
|
|
+ return receipt;
|
|
|
|
|
+}
|
|
|
|
|
+async function directPayrollSyncStatus(context,input,fallback){
|
|
|
|
|
+ try{const receipt=await directSyncReceipt(context,input);if(!receipt)return fallback;const lastSuccessAt=payrollDateIso(receipt.completedAt),lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000));
|
|
|
|
|
+ return {...fallback,state:'batch_verified',lastSuccessAt,lagSeconds,settlementReady:false,message:'旧接口手动同步批次已完成并核对业务编号;保留差异仍待复核,结算前须核验工资截止范围。每日自动同步尚未验收。',datasets:fallback.datasets.map(r=>{const model={'appointments':54,'lessons':59,'memory-records':60}[r.key],d=receipt.datasets.find(d=>number(d.modelId)===model);return d?{...r,state:'batch_verified',lastSuccessAt,lagSeconds,legacyCount:number(d.source),localCount:number(d.source),conflicts:number(d.conflicts),pendingFailures:number(d.failed),complete:false}:r;})};
|
|
|
|
|
+ }catch{return fallback;}
|
|
|
|
|
+}
|
|
|
|
|
+async function operationalSyncIndicator(context,input){
|
|
|
|
|
+ const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret){const receipt=await directSyncReceipt(context,input).catch(()=>null);if(receipt){const lastSuccessAt=payrollDateIso(receipt.completedAt);return{state:'delayed',label:'旧接口批次已同步,保留差异待核对',lastSuccessAt,lagSeconds:Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000))};}return{state:'not_configured',label:'尚未启用持续同步',lastSuccessAt:'',lagSeconds:null};}
|
|
|
|
|
+ const rows=await newClassObjects('LegacySyncCheckpoint',context,input,1000),critical=rows.filter(row=>PAYROLL_SYNC_DATASETS.includes(text(row.get('dataset')))),lastSuccessAt=critical.map(row=>dateIso(row.get('lastSuccessAt'))).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=critical.length===3&&critical.every(row=>text(row.get('state'))==='healthy')&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'工资相关数据刚刚同步':'数据同步延迟,正在追平',lastSuccessAt,lagSeconds};
|
|
|
|
|
+}
|