|
|
@@ -6,6 +6,13 @@ const STORE_OPERATIONS = new Set([
|
|
|
'ops/users/audit','ops/users/export','ops/users/reveal-mobile','ops/users/change-status',
|
|
|
'ops/users/credit-accounts','ops/users/credit-preview','ops/users/adjust-balance','ops/users/credit-ledger',
|
|
|
'ops/stores/credit-accounts','ops/stores/credit-ledger',
|
|
|
+ 'ops/stores/list','ops/stores/detail','ops/coaches/list','ops/coaches/detail','ops/coaches/create','ops/coaches/update','ops/coaches/delete',
|
|
|
+ 'ops/coach-availability/list','ops/coach-availability/save','ops/coach-availability/delete',
|
|
|
+ 'ops/lessons/list','ops/lessons/detail','ops/learning-reports/list','ops/learning-reports/detail','ops/learning-reports/export',
|
|
|
+ 'ops/support-requests/list','ops/support-requests/process',
|
|
|
+ 'ops/payroll/overview','ops/payroll/preview','ops/payroll/list','ops/payroll/detail','ops/payroll/export','ops/payroll/coaches',
|
|
|
+ 'ops/payroll/create','ops/payroll/refresh','ops/payroll/review','ops/payroll/mark-paid','ops/payroll/add-adjustment','ops/payroll/exclude-line',
|
|
|
+ 'ops/payroll/review-rewards','ops/payroll/cutoff','ops/pay-rates/list','ops/teaching-rules/list',
|
|
|
'ops/course-bindings/list','ops/course-bindings/options','ops/course-bindings/save',
|
|
|
'ops/course-bindings/recycle','ops/course-bindings/restore','ops/appointments/list',
|
|
|
'ops/schedule/calendar','ops/appointments/preflight','ops/appointments/create',
|
|
|
@@ -22,7 +29,8 @@ async function storeAssertMember(context,input,id,objectId=false) {
|
|
|
}
|
|
|
async function storeAssertObject(context,input,className,target) {
|
|
|
if(!context.storeId)return;
|
|
|
- if(className==='_User')return storeAssertMember(context,input,target.id,true);
|
|
|
+ if(className==='_User'){if(text(input.operation).startsWith('ops/coaches/'))return storeAssertCoach(context,target.id,true);return storeAssertMember(context,input,target.id,true);}
|
|
|
+ if(className==='CoachAvailability')return storeAssertCoach(context,target.get('coachId'));
|
|
|
if(className!=='CommonModel')fail(403,'门店无权访问此类记录');
|
|
|
const map={54:['CourseAppointment','szyh'],58:['CourseBinding','yhid']},entry=map[number(target.get('modelId'))];
|
|
|
if(!entry)fail(403,'门店无权操作此类记录');
|
|
|
@@ -33,12 +41,12 @@ async function prepareStoreRequest(context,input,payload) {
|
|
|
if(!STORE_OPERATIONS.has(text(input.operation)))fail(403,'门店账号无权访问此功能');
|
|
|
if(input.companyId&&text(input.companyId)!==pointerId(context.company))fail(403,'不能切换其他帐套');
|
|
|
// Private scope values are overwritten, never trusted from browser filters.
|
|
|
- input.companyId=pointerId(context.company);input._storeId=context.storeId;
|
|
|
- input.filters={...(input.filters||{}),parentUserId:context.storeId};input.source='database';
|
|
|
+ input.companyId=pointerId(context.company);input._storeId=context.storeId;input.storeId=context.storeId;input.refresh=false;input.legacyRefresh=false;
|
|
|
+ input.filters={...(input.filters||{}),parentUserId:context.storeId,storeId:context.storeId};input.source='database';
|
|
|
if(['ops/stores/credit-accounts','ops/stores/credit-ledger'].includes(input.operation)){
|
|
|
- if(text(input.objectId||input.targetId)!==context.current.id)fail(404,'门店不存在');
|
|
|
+ if(text(input.objectId||input.targetId)!==(context.store||context.current).id)fail(404,'门店不存在');
|
|
|
}
|
|
|
- if(input.operation==='ops/users/create'||input.operation==='ops/users/update-profile'){
|
|
|
+ if(['ops/users/create','ops/users/update-profile','ops/coaches/create','ops/coaches/update','ops/coaches/delete'].includes(input.operation)){
|
|
|
if(number(payload.parentUserId)&&number(payload.parentUserId)!==context.storeId)fail(403,'不能将学生归属其他门店');
|
|
|
payload.parentUserId=context.storeId;
|
|
|
}
|
|
|
@@ -46,6 +54,9 @@ async function prepareStoreRequest(context,input,payload) {
|
|
|
if(!['add','deduct'].includes(text(payload.direction)||'add'))fail(403,'门店只能划拨或按原单退回额度');
|
|
|
if(payload.sourceTransactionId){const row=await Psql.oneOrNone('SELECT "storeId" FROM "CreditTransaction" WHERE company=$1 AND "objectId"=$2',[pointerId(context.company),text(payload.sourceTransactionId)]);if(!row||number(row.storeId)!==context.storeId)fail(404,'原单不属于本店');}
|
|
|
}
|
|
|
+ if(input.operation.startsWith('ops/coaches/')&&text(input.objectId||input.targetId))await storeAssertCoach(context,input.objectId||input.targetId,true);
|
|
|
+ if(input.operation==='ops/coach-availability/list'&&input.filters.coachId)await storeAssertCoach(context,input.filters.coachId);
|
|
|
+ if(input.operation==='ops/stores/detail'&&text(input.objectId||input.targetId)!==(context.store||context.current).id)fail(404,'门店不存在');
|
|
|
for(const id of [input.studentId,payload.studentId])if(id!==undefined&&id!==null&&id!=='')await storeAssertMember(context,input,id);
|
|
|
// Check target before idempotent replay as well as before any mutation.
|
|
|
if(input.operation.startsWith('ops/users/')&&text(input.objectId||input.targetId))await storeAssertMember(context,input,input.objectId||input.targetId,true);
|
|
|
@@ -67,7 +78,7 @@ async function storeSchedule(context,input) {
|
|
|
return storePublicSchedule(await enrollmentSchedule(context,input,{dateFrom:from,dateTo:to,members,coaches,appointments:selected,unassigned:selected.filter(a=>!a.coachId&&!a.cancelled),totalAppointments:summary.total,dataDateFrom:text(summary.first),dataDateTo:text(summary.last),refreshedAt:new Date().toISOString(),source:'database',liveSourceAvailable:true,liveSourceMessage:'本店排课',sync:{state:'synced',label:'本店数据'}}));
|
|
|
}
|
|
|
async function storeHome(context,input) {
|
|
|
- const members=await queryUsers(context,input),store=context.current;
|
|
|
+ const members=await queryUsers(context,input),store=context.store||context.current;
|
|
|
return {storeId:context.storeId,storeObjectId:store.id,storeName:storeNameOf(store.toJSON()),summary:memberSummary(members),refreshedAt:new Date().toISOString()};
|
|
|
}
|
|
|
function storeSafeRules(context,value) {
|
|
|
@@ -76,3 +87,57 @@ function storeSafeRules(context,value) {
|
|
|
const clean=item=>{if(Array.isArray(item))return item.map(clean);if(!item||typeof item!=='object')return item;return Object.fromEntries(Object.entries(item).filter(([key])=>!['teacherPay','teacherPaySnapshot','operatorId'].includes(key)).map(([key,v])=>[key,clean(v)]));};
|
|
|
const result=clean(value);if(result.conflicts)result.conflicts=result.conflicts.map(({targetId,...row})=>row);return result;
|
|
|
}
|
|
|
+
|
|
|
+async function storeAssertCoach(context,id,objectId=false) {
|
|
|
+ const row=await Psql.oneOrNone('SELECT "objectId","legacyUserId" FROM "_User" WHERE company=$1 AND '+(objectId?'"objectId"=$2':'"legacyUserId"::text=$2')+' AND "identityType"=\'coach\' AND "legacyUserData"->>\'ParentUserID\'=$3 AND NOT COALESCE("isDeleted",false)',[pointerId(context.company),text(id),String(context.storeId)]);
|
|
|
+ if(!row)fail(404,'陪练老师不存在或不属于本店');return row;
|
|
|
+}
|
|
|
+async function storeCoachRows(context,input) {
|
|
|
+ const co=companyIdOf(context,input),store=context.store||context.current,month=/^\d{4}-(0[1-9]|1[0-2])$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),monthEnd=new Date(Date.UTC(Number(month.slice(0,4)),Number(month.slice(5)),0)).toISOString().slice(0,10);
|
|
|
+ const [users,schedule,payroll,availability]=await Promise.all([
|
|
|
+ Psql.query('SELECT * FROM "_User" WHERE company=$1 AND "identityType"=\'coach\' AND "legacyUserData"->>\'ParentUserID\'=$2 AND NOT COALESCE("isDeleted",false) ORDER BY "legacyUserId"',[co,String(context.storeId)]),
|
|
|
+ appointmentRowsInRange(context,input,month+'-01',monthEnd),payrollPreviewData(context,input,month,true,{forDashboard:true}),storeAvailability(context,input)
|
|
|
+ ]);
|
|
|
+ const history=appointmentHistory(schedule),today=businessDate(),rows=users.map(u=>{const uid=number(u.legacyUserId),pay=payroll.coachItems.find(p=>p.coachId===uid),appts=schedule.filter(a=>a.coachId===uid&&!a.cancelled);return{objectId:u.objectId,userId:uid,username:u.username,displayName:displayName(u),mobile:mobileOf(u),mobileMasked:maskMobile(mobileOf(u)),parentUserId:context.storeId,storeName:storeNameOf(store.toJSON()),status:u.isDisabled?'disabled':'active',statusLabel:u.isDisabled?'已停用':'正常',todayAppointments:appts.filter(a=>businessDate(a.startsAt)===today).length,pendingAppointments:appts.filter(a=>a.statusCode<30&&businessDate(a.startsAt)>=today).length,...(history.byCoach.get(uid)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''}),monthLessons:pay?.lessonCount||0,monthHours:pay?.totalHours||0,estimatedPay:pay?.payableAmount||0,averageScore:pay?.averageScore||0,lastLessonAt:pay?.latestLessonAt||'',availabilityConfigured:availability.some(a=>a.coachId===uid),updatedAt:dateIso(u.updatedAt),readOnly:false,sourceLabel:'本店老师'};});
|
|
|
+ return {rows,payroll,schedule,availability,month,history};
|
|
|
+}
|
|
|
+async function storeCoachPage(context,input) {
|
|
|
+ const d=await storeCoachRows(context,input),store=context.store||context.current,status=text(input.filters?.status||'all');
|
|
|
+ return{...pageRows(searchRows(d.rows.filter(r=>status==='all'||r.status===status),input).sort(coachActivityCompare),input),canManage:true,summary:{total:d.rows.length,active:d.rows.filter(r=>r.status==='active').length,disabled:d.rows.filter(r=>r.status==='disabled').length,monthActive:d.rows.filter(r=>r.monthLessons>0).length,liveOnly:0},stores:[{objectId:store.id,userId:context.storeId,displayName:storeNameOf(store.toJSON())}],payrollMonth:d.month,payrollSourceStatus:d.payroll.sourceStatus,payrollRefreshedAt:d.payroll.refreshedAt,liveSourceAvailable:true,liveSourceMessage:'本店陪练与排课',dataDateFrom:d.history.dataDateFrom,dataDateTo:d.history.dataDateTo};
|
|
|
+}
|
|
|
+async function storeCoachDetail(context,input) {
|
|
|
+ await storeAssertCoach(context,input.objectId,true);const d=await storeCoachRows(context,input),coach=d.rows.find(r=>r.objectId===input.objectId);if(!coach)fail(404,'陪练不存在');
|
|
|
+ const lines=d.payroll.lines.filter(l=>l.coachId===coach.userId),totals=payrollResult(d.month,lines,d.payroll.sourceStatus,{}),current=totals.coachItems[0]||{coachId:coach.userId,coachName:coach.displayName,...totals,type30Count:0,type60Count:0,trialCount:0,type4Count:0,averageScore:0};
|
|
|
+ const [review,stored,batches]=await Promise.all([
|
|
|
+ Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" WHERE company=$1 AND plid::text=$2',[companyIdOf(context,input),String(coach.userId)]),
|
|
|
+ Psql.query('SELECT l.* FROM "PayrollLine" l JOIN "PayrollBatch" b ON b."objectId"=l."batchId" AND b.company=l.company WHERE l.company=$1 AND b."scopeStoreId"=$2 AND l."coachId"=$3 AND b.month=$4',[companyIdOf(context,input),context.storeId,coach.userId,d.month]),
|
|
|
+ storePayrollPage(context,{...input,month:d.month,page:1,pageSize:100})
|
|
|
+ ]);
|
|
|
+ const history=batches.items.map(b=>{const own=stored.filter(l=>l.batchId===b.objectId&&!l.excluded).map(payrollLine);return own.length?{...b,...payrollResult(d.month,own,d.payroll.sourceStatus,{})}:null;}).filter(Boolean);
|
|
|
+ return{coach,profile:{username:coach.username,mobile:coach.mobile,storeName:coach.storeName,statusLabel:coach.statusLabel,sourceLabel:'本店老师'},currentPayroll:current,availability:d.availability.filter(a=>a.coachId===coach.userId),appointments:d.schedule.filter(a=>a.coachId===coach.userId),lessons:lines,reviews:lines.filter(l=>l.score||l.comment),payrolls:[{...totals,objectId:'preview:'+d.month,statusLabel:'本月预估'},...history],reviewTaskCount:number(review.total),payrollMonth:d.month,payrollSourceStatus:d.payroll.sourceStatus,refreshedAt:d.payroll.refreshedAt,dataDateFrom:d.history.dataDateFrom,dataDateTo:d.history.dataDateTo};
|
|
|
+}
|
|
|
+async function storeAvailability(context,input) {
|
|
|
+ const rows=await Psql.query('SELECT a.* FROM "CoachAvailability" a JOIN "_User" u ON u.company=a.company AND u."legacyUserId"=a."coachId" WHERE a.company=$1 AND u."identityType"=\'coach\' AND u."legacyUserData"->>\'ParentUserID\'=$2 AND NOT COALESCE(u."isDeleted",false)',[companyIdOf(context,input),String(context.storeId)]);
|
|
|
+ return rows.map(r=>({...r,coachId:number(r.coachId),updatedAt:dateIso(r.updatedAt)}));
|
|
|
+}
|
|
|
+async function storeOwnProfile(context,input) {
|
|
|
+ const home=await storeHome(context,input),st=(context.store||context.current).toJSON(),month=text(input.month)||businessDate().slice(0,7),d=await storeCoachRows(context,{...input,month}),members=await queryUsers(context,input),appointments=d.schedule;
|
|
|
+ const history=await Psql.one('SELECT count(*)::int total,max(left(a.yysj,10)) last FROM "CourseAppointment" a JOIN "_User" s ON s.company=a.company AND s."legacyUserId"::text=a.szyh::text WHERE a.company=$1 AND s."identityType"=\'member\' AND s."legacyUserData"->>\'ParentUserID\'=$2 AND NOT COALESCE(s."isDeleted",false)',[companyIdOf(context,input),String(context.storeId)]);
|
|
|
+ const store={objectId:home.storeObjectId,userId:home.storeId,username:st.username,storeName:home.storeName,contactName:st.nickname||'',mobile:storeMobileOf(st),mobileMasked:maskMobile(storeMobileOf(st)),parentStoreId:0,parentStoreName:'',memberCount:members.length,activeStudyingMemberCount:home.summary.studying,coachCount:d.rows.length,todayAppointments:appointments.filter(a=>!a.cancelled&&businessDate(a.startsAt)===businessDate()).length,totalAppointments:history.total,lastAppointmentAt:text(history.last),registeredAt:registeredAtOf(st),lastLoginAt:dateIso(st.lastLoginAt),loginCount:0,status:'active',statusLabel:'正常',updatedAt:dateIso(st.updatedAt),readOnly:true};
|
|
|
+ const courses=[...new Set(appointments.map(a=>a.courseId))].map(id=>{const a=appointments.filter(a=>a.courseId===id);return{courseId:id,courseName:a[0].courseName,appointmentCount:a.length,completedCount:a.filter(r=>r.statusCode>=30&&!r.cancelled).length,studentCount:new Set(a.map(r=>r.studentId)).size,completionRate:a.length?Math.round(a.filter(r=>r.statusCode>=30&&!r.cancelled).length*100/a.length):0,lastAppointmentAt:a.map(r=>r.startsAt).sort().at(-1)||''};});
|
|
|
+ return{store,month,summary:{memberCount:members.length,coachCount:d.rows.length,monthAppointments:appointments.length,completedAppointments:appointments.filter(a=>a.statusCode>=30&&!a.cancelled).length,activeStudents:new Set(appointments.map(a=>a.studentId)).size,lessonCount:d.payroll.lessonCount,totalHours:d.payroll.totalHours,estimatedCoachPay:d.payroll.payableAmount},members,coaches:d.rows,appointments,courses,commissionStatus:{available:false,message:'门店额度与陪练工资分别记账。'},payrollSourceStatus:d.payroll.sourceStatus,refreshedAt:d.payroll.refreshedAt};
|
|
|
+}
|
|
|
+async function storeModuleRequest(context,input,payload) {
|
|
|
+ const op=input.operation;
|
|
|
+ if(op==='ops/support-requests/list')return supportRequestsPage(context,input);
|
|
|
+ if(op==='ops/support-requests/process')return supportRequestProcess(context,input);
|
|
|
+ if(op==='ops/lessons/list')return lessonHistoryPage(context,input);
|
|
|
+ if(op==='ops/lessons/detail')return lessonHistoryDetail(context,input);
|
|
|
+ if(op==='ops/coaches/list')return storeCoachPage(context,input);
|
|
|
+ if(op==='ops/coaches/detail')return storeCoachDetail(context,input);
|
|
|
+ if(op==='ops/coach-availability/list')return pageRows((await storeAvailability(context,input)).filter(a=>!number(input.filters?.coachId)||a.coachId===number(input.filters.coachId)),input);
|
|
|
+ if(op==='ops/stores/detail')return storeOwnProfile(context,input);
|
|
|
+ if(op==='ops/stores/list'){const d=await storeOwnProfile(context,input);return{...pageRows(searchRows([d.store],input),input),summary:{total:1,active:1,disabled:0,members:d.summary.memberCount,unboundMembers:0,coaches:d.summary.coachCount,todayAppointments:d.store.todayAppointments,liveOnly:0},parents:[],canManage:false,canFund:false,canGrantCredit:false,liveSourceAvailable:true};}
|
|
|
+ if(op==='ops/payroll/review-rewards'){const d=await reviewRewardPage(companyIdOf(context,input),input);return{...d,items:d.items.map(r=>({...r,canCorrect:false,canConfirm:false}))};}
|
|
|
+ if(op==='ops/payroll/cutoff'){const d=await payrollCutoff(context,input,payload);return{...d,conflicts:[],datasets:[],message:'结算来源由总部统一核验,本店沿用已核验的来源。'};}
|
|
|
+}
|