Selaa lähdekoodia

feat(stores): add scoped administrator accounts and independent payroll

彭峰 3 päivää sitten
vanhempi
commit
9ed0c725c4

+ 11 - 1
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -40,7 +40,17 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
 ];
 
 export const STORE_NAVIGATION: readonly AdminNavigationGroup[] = [
- {label:'门店运营',items:[{label:'本店工作台',icon:'store',route:'/admin/store-home'},{label:'本店学生',icon:'users',route:'/admin/members'},{label:'本店排课',icon:'calendar-days',route:'/admin/schedule'}]},
+ {label:'运营总览',items:[{label:'工作台',icon:'layout-dashboard',route:'/admin/store-home'}]},
+ {label:'核心运营',items:[
+  {label:'会员管理',icon:'users',route:'/admin/members'},
+  {label:'门店管理',icon:'store',route:'/admin/stores'},
+  {label:'排课中心',icon:'calendar-days',route:'/admin/schedule'},
+  {label:'陪练老师',icon:'graduation-cap',route:'/admin/coaches'},
+  {label:'工资结算',icon:'wallet-cards',route:'/admin/payroll'}]},
+ {label:'记录管理',items:[
+  {label:'上课记录',icon:'clipboard-list',route:'/admin/lessons'},
+  {label:'学习报表',icon:'file-chart-column',route:'/admin/learning-reports'},
+  {label:'反馈与休假申请',icon:'message-square-text',route:'/admin/support-requests'}]},
  {label:'个人设置',items:[{label:'修改密码',icon:'key-round',route:'/admin/change-password'}]}
 ];
 

+ 1 - 1
projects/xiaoshu-admin/src/app/admin.guard.ts

@@ -11,7 +11,7 @@ export const adminGuard: CanActivateFn = (_route, state) => {
     if(sessions.session()?.operationsRole==='store-admin'){
       const path=state.url.split('?')[0];
       if(path==='/admin'||path==='/admin/dashboard')return inject(Router).createUrlTree(['/admin/store-home']);
-      if(!/^\/admin\/(store-home|members(?:\/[^/]+)?|schedule|change-password|forbidden)$/.test(path))return inject(Router).createUrlTree(['/admin/forbidden']);
+      if(!/^\/admin\/(store-home|members(?:\/[^/]+)?|stores(?:\/[^/]+)?|coaches(?:\/[^/]+)?|schedule|payroll|lessons|learning-reports|support-requests|change-password|forbidden)$/.test(path))return inject(Router).createUrlTree(['/admin/forbidden']);
     }
     return true;
   }

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/admin-login.component.ts

@@ -31,7 +31,7 @@ export class AdminLoginComponent {
   private readonly route = inject(ActivatedRoute);
   private readonly operations = inject(OperationsService);
 
-  constructor() { document.title = '后台登录 · 小树陪练'; if (this.route.snapshot.queryParamMap.get('passwordChanged') === '1') this.notice.set('密码已修改,所有旧会话均已注销,请使用新密码登录。'); }
+  constructor() { this.username = this.route.snapshot.queryParamMap.get('account')?.trim().slice(0,100) || ''; document.title = '后台登录 · 小树陪练'; if (this.route.snapshot.queryParamMap.get('passwordChanged') === '1') this.notice.set('密码已修改,所有旧会话均已注销,请使用新密码登录。'); }
 
   async submit(): Promise<void> {
     if (!this.username.trim() || !this.password) {

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html

@@ -34,7 +34,7 @@
       <label><span>{{editing()?'重置密码':'初始密码'}} <em [class.optional]="editing()">{{editing()?'选填':'*'}}</em></span><input type="password" autocomplete="new-password" [ngModel]="password()" (ngModelChange)="password.set($event)" [placeholder]="editing()?'留空则不修改密码':'至少 8 位'"/></label>
       <label><span>老师姓名 <em>*</em></span><input [ngModel]="displayName()" (ngModelChange)="displayName.set($event)" placeholder="用于排课和工资单显示"/></label>
       <label><span>手机号码</span><input inputmode="numeric" maxlength="11" [ngModel]="mobile()" (ngModelChange)="mobile.set($event)" placeholder="11 位手机号码"/></label>
-      <label><span>所属门店</span><select [ngModel]="parentUserId()" (ngModelChange)="parentUserId.set(+$event)"><option [ngValue]="0">暂不绑定门店</option>@for(store of page()?.stores||[];track store.userId){<option [ngValue]="store.userId">{{store.displayName}}</option>}</select></label>
+      <label><span>所属门店</span><select [ngModel]="parentUserId()" (ngModelChange)="parentUserId.set(+$event)">@if(!isStore()){<option [ngValue]="0">暂不绑定门店</option>}@for(store of page()?.stores||[];track store.userId){<option [ngValue]="store.userId">{{store.displayName}}</option>}</select></label>
       <label><span>账号状态</span><select [ngModel]="disabled()" (ngModelChange)="disabled.set($event)"><option [ngValue]="false">正常使用</option><option [ngValue]="true">停用账号</option></select></label>
       <label class="wide"><span>操作原因 <em>*</em></span><textarea rows="3" [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="用于安全审计,例如:新员工入职、资料更正或离职"></textarea></label>
     </div>

+ 4 - 1
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts

@@ -1,3 +1,4 @@
+import {AdminSessionService} from '../admin-session.service';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
@@ -10,13 +11,15 @@ import { OperationsLiveRefreshService } from '../operations-live-refresh.service
 
 @Component({selector:'app-operations-coaches',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrls:['./operations-professional.scss','./operations-coaches.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachesComponent implements OnInit,OnDestroy{
+ readonly sessions=inject(AdminSessionService);readonly isStore=this.sessions.isStore;
+
  readonly page=signal<CoachPage|null>(null);readonly queryInput=signal('');readonly statusInput=signal('all');readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly querying=signal(false);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
  readonly username=signal('');readonly password=signal('');readonly displayName=signal('');readonly mobile=signal('');readonly parentUserId=signal(0);readonly disabled=signal(false);readonly reason=signal('');
  readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,LoaderCircle,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,X};private readonly operations=inject(OperationsService);private readonly liveRefresh=inject(OperationsLiveRefreshService);private stopLiveRefresh?:()=>void;private loadRequest=0;
  ngOnInit():void{void this.load(1);this.stopLiveRefresh=this.liveRefresh.watch(['appointments','account','review'],()=>{if(this.loading()||this.querying()||this.refreshing()||this.saving())return false;void this.load(this.page()?.page||1,false,'load',true);return true;});}ngOnDestroy():void{this.stopLiveRefresh?.();}async load(page=1,refresh=false,action:'load'|'query'|'refresh'='load',background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.querying.set(!background&&action==='query');this.refreshing.set(!background&&action==='refresh');this.error.set('');try{const result=await this.operations.coaches(page,20,this.query(),{status:this.status()},refresh);if(request===this.loadRequest)this.page.set(result);}catch(error){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{if(!background&&request===this.loadRequest){this.loading.set(false);this.querying.set(false);this.refreshing.set(false);}}}
  applyFilters():void{this.query.set(this.queryInput().trim());this.status.set(this.statusInput());void this.load(1,false,'query');}
  reset():void{this.queryInput.set('');this.statusInput.set('all');this.query.set('');this.status.set('all');void this.load(1,false,'query');}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
- openCreate():void{this.editing.set(null);this.username.set('');this.password.set('');this.displayName.set('');this.mobile.set('');this.parentUserId.set(0);this.disabled.set(false);this.reason.set('新增陪练老师');this.deleteArmed.set(false);this.error.set('');this.drawerOpen.set(true);}
+ openCreate():void{this.editing.set(null);this.username.set('');this.password.set('');this.displayName.set('');this.mobile.set('');this.parentUserId.set(this.sessions.session()?.storeId||0);this.disabled.set(false);this.reason.set('新增陪练老师');this.deleteArmed.set(false);this.error.set('');this.drawerOpen.set(true);}
  openEdit(row:CoachSummary,deleteMode=false):void{this.editing.set(row);this.username.set(row.username);this.password.set('');this.displayName.set(row.displayName);this.mobile.set(row.mobile||'');this.parentUserId.set(row.parentUserId||0);this.disabled.set(row.status==='disabled');this.reason.set('');this.deleteArmed.set(deleteMode);this.error.set('');this.drawerOpen.set(true);}
  closeDrawer():void{this.drawerOpen.set(false);this.deleteArmed.set(false);}
  async save():Promise<void>{const editing=this.editing();if(!this.username().trim()||!this.displayName().trim()||!this.reason().trim()){this.error.set('登录账号、老师姓名和操作原因必填');return;}if(!editing&&!this.password()){this.error.set('新增老师必须设置初始密码');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate(editing?'ops/coaches/update':'ops/coaches/create',editing?.objectId||'',this.reason(),{username:this.username(),password:this.password(),displayName:this.displayName(),mobile:this.mobile(),parentUserId:this.parentUserId(),disabled:this.disabled()},editing?.updatedAt||'');this.message.set(editing?'陪练资料已更新':'陪练老师已新增');this.closeDrawer();await this.load(editing?this.page()?.page||1:1,true);}catch(error){this.error.set(error instanceof Error?error.message:'陪练资料保存失败');}finally{this.saving.set(false);}}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html


+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts

@@ -52,7 +52,7 @@ export class OperationsPayrollComponent implements OnInit,OnDestroy{
  private monthLabelOf(value:string):string{const [year,month]=value.split('-');return year&&month?`${year}年${Number(month)}月`:'选择月份';}
  private clearMonthContext():void{this.message.set('');this.error.set('');this.closeDrawer();}
  monthOptionValue(monthIndex:number):string{return `${this.monthPickerYear()}-${String(monthIndex+1).padStart(2,'0')}`;}
- clearRewardCoach():void{this.rewardCoachId.set(0);this.openRewards();}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
+ clearRewardCoach():void{this.rewardCoachId.set(0);this.openRewards();}isStore():boolean{return this.sessions.isStore();}isManager():boolean{return this.isStore()||this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
  async load(refresh=false,background=false):Promise<void>{
   const request=++this.loadRequest,batchRequest=++this.batchRequest,query={month:this.month(),storeId:this.storeId(),search:this.search()};if(!background)this.loading.set(true);this.error.set('');
   try{
@@ -75,7 +75,7 @@ export class OperationsPayrollComponent implements OnInit,OnDestroy{
  async open(row:PayrollSummary):Promise<void>{const request=++this.openRequest;this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);const detail=await this.operations.payrollDetail(row.objectId);if(request===this.openRequest)this.detail.set(detail);}catch(error){if(request===this.openRequest)this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{if(request===this.openRequest)this.loading.set(false);}}
  coachLabel(name:string,coachId:number):string{const value=String(name||'').trim();return !value||value===String(coachId)?`陪练 ${coachId}`:value;}
  openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.selectedCoachName.set(this.coachLabel(row.coachName,row.coachId));this.detail.set(null);}closeDrawer():void{this.openRequest++;this.detail.set(null);this.selectedCoachId.set(0);this.selectedCoachName.set('');}
- async create():Promise<void>{if(this.saving())return;if(!this.overview()?.sourceStatus.sync?.settlementReady){this.error.set(this.overview()?.sourceStatus.sync?.message||'旧系统数据尚未追平,不能生成工资草稿');return;}const generatedMonth=this.month();this.saving.set(true);this.error.set('');try{const result=await this.payrollCommand<PayrollSummary>('ops/payroll/create','',`生成 ${generatedMonth} 月全公司工资草稿`,{month:generatedMonth});await this.load();await this.open(result);this.message.set(`${this.monthLabelOf(generatedMonth)}全公司工资草稿已生成,尚未审核或发放`);this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
+ async create():Promise<void>{if(this.saving())return;if(!this.overview()?.sourceStatus.sync?.settlementReady){this.error.set(this.overview()?.sourceStatus.sync?.message||'旧系统数据尚未追平,不能生成工资草稿');return;}const generatedMonth=this.month();this.saving.set(true);this.error.set('');try{const result=await this.payrollCommand<PayrollSummary>('ops/payroll/create','',`生成 ${generatedMonth} 月${this.isStore()?'本店':'全公司'}工资草稿`,{month:generatedMonth});await this.load();await this.open(result);this.message.set(`${this.monthLabelOf(generatedMonth)}${this.isStore()?'本店':'全公司'}工资草稿已生成,尚未审核或发放`);this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
  async saveRate():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
  creditCosts(categoryKey=this.ruleCategoryKey(),duration=this.ruleDuration()):Array<{account:TeachingCreditAccount;amount:number}>{if(categoryKey==='trial')return[{account:'UserExp',amount:1}];if(categoryKey==='review')return[{account:'UserPoint',amount:duration===30?.5:1}];return duration===30?[{account:'Purse',amount:1},{account:'UserPoint',amount:.5}]:[{account:'SilverCoin',amount:1},{account:'UserPoint',amount:1}];}
  creditSummary(rule:TeachingRule):string{return rule.creditCosts.map((item)=>`${item.label} -${item.amount}`).join('+');}

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/store-home.component.ts

@@ -4,11 +4,11 @@ import {OperationsService} from '../operations.service';
 import {StoreCreditAccountComponent} from '../components/store-credit-account.component';
 interface StoreHome {storeId:number;storeObjectId:string;storeName:string;summary:{total:number;todayNew:number;studying:number};}
 @Component({selector:'app-store-home',standalone:true,imports:[RouterLink,StoreCreditAccountComponent],template:`
-<header><div><p>门店工作台</p><h1>{{data()?.storeName||'我的门店'}}</h1><span>管理本店学生、开课与排课</span></div><button type="button" (click)="load()" [disabled]="loading()">刷新</button></header>
+<header><div><p>门店工作台</p><h1>{{data()?.storeName||'我的门店'}}</h1><span>本店会员、排课、陪练、工资与学习记录</span></div><button type="button" (click)="load()" [disabled]="loading()">刷新</button></header>
 @if(error()){<p class="error" role="alert">{{error()}}</p>}
 @if(loading()&&!data()){<p>正在读取本店数据…</p>}
-@if(data();as d){<section class="summary"><article><span>本店学生</span><strong>{{d.summary.total}}</strong></article><article><span>学习中</span><strong>{{d.summary.studying}}</strong></article><article><span>今日新增</span><strong>{{d.summary.todayNew}}</strong></article></section><nav><a routerLink="/admin/members">管理学生 / 开通课程</a><a routerLink="/admin/schedule">查看课表 / 新增排课</a></nav><app-store-credit-account [objectId]="d.storeObjectId"/>}
-`,styles:[`:host{display:block;color:#213e38}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}h1{margin:8px 0}header p,span{color:#667e74}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.summary article{background:#fff;border:1px solid #d9e5df;border-radius:16px;padding:24px}.summary strong{display:block;font-size:32px;margin-top:12px;color:#18745f}nav{display:flex;gap:16px;margin:24px 0}a,button{padding:12px 18px;border-radius:10px;background:#18745f;color:white;border:0;text-decoration:none;cursor:pointer}.error{color:#a33322}@media(max-width:600px){.summary article{padding:14px}nav{flex-direction:column}}`]})
+@if(data();as d){<section class="summary"><article><span>本店学生</span><strong>{{d.summary.total}}</strong></article><article><span>学习中</span><strong>{{d.summary.studying}}</strong></article><article><span>今日新增</span><strong>{{d.summary.todayNew}}</strong></article></section><nav><a routerLink="/admin/members">管理学生 / 开通课程</a><a routerLink="/admin/schedule">查看课表 / 新增排课</a><a routerLink="/admin/stores">门店资料 / 额度</a><a routerLink="/admin/coaches">陪练老师</a><a routerLink="/admin/payroll">工资结算</a><a routerLink="/admin/lessons">上课记录</a><a routerLink="/admin/learning-reports">学习报表</a><a routerLink="/admin/support-requests">反馈与休假申请</a></nav><app-store-credit-account [objectId]="d.storeObjectId"/>}
+`,styles:[`:host{display:block;color:#213e38}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}h1{margin:8px 0}header p,span{color:#667e74}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.summary article{background:#fff;border:1px solid #d9e5df;border-radius:16px;padding:24px}.summary strong{display:block;font-size:32px;margin-top:12px;color:#18745f}nav{display:flex;flex-wrap:wrap;gap:16px;margin:24px 0}a,button{padding:12px 18px;border-radius:10px;background:#18745f;color:white;border:0;text-decoration:none;cursor:pointer}.error{color:#a33322}@media(max-width:600px){.summary article{padding:14px}nav{flex-direction:column}}`]})
 export class StoreHomeComponent implements OnInit {
  private readonly operations=inject(OperationsService);data=signal<StoreHome|null>(null);loading=signal(false);error=signal('');
  ngOnInit(){void this.load();}

+ 3 - 3
projects/xiaoshu-admin/src/app/store-scope.spec.ts

@@ -12,13 +12,13 @@ describe('门店后台入口与权限',()=>{
  const session=signal({operationsRole:'store-admin',storeId:10,storeObjectId:'store-a',passwordResetRequired:false});
  beforeEach(()=>TestBed.configureTestingModule({providers:[provideRouter([]),{provide:AdminSessionService,useValue:{session,isAuthenticated:()=>true}},{provide:OperationsService,useValue:{request:()=>Promise.resolve({accounts:[],transactions:[],canPost:false,items:[],total:0})}}]}));
  it('门店首页重定向且阻止直接访问总部路由',()=>{
-  for(const path of ['/admin/dashboard','/admin/payroll','/admin/stores','/admin/operator-accounts','/tech-admin/dashboard']){
+  for(const path of ['/admin/dashboard','/admin/vocabulary','/admin/privacy-requests','/admin/operator-accounts','/tech-admin/dashboard']){
    const result=TestBed.runInInjectionContext(()=>adminGuard({} as never,{url:path} as never)) as UrlTree;
    expect(TestBed.inject(Router).serializeUrl(result)).toBe(path==='/admin/dashboard'?'/admin/store-home':'/admin/forbidden');
   }
-  for(const path of ['/admin/members','/admin/members/a','/admin/schedule','/admin/store-home','/admin/change-password'])expect(TestBed.runInInjectionContext(()=>adminGuard({} as never,{url:path} as never))).toBeTrue();
+  for(const path of ['/admin/members','/admin/members/a','/admin/schedule','/admin/stores','/admin/stores/a','/admin/coaches','/admin/coaches/a','/admin/payroll','/admin/lessons','/admin/learning-reports','/admin/support-requests','/admin/store-home','/admin/change-password'])expect(TestBed.runInInjectionContext(()=>adminGuard({} as never,{url:path} as never))).toBeTrue();
  });
- it('仅展示本店工作台、学生、排课和个人密码菜单',()=>{expect(STORE_NAVIGATION.flatMap(g=>g.items).map(i=>i.route)).toEqual(['/admin/store-home','/admin/members','/admin/schedule','/admin/change-password']);});
+ it('展示全部八项门店业务模块和个人密码菜单',()=>{expect(STORE_NAVIGATION.flatMap(g=>g.items).map(i=>i.route)).toEqual(['/admin/store-home','/admin/members','/admin/stores','/admin/schedule','/admin/coaches','/admin/payroll','/admin/lessons','/admin/learning-reports','/admin/support-requests','/admin/change-password']);});
  it('学生充值不提供历史差错增额或旧系统导入入口',async()=>{
   const f=TestBed.createComponent(MemberCreditAccountComponent);f.componentInstance.data.set({storeId:10,storeName:'本店',trialStatus:'pending',canPost:true,accounts:[],transactions:[]} as never);f.detectChanges();
   expect(f.nativeElement.querySelector('select[name="direction"] option[value="adjust"]')).toBeNull();expect(f.nativeElement.textContent).not.toContain('核验旧体验');f.destroy();

+ 13 - 0
scripts/cloud/admin-role-policy.js

@@ -20,3 +20,16 @@ function storeAccountSnapshot(current) {
   const store = identity === 'store' || (!identity && Number(read('legacyGroupId') || legacy.GroupID) === 2);
   return store && Number.isSafeInteger(storeId) && storeId > 0 ? storeId : 0;
 }
+
+// Delegated logins never inherit headquarters roles. The binding is server-only.
+async function resolveStoreAccount(current) {
+  if(current.get('identityType')!=='store-admin')return null;
+  const company=current.get('company'),co=company&&(company.id||company.objectId);
+  if(!co)fail(403,'门店管理员尚未分配帐套');
+  const row=await Psql.oneOrNone('SELECT "storeObjectId" FROM "StoreAdministrator" WHERE company=$1 AND "userId"=$2 AND enabled=true',[co,current.id]);
+  if(!row)fail(403,'门店管理员未绑定有效门店');
+  const store=await new Parse.Query('_User').get(row.storeObjectId,{useMasterKey:true});
+  const sc=store.get('company');
+  if((sc&&(sc.id||sc.objectId))!==co||!storeAccountSnapshot(store)||store.get('isDisabled')===true||store.get('isDeleted')===true)fail(403,'绑定门店不存在或已停用');
+  return store;
+}

+ 4 - 2
scripts/cloud/lesson-records.js

@@ -21,6 +21,7 @@ function lessonHistoryBase(){return `WITH facts AS (
  ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn,count(*) OVER(PARTITION BY company,xymz,"classKey") duplicates FROM facts), lessons AS (SELECT * FROM ranked WHERE rn=1)`;}
 function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...input},conditions=[];
  const add=(sql,value)=>{if(value!==undefined&&value!==null&&String(value).trim()){args.push(String(value).trim());conditions.push(sql.replaceAll('?',String(args.length)));}};
+ if(f._storeId)add('EXISTS(SELECT 1 FROM \"_User\" s WHERE s.company=lessons.company AND s.\"legacyUserId\"::text=lessons.xymz AND s.\"identityType\"=\'member\' AND s.\"legacyUserData\"->>\'ParentUserID\'=$? AND NOT COALESCE(s.\"isDeleted\",false))',f._storeId);
  add('xymz=$?::text',f.studentId);add('jsmz=$?::text',f.coachId);add('"storeIdResolved"=$?::text',f.storeId);add('kcid=$?::text',f.courseId);
  for(const [field,key] of [['"studentNameResolved"','studentName'],['"coachNameResolved"','coachName'],['"storeName"','storeName'],['"courseNameResolved"','courseName']])add('position(lower($?) in lower('+field+'))>0',f[key]);
  add('position(lower($?) in lower(concat_ws(\' \',xymz,"studentNameResolved")))>0',f.search);
@@ -30,7 +31,7 @@ function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...inp
 function lessonHistoryRow(row){return {targetId:text(row.targetId||row.objectId),lessonId:row.objectId,generalId:number(row.generalId||row.id),appointmentId:text(row.yyds),studentId:number(row.xymz),studentName:row.studentNameResolved,coachId:number(row.jsmz),coachName:row.coachNameResolved,storeId:number(row.storeIdResolved),storeName:row.storeName,courseId:number(row.kcid),courseName:row.courseNameResolved,classTypeLabel:row.courseCategoryName||classType(row.kclx),lessonAt:dateIso(row.actualAt),score:number(row.pf),comment:text(row.pldp),coachComment:text(row.pldp),studentComment:text(row.pjnr),feedbackSummary:text(row.feedbackSummary||row.pldp),duplicateCount:number(row.duplicates),pendingReviews:number(row.pendingReviews),reviewCoaches:row.reviewCoaches||[],reviewCoachLabel:(row.reviewCoaches||[]).length>1?'多位陪练':row.reviewCoaches?.[0]?.name||'无待复习任务'};}
 async function lessonHistoryPage(context,input){
  requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'缺少帐套');
- const args=[co],where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search},args),paging=pageInput(input);args.push(paging.pageSize,(paging.page-1)*paging.pageSize);
+ const args=[co];if(context.storeId)input={...input,storeId:context.storeId,_storeId:context.storeId};const where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search,_storeId:input._storeId},args),paging=pageInput(input);args.push(paging.pageSize,(paging.page-1)*paging.pageSize);
  const q=lessonHistoryBase()+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})
  SELECT md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),'')) AS "historyVersion",(SELECT count(*)::int FROM filtered) total,(SELECT count(*)::int FROM lessons${cumulativeWhere}) "cumulativeTotal",
  COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM page p
@@ -46,12 +47,13 @@ async function lessonHistoryDetail(context,input){
  if(!co||(!id&&!appointment))fail(400,'缺少课次编号');
  const row=await Psql.oneOrNone(lessonHistoryBase()+` SELECT * FROM ranked WHERE ($2<>'' AND ("targetId"=$2 OR "objectId"=$2 OR "generalId"::text=$2)) OR ($3<>'' AND yyds=$3) LIMIT 1`,[co,id,appointment]);
  if(!row)fail(404,'未找到实际上课记录');
+ if(context.storeId)await storeAssertMember(context,input,row.xymz);
  const [report,review,tasks,auditRows]=await Promise.all([
  Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE company=$1 AND ("lessonId"=$2 OR "appointmentGeneralId"::text=$3) ORDER BY "submittedAt" DESC LIMIT 1',[co,row.objectId,row.yyds]),
  Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"::text=$2 LIMIT 1',[co,row.yyds]),
  Psql.query('SELECT m."objectId",m.plid AS "coachId",m.kywrq AS "plannedDate",m.fxzt AS status,m.wcsj AS "completedAt",COALESCE(NULLIF(u."realName",\'\'),NULLIF(u.nickname,\'\'),u."legacyUserData"->>\'HoneyName\',u.username,\'陪练 \'||m.plid::text) AS "coachName" FROM xs_review_task_link l JOIN "MemoryPracticeRecord" m ON m.company=l.company AND m."objectId"=l."taskId" LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true WHERE l.company=$1 AND l."studentId"=$2 AND l."appointmentId"=$3 ORDER BY m.kywrq,m."objectId"',[co,row.xymz,row.yyds]),
  Psql.query('SELECT a.*,c.reason,c."actorId" FROM "ReviewTransferAudit" a JOIN "ReviewTransferCommand" c ON c."objectId"=a."commandId" WHERE a.company=$1 AND a."studentId"=$2 AND a."appointmentId"=$3 ORDER BY a."createdAt" DESC',[co,row.xymz,row.yyds])]);
- return {...lessonHistoryRow(row),report:report||null,coachComment:text(report?.coachComment||row.pldp),studentReview:review||{score:number(row.pf),comment:text(row.pjnr)},tasks,audit:auditRows,canTransfer:roleOf(context)==='ops-manager',hasReport:Boolean(report)};
+ return {...lessonHistoryRow(row),report:report||null,coachComment:text(report?.coachComment||row.pldp),studentReview:review||{score:number(row.pf),comment:text(row.pjnr)},tasks,audit:auditRows,canTransfer:!context.storeId&&roleOf(context)==='ops-manager',hasReport:Boolean(report)};
 }
 async function lessonTransferPreview(context,input){
  requireRole(context,['ops-manager']);const co=companyIdOf(context,input),student=text(input.studentId),appointment=input.scope==='student'?'':text(input.appointmentId),coach=text(input.coachId);

+ 18 - 8
scripts/cloud/payroll-integrity.js

@@ -40,7 +40,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)};
   });
-  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}};
+  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}};
 }
 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){
@@ -50,10 +50,11 @@ function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
 }
 async function payrollPreviewData(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}),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 "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous])).value);
+ 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);
  const grouped=new Map();for(const item of source.items){const key=payrollIdentity(item);if(grouped.has(key))grouped.get(key).duplicate=true;else grouped.set(key,item);}
  const lines=Array.from(grouped.values()).filter(item=>{const m=payrollBusinessDate(item.lessonAt).slice(0,7);return (m===month||!m||allowLate&&m===previous)&& (includeUsed||!other.has(payrollIdentity(item)));}).map(item=>payrollCalculatedLine(item,rates,month,rules,mappingMap));
  const rewards=await Psql.query('SELECT r.* FROM "ReviewRewardIncome" r WHERE r."company"=$1 AND r."status"=\'earned\' AND '+(includeUsed?'r."incomeMonth"=$2':'r."incomeMonth"<=$2 AND NOT EXISTS(SELECT 1 FROM "PayrollLine" l WHERE l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND NOT COALESCE(l."excluded",false) AND l."batchId"<>$3)')+' ORDER BY r."objectId"',includeUsed?[co,month]:[co,month,text(input.targetId)]);
@@ -61,13 +62,14 @@ async function payrollPreviewData(context,input,month,includeUsed=false,requireL
  if(!includeUsed&&input.targetId){const existing=await Psql.query('SELECT * FROM "PayrollLine" WHERE "company"=$1 AND "batchId"=$2 AND "kind"=\'lesson\' AND NOT COALESCE("excluded",false)',[co,input.targetId]),present=new Set(lines.map(payrollIdentity));for(const row of existing)if(!present.has(row.standardLessonKey))lines.push({...payrollLine(row),standardLessonKey:row.standardLessonKey,exception:'原计薪课次已缺失、跨月或被其他批次占用,请核对',amount:0});}
  return {...payrollResult(month,lines,source.sourceStatus,input),sourceVersion:source.version};
 }
-async function payrollPage(context,input){const co=payrollTenant(context,input),paging=pageInput(input),month=text(input.month),where='"company"=$1 AND ($2=\'\' OR "month"=$2)',rows=await Psql.query('SELECT * FROM "PayrollBatch" WHERE '+where+' ORDER BY "month" DESC,"objectId" LIMIT $3 OFFSET $4',[co,month,paging.pageSize,(paging.page-1)*paging.pageSize]),total=await Psql.one('SELECT count(*)::int AS total FROM "PayrollBatch" WHERE '+where,[co,month]);return{items:rows.map(r=>({...payrollSummary(r),version:r.version})),total:total.total,page:paging.page,pageSize:paging.pageSize};}
-async function payrollDetailData(context,input,targetId){const co=payrollTenant(context,input),batch=await Psql.oneOrNone('SELECT * FROM "PayrollBatch" WHERE "company"=$1 AND "objectId"=$2',[co,targetId]);if(!batch)fail(404,'工资单不存在');const rows=await Psql.query('SELECT * FROM "PayrollLine" WHERE "company"=$1 AND "batchId"=$2 ORDER BY "lessonAt" NULLS LAST,"objectId"',[co,targetId]),lines=rows.map(row=>({...payrollLine(row),standardLessonKey:row.standardLessonKey,exclusionReason:row.exclusionReason,coachMobile:row.coachMobile}));return{...payrollSummary(batch),version:batch.version,lines,exceptions:lines.filter(r=>r.exception&&!r.excluded),coachItems:payrollCoachItems(lines,{})};}
+async function payrollPage(context,input){return storePayrollPage(context,input);}
+async function payrollDetailData(context,input,targetId){const co=payrollTenant(context,input),batch=await Psql.oneOrNone('SELECT * FROM "PayrollBatch" WHERE "company"=$1 AND "objectId"=$2',[co,targetId]);if(!batch||context.storeId&&number(batch.scopeStoreId)!==context.storeId)fail(404,'工资单不存在或不属于本店');const rows=await Psql.query('SELECT * FROM "PayrollLine" WHERE "company"=$1 AND "batchId"=$2 ORDER BY "lessonAt" NULLS LAST,"objectId"',[co,targetId]),lines=rows.map(row=>({...payrollLine(row),standardLessonKey:row.standardLessonKey,exclusionReason:row.exclusionReason,coachMobile:row.coachMobile}));return{...payrollSummary(batch),version:batch.version,lines,exceptions:lines.filter(r=>r.exception&&!r.excluded),coachItems:payrollCoachItems(lines,{})};}
 async function payrollOverviewData(context,input){
+ if(context.storeId)input={...input,storeId:context.storeId};
  const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7),income=await payrollPreviewData(context,{...input,storeId:0,search:''},month,true),batches=(await payrollPage(context,{...input,month,page:1,pageSize:100})).items;
  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=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]);
+ 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};
 }
 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};}
@@ -79,7 +81,9 @@ async function confirmLegacyReviewRewards(context,input,payload){
  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={}){
- 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,scopeStoreId:context.storeId||0};
+ if(context.storeId&&target)await payrollDetailData(context,input,target);
+ if(context.storeId&&op==='adjustment')await storeAssertCoach(context,payload.coachId);
  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);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;req.cutoffVersion=preview.sourceStatus.sync.cutoffVersion;}
  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]);
@@ -89,8 +93,8 @@ async function createPayroll(context,input,payload){return payrollExecute(contex
 async function refreshPayrollDraft(context,input){await payrollExecute(context,input,'refresh');return payrollDetailData(context,input,input.targetId);}
 async function payrollWorkflow(context,input,operation,payload){if(operation==='reopen')fail(409,'工资单审核后永久锁定,差异请进入下期调整单');return payrollExecute(context,input,operation,payload);}
 async function payrollAdjustment(context,input,payload){return payrollExecute(context,input,'adjustment',{coachId:payload.coachId,amount:payload.amount});}
-async function payrollExclude(context,input){const co=payrollTenant(context,input),line=await Psql.oneOrNone('SELECT "batchId" FROM "PayrollLine" WHERE "company"=$1 AND "objectId"=$2',[co,input.targetId]);if(!line)fail(404,'明细不存在');return payrollExecute(context,{...input,targetId:line.batchId},'exclude',{lineId:input.targetId});}
-async function payrollCoaches(context,input){const co=payrollTenant(context,input),rows=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1 AND ("identityType"=\'coach\' OR "identityType" IS NULL AND "legacyGroupId"=3) AND NOT COALESCE("isDeleted",false) ORDER BY "legacyUserId"',[co]);return{items:rows.map(u=>({coachId:number(u.legacyUserId||legacyOf(u).UserID),coachName:displayName(u),coachObjectId:u.objectId}))};}
+async function payrollExclude(context,input){const co=payrollTenant(context,input),line=await Psql.oneOrNone('SELECT "batchId" FROM "PayrollLine" WHERE "company"=$1 AND "objectId"=$2',[co,input.targetId]);if(!line)fail(404,'明细不存在');if(context.storeId)await payrollDetailData(context,input,line.batchId);return payrollExecute(context,{...input,targetId:line.batchId},'exclude',{lineId:input.targetId});}
+async function payrollCoaches(context,input){if(context.storeId){const rows=await Psql.query('SELECT "objectId","legacyUserId","realName",nickname,username FROM "_User" WHERE company=$1 AND "identityType"=\'coach\' AND "legacyUserData"->>\'ParentUserID\'=$2 AND NOT COALESCE("isDeleted",false)',[companyIdOf(context,input),String(context.storeId)]);return{items:rows.map(u=>({coachId:number(u.legacyUserId),coachName:displayName(u),coachObjectId:u.objectId}))};}const co=payrollTenant(context,input),rows=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1 AND ("identityType"=\'coach\' OR "identityType" IS NULL AND "legacyGroupId"=3) AND NOT COALESCE("isDeleted",false) ORDER BY "legacyUserId"',[co]);return{items:rows.map(u=>({coachId:number(u.legacyUserId||legacyOf(u).UserID),coachName:displayName(u),coachObjectId:u.objectId}))};}
 async function teachingRuleRows(context,input){
  const co=payrollTenant(context,input),stored=await Psql.query('SELECT * FROM "TeachingCompensationRule" WHERE "company"=$1 ORDER BY "revision" DESC,"createdAt" DESC,"objectId"',[co]),names=new Map(TEACHING_CATEGORY_DEFAULTS.map(c=>[c.key,c.name])),latest=new Set(),rows=[];
  for(const r of stored){const key=[r.categoryKey,r.durationMinutes,r.deliveryMode,r.effectiveFrom].join(':');const superseded=latest.has(key);latest.add(key);rows.push({...teachingRuleProjection(r,names),revision:r.revision,supersedesId:r.supersedesId,superseded,active:r.active!==false&&!superseded});}
@@ -176,3 +180,9 @@ async function correctReviewReward(context,input,payload){
  requireRole(context,['ops-manager']);
  return (await payrollSql('SELECT xs_review_reward_correct($1,$2,$3,$4,$5,$6::int) AS result',[payrollTenant(context,input),context.current.id,text(input.targetId),reasonOf(input),text(payload.batchId),payload.version??null])).result;
 }
+
+async function storePayrollPage(context,input){
+ const co=payrollTenant(context,input),paging=pageInput(input),month=text(input.month),args=[co,month,context.storeId||0],where='company=$1 AND ($2=\'\' OR month=$2) AND "scopeStoreId"=$3';
+ const [rows,total]=await Promise.all([Psql.query('SELECT * FROM "PayrollBatch" WHERE '+where+' ORDER BY month DESC,"objectId" LIMIT $4 OFFSET $5',[...args,paging.pageSize,(paging.page-1)*paging.pageSize]),Psql.one('SELECT count(*)::int total FROM "PayrollBatch" WHERE '+where,args)]);
+ return{items:rows.map(r=>({...payrollSummary(r),version:r.version,scopeStoreId:r.scopeStoreId})),total:total.total,page:paging.page,pageSize:paging.pageSize};
+}

+ 71 - 6
scripts/cloud/store-scope.js

@@ -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:'结算来源由总部统一核验,本店沿用已核验的来源。'};}
+}

+ 3 - 3
scripts/cloud/support-requests.js

@@ -30,7 +30,7 @@ async function supportRequestsPage(context, input) {
     LEFT JOIN LATERAL (SELECT u.* FROM "_User" u WHERE u.company=g.company
       AND COALESCE(u."legacyUserId"::text,u."legacyUserData"->>'UserID')=g.userid::text
       ORDER BY u."createdAt" DESC,u."objectId" LIMIT 1) u ON TRUE
-    WHERE g.company=$1 AND COALESCE(g.parentid,0)=0 AND COALESCE(g.status,0)<>-2
+    WHERE g.company=$1 AND ($10::text='' OR (u."legacyUserData"->>'ParentUserID'=$10 AND u."identityType" IN ('member','coach') AND NOT COALESCE(u."isDeleted",false))) AND COALESCE(g.parentid,0)=0 AND COALESCE(g.status,0)<>-2
       AND (g.cateid=22 OR g."sourceKey" LIKE 'cloud:guestbook_add:%')
   ), filtered AS (
     SELECT * FROM requests WHERE ($9='' OR status=$9) AND ($2='' OR kind=$2) AND ($3='' OR role=$3)
@@ -43,7 +43,7 @@ async function supportRequestsPage(context, input) {
     COUNT(*) FILTER(WHERE kind='leave')::int AS leave,
     COUNT(*) FILTER(WHERE kind='legacy')::int AS legacy,
     COALESCE((SELECT jsonb_agg(p ORDER BY p."submittedAt" DESC NULLS LAST,p.id DESC) FROM paged p),'[]'::jsonb) AS items
-  FROM filtered`, [company,kind,role,search.slice(0,200),from,to,pageSize,(page-1)*pageSize,status]);
+  FROM filtered`, [company,kind,role,search.slice(0,200),from,to,pageSize,(page-1)*pageSize,status,context.storeId?String(context.storeId):'']);
   return {canProcess:['ops-manager','ops-staff'].includes(roleOf(context)),items:result.items, total:result.total, page, pageSize,
     summary:{feedback:result.feedback,leave:result.leave,legacy:result.legacy},refreshedAt:new Date().toISOString()};
 }
@@ -54,6 +54,6 @@ async function supportRequestProcess(context,input) {
   if(!company || !id) fail(400,'缺少帐套或申请编号');
   if(!['processed','approved','rejected'].includes(status)||!note||note.length>1000)fail(400,'请填写处理结果和 1 至 1000 字的处理意见');
   const actorName=text(context.current.get('realName')||context.current.get('nickname')||context.current.get('username')||'管理员');
-  try {return (await Psql.one('SELECT xs_support_request_process($1,$2,$3,$4,$5,$6,$7) AS result',[company,id,context.current.id,actorName,status,note,text(input.version)])).result;}
+  try {if(context.storeId)return(await Psql.one('SELECT xs_store_support_process($1,$2,$3,$4,$5,$6,$7) AS result',[company,id,context.current.id,actorName,status,note,text(input.version)])).result;return (await Psql.one('SELECT xs_support_request_process($1,$2,$3,$4,$5,$6,$7) AS result',[company,id,context.current.id,actorName,status,note,text(input.version)])).result;}
   catch(error){if(error.code==='P0001')fail(409,error.message);throw error;}
 }

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 9 - 7
scripts/deploy-admin-functions.mjs


+ 37 - 0
scripts/sql/store-administrators.sql

@@ -0,0 +1,37 @@
+-- Server-owned bindings: login accounts do not duplicate a store's business ID.
+CREATE TABLE IF NOT EXISTS "StoreAdministrator" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, "userId" text NOT NULL UNIQUE,
+ "storeObjectId" text NOT NULL, enabled boolean NOT NULL DEFAULT true,
+ "createdBy" text NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(),
+ "updatedAt" timestamptz NOT NULL DEFAULT now()
+);
+ALTER TABLE "StoreAdministrator" ALTER COLUMN enabled SET DEFAULT true;
+ALTER TABLE "StoreAdministrator" ALTER COLUMN "createdAt" SET DEFAULT now();
+ALTER TABLE "StoreAdministrator" ALTER COLUMN "updatedAt" SET DEFAULT now();
+CREATE UNIQUE INDEX IF NOT EXISTS store_administrator_user_once ON "StoreAdministrator" ("userId");
+CREATE OR REPLACE FUNCTION xs_store_actor(co text, actor text) RETURNS text LANGUAGE plpgsql AS $$
+DECLARE u "_User"; st "_User"; sid text;
+BEGIN
+ SELECT * INTO u FROM "_User" WHERE "objectId"=actor AND company=co FOR SHARE;
+ IF NOT FOUND OR COALESCE(u."isDisabled",false) OR COALESCE(u."isDeleted",false) THEN RAISE EXCEPTION '门店账号无效或已停用'; END IF;
+ IF u."identityType"='store-admin' THEN
+  SELECT "storeObjectId" INTO sid FROM "StoreAdministrator" WHERE company=co AND "userId"=actor AND enabled FOR SHARE;
+ ELSE sid:=actor; END IF;
+ SELECT * INTO st FROM "_User" WHERE "objectId"=sid AND company=co FOR SHARE;
+ IF NOT FOUND OR COALESCE(st."isDisabled",false) OR COALESCE(st."isDeleted",false)
+ OR NOT (st."identityType"='store' OR st."identityType" IS NULL AND st."legacyGroupId"=2)
+ OR COALESCE(st."legacyUserId",0)<=0 THEN RAISE EXCEPTION '门店账号无效或已停用'; END IF;
+ RETURN sid;
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_store_support_process(co text,rid text,actor_id text,actor_name text,next_status text,note text,expected text)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE sid text; st "_User"; owner "_User"; g "Guestbook";
+BEGIN
+ sid:=xs_store_actor(co,actor_id);
+ SELECT * INTO st FROM "_User" WHERE "objectId"=sid;
+ SELECT * INTO g FROM "Guestbook" WHERE company=co AND "objectId"=rid FOR UPDATE;
+ SELECT * INTO owner FROM "_User" WHERE company=co AND "legacyUserId"::text=g.userid::text FOR SHARE;
+ IF owner."objectId" IS NULL OR owner."identityType" NOT IN ('member','coach') OR COALESCE(owner."isDeleted",false) OR owner."legacyUserData"->>'ParentUserID' IS DISTINCT FROM st."legacyUserId"::text THEN RAISE EXCEPTION '申请不存在或不属于本店'; END IF;
+ RETURN xs_support_request_process(co,rid,actor_id,actor_name,next_status,note,expected);
+END $$;

+ 88 - 0
scripts/sql/store-payroll.sql

@@ -0,0 +1,88 @@
+-- Independent store batches keep company-wide once-only lesson/reward indexes.
+BEGIN;
+SELECT set_config('xiaoshu.payroll_write','yes',true);
+ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "scopeStoreId" numeric NOT NULL DEFAULT 0;
+ALTER TABLE "PayrollBatch" ALTER COLUMN "scopeStoreId" SET DEFAULT 0;
+UPDATE "PayrollBatch" SET "scopeStoreId"=0 WHERE "scopeStoreId" IS NULL;
+ALTER TABLE "PayrollBatch" ALTER COLUMN "scopeStoreId" SET NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS payroll_scope_month ON "PayrollBatch" (company,month,"scopeStoreId");
+DROP INDEX IF EXISTS payroll_company_month_once;
+CREATE OR REPLACE FUNCTION xs_payroll_source_store(co text,s jsonb) RETURNS numeric LANGUAGE sql STABLE AS $$
+ SELECT COALESCE(NULLIF(COALESCE(NULLIF(s->>'storeId','0'),NULLIF(s->>'szmdid','0')),'')::numeric,
+ (SELECT NULLIF(u."legacyUserData"->>'ParentUserID','')::numeric FROM "_User" u WHERE u.company=co AND u."legacyUserId"::text=COALESCE(NULLIF(s->>'coachId','0'),s->>'jsmz') LIMIT 1),0)
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_scope_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN IF NEW."scopeStoreId" IS DISTINCT FROM OLD."scopeStoreId" THEN RAISE EXCEPTION '不能修改工资批次门店归属';END IF;RETURN NEW;END $$;
+DROP TRIGGER IF EXISTS payroll_scope_immutable ON "PayrollBatch";
+CREATE TRIGGER payroll_scope_immutable BEFORE UPDATE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xs_payroll_scope_immutable();
+CREATE OR REPLACE FUNCTION xs_payroll_command(co text,actor text,op text,target text,req jsonb,idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE b "PayrollBatch"; previous jsonb; saved "PayrollCommand"; line jsonb; candidate "PayrollLine"; key text; result jsonb; u "_User"; st "_User"; actor_store text; scope_id numeric:=COALESCE((req->>'scopeStoreId')::numeric,0); source_row jsonb;
+BEGIN
+ IF co IS NULL OR length(COALESCE(idem,''))<8 OR length(trim(COALESCE(req->>'reason','')))<2 THEN RAISE EXCEPTION '缺少帐套、操作原因或请求编号'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ PERFORM set_config('xiaoshu.payroll_write','yes',true);
+ IF EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=actor AND ("identityType" IN ('store','store-admin') OR "identityType" IS NULL AND "legacyGroupId"=2)) THEN
+  actor_store:=xs_store_actor(co,actor);
+  SELECT * INTO st FROM "_User" WHERE "objectId"=actor_store;
+  IF scope_id IS DISTINCT FROM st."legacyUserId" THEN RAISE EXCEPTION '工资门店归属无效'; END IF;
+  IF op<>'create' AND NOT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE company=co AND "objectId"=target AND "scopeStoreId"=scope_id) THEN RAISE EXCEPTION '工资单不存在或不属于本店'; END IF;
+ ELSIF scope_id<>0 THEN RAISE EXCEPTION '请使用对应门店账号创建门店工资'; END IF;
+ IF op<>'create' AND NOT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE company=co AND "objectId"=target AND "scopeStoreId"=scope_id) THEN RAISE EXCEPTION '工资单不属于当前结算范围'; END IF;
+ req:=req||jsonb_build_object('actor',actor,'operation',op,'target',target);
+ SELECT * INTO saved FROM "PayrollCommand" WHERE "company"=co AND "requestId"=idem;
+ IF FOUND THEN IF saved."request" IS DISTINCT FROM (req-'lines'-'sourceVersion'-'cutoffVersion') THEN RAISE EXCEPTION '请求编号已用于不同操作'; END IF;RETURN saved."result";END IF;
+ IF op='create' THEN
+  IF COALESCE(req->>'month','') !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
+  INSERT INTO "PayrollBatch" ("objectId","company","sourceKey","month","status","createdAt","updatedAt","createdBy","scopeStoreId") VALUES(xs_object_id(),co,'payroll:'||co||':'||scope_id||':'||(req->>'month'),req->>'month','draft',now(),now(),actor,scope_id) RETURNING * INTO b;
+ ELSE
+  SELECT * INTO b FROM "PayrollBatch" WHERE "company"=co AND "objectId"=target FOR UPDATE;
+  IF NOT FOUND THEN RAISE EXCEPTION '工资单不存在'; END IF;
+  IF req->>'version' IS NULL OR (req->>'version')::int<>b."version" THEN RAISE EXCEPTION '工资单已更新,请刷新后重试'; END IF;
+ END IF;
+ previous:=to_jsonb(b);
+ IF op IN ('create','refresh','review') THEN
+  IF b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定'; END IF;
+  IF req->>'sourceVersion' IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '计薪来源已更新,请重新核对'; END IF;
+  IF to_regprocedure('xs_payroll_assert_cutoff(text,text,text,text)') IS NOT NULL THEN PERFORM xs_payroll_assert_cutoff(co,b."month",op,req->>'cutoffVersion'); END IF;
+  -- Only automated lines are rebuilt; explicit exclusions and adjustments survive.
+  DELETE FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "kind" IN ('lesson','review_reward') AND NOT COALESCE("excluded",false);
+  FOR line IN SELECT value FROM jsonb_array_elements(COALESCE(req->'lines','[]')) LOOP
+   IF line->>'kind' NOT IN ('lesson','review_reward') THEN RAISE EXCEPTION '自动明细类型无效'; END IF;
+   IF line->>'kind'='lesson' AND EXISTS(SELECT 1 FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "standardLessonKey"=line->>'standardLessonKey' AND COALESCE("excluded",false)) THEN CONTINUE; END IF;
+   IF actor_store IS NOT NULL THEN
+    IF COALESCE((line->>'storeId')::numeric,0)<>scope_id THEN RAISE EXCEPTION '工资明细不属于本店'; END IF;
+    IF line->>'kind'='lesson' THEN
+     SELECT s INTO source_row FROM xs_payroll_sources(co) s WHERE s->>'standardLessonKey'=line->>'standardLessonKey';
+     IF source_row IS NULL OR xs_payroll_source_store(co,source_row)<>scope_id THEN RAISE EXCEPTION '计薪课次不属于本店'; END IF;
+    ELSIF NOT EXISTS(SELECT 1 FROM "ReviewRewardIncome" r WHERE r.company=co AND r."objectId"=line->>'reviewRewardId' AND r."storeId"=scope_id) THEN RAISE EXCEPTION '复习工资不属于本店'; END IF;
+   END IF;
+   line:=line||jsonb_build_object('objectId',xs_object_id(),'company',co,'batchId',b."objectId",'settlementMonth',b."month",'createdAt',now(),'updatedAt',now(),'excluded',false,'sourceKey','payroll-line:'||xs_object_id());
+   candidate:=jsonb_populate_record(NULL::"PayrollLine",line); INSERT INTO "PayrollLine" SELECT candidate.*;
+  END LOOP;
+  IF op='review' THEN b."status":='reviewed'; END IF;
+ ELSIF op='adjustment' THEN
+  IF b."status"<>'draft' THEN RAISE EXCEPTION '只有草稿可以调整'; END IF;
+  IF (req->>'amount')::numeric=0 OR abs((req->>'amount')::numeric)>1000000000 OR round((req->>'amount')::numeric,2)<>(req->>'amount')::numeric THEN RAISE EXCEPTION '调整金额无效,最多两位小数'; END IF;
+  SELECT * INTO u FROM "_User" WHERE "company"=co AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=req->>'coachId' AND ("identityType"='coach' OR "identityType" IS NULL AND COALESCE("legacyGroupId"::text,"legacyUserData"->>'GroupID')='3');
+  IF NOT FOUND THEN RAISE EXCEPTION '陪练不存在'; END IF;
+  IF actor_store IS NOT NULL AND u."legacyUserData"->>'ParentUserID' IS DISTINCT FROM scope_id::text THEN RAISE EXCEPTION '陪练不属于本店'; END IF;
+  SELECT * INTO st FROM "_User" WHERE "company"=co AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=u."legacyUserData"->>'ParentUserID' LIMIT 1;
+  INSERT INTO "PayrollLine" ("objectId","company","batchId","sourceKey","kind","coachId","coachObjectId","coachName","storeId","storeName","amount","rateAmount","durationHours","originalMonth","settlementMonth","description","exception","createdAt","updatedAt")
+  VALUES(xs_object_id(),co,b."objectId",'adjustment:'||idem,'adjustment',(req->>'coachId')::numeric,u."objectId",COALESCE(NULLIF(u."realName",''),NULLIF(u."nickname",''),u."username"),COALESCE(st."legacyUserId",0),COALESCE(NULLIF(st."realName",''),NULLIF(st."nickname",''),st."username",'未绑定门店'),(req->>'amount')::numeric,0,0,b."month",b."month",req->>'reason','',now(),now());
+ ELSIF op='exclude' THEN
+  UPDATE "PayrollLine" SET "excluded"=true,"exclusionReason"=req->>'reason',"updatedAt"=now() WHERE "company"=co AND "batchId"=b."objectId" AND "objectId"=req->>'lineId' AND "kind"='lesson' AND COALESCE("exception",'')<>'';
+  IF NOT FOUND THEN RAISE EXCEPTION '只能排除异常课次'; END IF;
+ ELSIF op='mark-paid' THEN
+  IF b."status"<>'reviewed' THEN RAISE EXCEPTION '只有已审核工资单可登记发放'; END IF;b."status":='paid';
+ ELSE RAISE EXCEPTION '工资操作无效'; END IF;
+ UPDATE "PayrollBatch" SET "status"=b."status","sourceRefreshedAt"=CASE WHEN op IN ('create','refresh','review') THEN now() ELSE "sourceRefreshedAt" END,
+ "reviewedAt"=CASE WHEN op='review' THEN now() ELSE "reviewedAt" END,"reviewedBy"=CASE WHEN op='review' THEN actor ELSE "reviewedBy" END,
+ "paidAt"=CASE WHEN op='mark-paid' THEN now() ELSE "paidAt" END,"paidBy"=CASE WHEN op='mark-paid' THEN actor ELSE "paidBy" END,
+ "paymentMethod"=CASE WHEN op='mark-paid' THEN req->>'paymentMethod' ELSE "paymentMethod" END,"paymentReference"=CASE WHEN op='mark-paid' THEN req->>'paymentReference' ELSE "paymentReference" END
+ WHERE "company"=co AND "objectId"=b."objectId" RETURNING to_jsonb("PayrollBatch".*) INTO result;
+ PERFORM xs_payroll_audit(co,actor,op,b."objectId",req->>'reason',previous,result);
+ INSERT INTO "PayrollCommand" ("company","requestId","request","result") VALUES(co,idem,req-'lines'-'sourceVersion'-'cutoffVersion',result);
+ RETURN result;
+END $$;
+
+COMMIT;

+ 1 - 1
scripts/sql/store-scope.sql

@@ -4,7 +4,7 @@ CREATE OR REPLACE FUNCTION xs_store_scope_guard(co text,actor text,uid numeric,c
 DECLARE st "_User"; student "_User"; owner_uid numeric;
 BEGIN
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
- SELECT * INTO st FROM "_User" WHERE "objectId"=actor AND company=co FOR SHARE;
+ SELECT * INTO st FROM "_User" WHERE "objectId"=xs_store_actor(co,actor) AND company=co FOR SHARE;
  IF NOT FOUND OR COALESCE(st."isDisabled",false) OR COALESCE((to_jsonb(st)->>'isDeleted')::boolean,false)
  OR NOT (st."identityType"='store' OR (st."identityType" IS NULL AND st."legacyGroupId"=2)) OR COALESCE(st."legacyUserId",0)<=0 THEN RAISE EXCEPTION '门店账号无效或已停用';END IF;
  SELECT * INTO student FROM "_User" WHERE company=co AND "legacyUserId"=uid FOR UPDATE;

+ 1 - 1
scripts/store-access-policy.mjs

@@ -1,7 +1,7 @@
 // Teaching and account records are served through authenticated, scoped cloud
 // functions. Public REST access would bypass the store gateway entirely.
 export const STORE_PRIVATE_CLASSES = [
- '_User','_Session','Role','Manager','PlatUserRole','CommonModel','CourseBinding','CourseAppointment',
+ 'StoreAdministrator','_User','_Session','Role','Manager','PlatUserRole','CommonModel','CourseBinding','CourseAppointment',
  'LessonRecord','DailyStudyRecord','PracticeRecord','CoachAvailability',
  'MemoryPracticeRecord','UnitLearningRecord','SurveyLog','UserBankAccount','UserCredit','UserDummyPoint','UserFavorite','UserCoupon','MemberNote','SysLog','UserExpDomP','UserSIcon','UserExpHis','UserUserPoint',
  'CourseCreditReservation','CreditTransaction','LessonCompletionReport','LessonReview',

+ 11 - 1
scripts/tests/learning-report-query.test.mjs

@@ -14,7 +14,7 @@ before(async () => {
   await db.exec(`
     CREATE TABLE "DailyStudyRecord"("objectId" text,"company" text,"id" int,"userId" int,"pl" text,"dsid" text,"dqrq" text,"xxqs" text,"createdAt" timestamptz);
     CREATE TABLE "CommonModel"("objectId" text,"company" text,"modelId" int,"itemId" int,"generalId" int,"createTime" timestamptz,"createdAt" timestamptz);
-    CREATE TABLE "_User"("objectId" text,"company" text,"legacyUserId" int,"legacyUserData" jsonb,"realName" text,"nickname" text,"username" text,"mobile" text);
+    CREATE TABLE "_User"("objectId" text,"company" text,"legacyUserId" int,"legacyUserData" jsonb,"realName" text,"nickname" text,"username" text,"mobile" text,"identityType" text,"isDeleted" boolean);
     CREATE TABLE "CourseAppointment"("objectId" text,"company" text,"id" int,"kcid" text,"dszt" text);
     CREATE TABLE "Node"("company" text,"nodeId" int,"nodeName" text);
     INSERT INTO "DailyStudyRecord" VALUES
@@ -46,3 +46,13 @@ test('报表展示与筛选使用同一个学习日期,斜线日期与 UTC 时
   for(const raw of ['20261004','2026-10-04','2026/10/04'])assert.equal(gateway.learningReportProjection({dqrq:raw}).studyDate,'2026-10-04');
   assert.equal(gateway.learningReportProjection({recordCreatedAt:'2026-09-30T16:01:00Z'}).studyDate,'2026-10-01');
 });
+
+test('门店报表按当前会员归属过滤后再计数,无法通过过滤器读取其他门店',async()=>{
+ await db.exec(`INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyUserData","identityType") VALUES ('own','co',20,'{"ParentUserID":57}','member'),('other','co',21,'{"ParentUserID":73}','member');
+ INSERT INTO "DailyStudyRecord" ("objectId",company,"userId",dqrq) VALUES ('other-store','co',21,'20261004');`);
+ const query=gateway.learningReportQueryParts({storeId:57},{page:1,pageSize:20,filters:{month:'2026-10',storeId:73}});
+ const count=(await db.query('SELECT COUNT(*)::int total'+query.from+query.where,query.values)).rows[0].total;
+ assert.equal(count,4);
+ const foreign=gateway.learningReportQueryParts({storeId:73},{page:1,pageSize:20,filters:{month:'2026-10'}});
+ assert.deepEqual((await db.query('SELECT d."objectId"'+foreign.from+foreign.where,foreign.values)).rows.map(r=>r.objectId),['other-store']);
+});

+ 63 - 0
scripts/tests/store-payroll.test.mjs

@@ -0,0 +1,63 @@
+import {test,before,after} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite(),source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const q=async(sql,args=[])=>(await db.query(sql,args)).rows,one=async(sql,args=[])=>(await q(sql,args))[0];
+const migration=readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8');
+const context=vm.createContext({Date,Psql:{query:q,one,oneOrNone:one}});
+vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),context);
+context.companyIdOf=()=> 'co';
+const manager={roles:['ops-manager'],current:{id:'teacher'}};
+async function command(op,target,req,id){return(await one('SELECT xs_payroll_command(\'co\',\'teacher\',$1,$2,$3::jsonb,$4) AS result',[op,target,JSON.stringify({reason:'测试工资',...req}),id])).result;}
+async function version(){return(await one('SELECT xs_payroll_source_version(\'co\') AS value')).value;}
+before(async()=>{
+ const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n  });/)[1]+')');
+ for(const [name,fields] of Object.entries(schemas)){if(name==='CommonModel')continue;const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);}
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,id numeric,plid text,yhid text,xxjlid text,kywrq text,kywsj text,kcid numeric,fxzt text,wcsj text,"updatedAt" timestamptz);
+ CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,company text,"sourceKey" text NOT NULL UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,cdate timestamptz,cname text,type1 text,type2 text,type3 text,clevel numeric,"cadminId" numeric,detail text,content1 text,content2 text);
+ ALTER TABLE "CourseAppointment" ADD COLUMN szyh text,ADD COLUMN kcid text,ADD COLUMN pl text,ADD COLUMN fxpl text,ADD COLUMN dszt text,ADD COLUMN dslx text,ADD COLUMN jffs text,ADD COLUMN jssj text,ADD COLUMN kssj text,ADD COLUMN yysj text,ADD COLUMN bxrq text,ADD COLUMN sdsd text,ADD COLUMN plxm text,ADD COLUMN yykcid text;
+ INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyGroupId","identityType","legacyUserData","realName",username) VALUES ('teacher','co',30,3,'coach','{"ParentUserID":10}','老师','teacher'),('student','co',20,1,'member','{"UserPoint":2,"Purse":2}','学员','student'),('store','co',10,2,'store','{}','门店','store');`);
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,company text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" numeric,score numeric,"scoreBefore" numeric,"hisTime" timestamptz,operator numeric,"scoreType" numeric,detail text,remark text,"payMethod" text)`);
+ await db.exec(readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8'));
+ await db.exec(migration);
+ await db.exec(readFileSync(new URL('../sql/store-administrators.sql',import.meta.url),'utf8').split('CREATE OR REPLACE FUNCTION xs_store_support_process')[0]);
+ await db.exec(readFileSync(new URL('../sql/store-payroll.sql',import.meta.url),'utf8'));
+ await db.exec(`INSERT INTO "_User" ("objectId",company,"legacyUserId","identityType","legacyUserData",username) VALUES ('store-b','co',11,'store','{}','store-b'),('admin-a','co',NULL,'store-admin','{}','admin-a'),('admin-b','co',NULL,'store-admin','{}','admin-b'),('teacher-b','co',31,'coach','{"ParentUserID":11}','teacher-b');
+ INSERT INTO "StoreAdministrator" ("objectId",company,"userId","storeObjectId","createdBy") VALUES ('bind-a','co','admin-a','store','hq'),('bind-b','co','admin-b','store-b','hq');
+ INSERT INTO "LessonRecord" ("objectId",company,id,jsmz,xymz,"legacyGeneralId","lessonAt","teacherPaySnapshot") VALUES ('lesson-a','co',701,'30','20',701,'2026-09-01 10:00+08',40),('lesson-b','co',702,'31','20',702,'2026-09-01 11:00+08',40);`);
+ context.teachingCategoryRows=async()=>[];context.courseTeachingRows=async()=>[];context.payRateRows=async()=>context.defaultPayRates();
+});after(()=>db.close());
+
+const ownLine={kind:'lesson',standardLessonKey:'legacy:701',legacyGeneralId:701,storeId:10,coachId:30,coachName:'老师',amount:40,rateAmount:40,durationHours:1,originalMonth:'2026-09',lessonAt:'2026-09-01T02:00:00Z',exception:''};
+const otherLine={...ownLine,standardLessonKey:'legacy:702',legacyGeneralId:702,storeId:11,coachId:31};
+const run=async(actor,scope,op,target,payload,id)=>(await one('SELECT xs_payroll_command(\'co\',$1,$2,$3,$4::jsonb,$5) result',[actor,op,target,JSON.stringify({scopeStoreId:scope,reason:'测试门店独立工资',...payload}),id])).result;
+let a,b;
+test('同月门店各自建单、不能伪造门店或跨店明细',async()=>{
+ const v=await version();
+ await assert.rejects(run('admin-a',11,'create','',{month:'2026-09',lines:[],sourceVersion:v},'bad-store'),/归属/);
+ await assert.rejects(run('admin-a',10,'create','',{month:'2026-09',lines:[otherLine],sourceVersion:v},'bad-lines'),/不属于/);
+ await assert.rejects(run('admin-a',10,'create','',{month:'2026-09',lines:[{...otherLine,storeId:10}],sourceVersion:v},'spoof-lines'),/不属于/);
+ a=await run('admin-a',10,'create','',{month:'2026-09',lines:[ownLine],sourceVersion:v},'create-a');
+ b=await run('admin-b',11,'create','',{month:'2026-09',lines:[otherLine],sourceVersion:v},'create-b');
+ assert.equal(Number(a.scopeStoreId),10);assert.equal(Number(b.scopeStoreId),11);assert.equal(a.payableAmount,40);
+ assert.equal((await run('admin-a',10,'create','',{month:'2026-09',lines:[ownLine],sourceVersion:v},'create-a')).objectId,a.objectId);
+ await assert.rejects(run('admin-a',10,'create','',{month:'2026-09',lines:[],sourceVersion:v},'duplicate-a'),/unique|重复/);
+ await assert.rejects(run('teacher',0,'create','',{month:'2026-09',lines:[ownLine],sourceVersion:v},'hq-duplicate'),/unique|重复/);
+});
+test('跨店批次与幂等重放均拒绝,门店只能调整本店老师',async()=>{
+ await assert.rejects(run('admin-b',11,'adjustment',a.objectId,{version:a.version,coachId:31,amount:5},'other-adjust'),/不属于/);
+ await assert.rejects(run('admin-a',10,'adjustment',a.objectId,{version:a.version,coachId:31,amount:5},'other-coach'),/不属于/);
+ a=await run('admin-a',10,'adjustment',a.objectId,{version:a.version,coachId:30,amount:5},'own-adjust');assert.equal(a.payableAmount,45);
+});
+test('本店审核与登记发放,保持来源校验和审核锁定',async()=>{
+ await assert.rejects(run('admin-a',10,'review',a.objectId,{version:a.version,sourceVersion:'stale',lines:[ownLine]},'stale-review'),/来源已更新/);
+ a=await run('admin-a',10,'review',a.objectId,{version:a.version,sourceVersion:await version(),lines:[ownLine]},'review-a');assert.equal(a.status,'reviewed');
+ await assert.rejects(run('admin-a',10,'adjustment',a.objectId,{version:a.version,coachId:30,amount:1},'locked-adjust'),/草稿/);
+ a=await run('admin-a',10,'mark-paid',a.objectId,{version:a.version,paymentMethod:'测试转账',paymentReference:'测试凭据'},'paid-store-a');assert.equal(a.status,'paid');
+ await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='store'`);
+ await assert.rejects(run('admin-a',10,'mark-paid',a.objectId,{version:a.version,paymentMethod:'测试转账',paymentReference:'测试凭据'},'paid-store-a'),/停用/);
+});

+ 21 - 2
scripts/tests/store-scope.test.mjs

@@ -21,7 +21,7 @@ test('门店身份独立,伪造帐套或总部角色不能拓宽权限',async(
  await assert.rejects(c.auth({user:user('orphan',{identityType:'store',legacyUserId:10})}),/帐套/);
 });
 test('门店接口默认拒绝总部、工资、通用数据、同步与未经审核的新接口',async()=>{
- const c=env();for(const operation of ['ops/stores/credit-post','ops/stores/credit-grants/save','ops/stores/update','ops/payroll/list','ops/coaches/create','ops/users/change-group','ops/relations/save','ops/users/import-commit','ops/sync/run','ops/new/endpoint','ops/appointments/complete'])await assert.rejects(c.prepareStoreRequest(ctx,{operation},{}),/无权/);
+ const c=env();for(const operation of ['ops/stores/credit-post','ops/stores/credit-grants/save','ops/stores/update','ops/payroll/cutoff-confirm','ops/teaching-rules/save','ops/users/change-group','ops/relations/save','ops/users/import-commit','ops/sync/run','ops/new/endpoint','ops/appointments/complete'])await assert.rejects(c.prepareStoreRequest(ctx,{operation},{}),/无权/);
 });
 test('新增学生强制本店归属,拒绝伪造跨店、跨帐套及直接差错增额',async()=>{
  const c=env(),input={operation:'ops/users/create',_storeId:999,filters:{parentUserId:99}},p={parentUserId:0};await c.prepareStoreRequest(ctx,input,p);assert.equal(p.parentUserId,10);assert.equal(input._storeId,10);assert.equal(input.filters.parentUserId,10);
@@ -62,7 +62,7 @@ test('事务内重新验证门店、学生、老师与原单,跨店或停用
  const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"isDeleted" boolean,"legacyUserData" jsonb);
  CREATE TABLE "CommonModel"("objectId" text,company text,"modelId" int,"itemId" int);CREATE TABLE "CourseAppointment"(company text,id int,szyh text);CREATE TABLE "CourseBinding"(company text,id int,yhid text);
  INSERT INTO "_User" VALUES('store-a','co',10,2,'store',false,false,'{"Purse":10}'),('store-b','co',11,2,'store',false,false,'{}'),('member-a','co',20,1,'member',false,false,'{"ParentUserID":10,"Purse":2}'),('member-b','co',21,1,'member',false,false,'{"ParentUserID":11}'),('teacher-a','co',30,3,'coach',false,false,'{"ParentUserID":10}'),('teacher-b','co',31,3,'coach',false,false,'{"ParentUserID":11}');
- INSERT INTO "CommonModel" VALUES('appt-a','co',54,1),('appt-b','co',54,2);INSERT INTO "CourseAppointment" VALUES('co',1,'20'),('co',2,'21');`);await db.exec(read('../sql/store-scope.sql'));
+ INSERT INTO "CommonModel" VALUES('appt-a','co',54,1),('appt-b','co',54,2);INSERT INTO "CourseAppointment" VALUES('co',1,'20'),('co',2,'21');`);await db.exec(read('../sql/store-administrators.sql').split('CREATE OR REPLACE FUNCTION xs_store_support_process')[0]);await db.exec(read('../sql/store-scope.sql'));
  const guard=(actor,uid,coach=0,target='',co='co')=>db.query('SELECT xs_store_scope_guard($1,$2,$3,$4,$5)',[co,actor,uid,coach,target]);
  await guard('store-a',20,30,'appt-a');await assert.rejects(guard('store-a',21),/不属于本店/);await assert.rejects(guard('store-a',20,31),/陪练/);await assert.rejects(guard('store-a',20,30,'appt-b'),/原记录/);await assert.rejects(guard('store-a',20,30,'','other'),/账号无效/);
  await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='store-a'`);await assert.rejects(guard('store-a',20),/停用/);assert.equal((await db.query(`SELECT "legacyUserData" FROM "_User" WHERE "objectId"='member-a'`)).rows[0].legacyUserData.Purse,2);
@@ -87,3 +87,22 @@ test('门店元数据可以读取,技术后台和旧移动业务入口拒绝
  await c.handler({user:a,body:{params:{operation:'catalog'}}},response);assert.equal(status,403);
  const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];assert.ok(app.includes("门店业务请使用门店管理后台"));
 });
+test('独立门店管理员只能通过服务器绑定获取门店身份,停用门店立即拒绝',async()=>{
+ const c=env(),delegated=user('login-a',{identityType:'store-admin',company,roles:['super-admin'],isAdmin:true});
+ let bound=account;c.Psql={oneOrNone:async(_q,args)=>args[1]==='login-a'?{storeObjectId:'store-a'}:null};c.Parse={Query:class{async get(){return bound}}};
+ const resolved=await c.auth({user:delegated,company:{id:'forged'}});assert.equal(resolved.storeId,10);assert.equal(resolved.store.id,'store-a');assert.equal(resolved.current.id,'login-a');assert.equal(resolved.isSuperAdmin,false);
+ bound=user('store-a',{identityType:'store',legacyUserId:10,company,isDisabled:true});await assert.rejects(c.auth({user:delegated}),/停用/);
+ await assert.rejects(c.auth({user:user('unbound',{identityType:'store-admin',company})}),/绑定/);
+});
+test('门店模块名单允许独立结算,覆盖浏览器伪造门店筛选且关闭旧库刷新',async()=>{
+ const c=env();for(const operation of ['ops/lessons/list','ops/learning-reports/list','ops/support-requests/list','ops/coaches/list','ops/payroll/create','ops/payroll/review','ops/payroll/mark-paid']){
+ const input={operation,storeId:999,_storeId:999,refresh:true,legacyRefresh:true,filters:{storeId:999,parentUserId:999}};await c.prepareStoreRequest(ctx,input,{});assert.equal(input.storeId,10);assert.equal(input.filters.storeId,10);assert.equal(input.refresh,false);assert.equal(input.legacyRefresh,false);
+ }
+});
+test('老师对象和可用时段始终校验本店归属',async()=>{
+ const c=env();c.Psql={oneOrNone:async(_q,args)=>args[1]==='own'?{objectId:'own',legacyUserId:30}:null};
+ await c.prepareStoreRequest(ctx,{operation:'ops/coaches/detail',objectId:'own'},{});
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/coaches/update',targetId:'other'},{parentUserId:10}),/不属于本店/);
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/coaches/create'},{parentUserId:11}),/其他门店/);
+ await assert.rejects(c.storeAssertObject(ctx,{},'CoachAvailability',user('availability',{coachId:31})),/不属于本店/);
+});

+ 14 - 0
scripts/tests/support-requests.test.mjs

@@ -16,6 +16,7 @@ before(async()=>{
   await db.exec(`CREATE TABLE "Guestbook"("objectId" text,company text,userid numeric,title text,tcontent text,gdate timestamptz,"createdAt" timestamptz,parentid numeric,status numeric,cateid numeric,"sourceKey" text);
     CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,"createdAt" timestamptz);
     INSERT INTO "_User" VALUES('s','a',1,1,'member','{}','学生甲','','','2026-10-01'),('t','a',2,3,'coach','{}','老师乙','','','2026-10-01'),('other','b',1,3,'coach','{}','其他帐套','','','2026-10-01');`);
+  await db.exec('ALTER TABLE "_User" ADD COLUMN "isDeleted" boolean, ADD COLUMN "isDisabled" boolean');
   await db.exec(readFileSync(new URL('../sql/support-requests.sql',import.meta.url),'utf8'));
   const rows=[
     ['new','a',1,'【意见反馈】学生甲','希望调整课程','2026-10-14T16:00:00Z',0,0,22],
@@ -107,3 +108,16 @@ test('leave approval or refusal is atomic and cannot be marked as ordinary feedb
  assert.equal((await ctx.supportRequestsPage(admin,{status:'rejected'})).total,1);
  assert.equal((await ctx.appSupportRequests({},member(2))).unread,2);
 });
+
+test('门店分页和处理仅限本店提交者,外店请求与停用绑定拒绝',async()=>{
+ await db.exec(`UPDATE "_User" SET "legacyUserData"='{"ParentUserID":10}' WHERE "objectId"='s';UPDATE "_User" SET "legacyUserData"='{"ParentUserID":11}' WHERE "objectId"='t';
+ INSERT INTO "_User" ("objectId",company,"legacyUserId","identityType") VALUES ('store-a','a',10,'store'),('store-b','a',11,'store'),('store-login','a',NULL,'store-admin');`);
+ await db.exec(readFileSync(new URL('../sql/store-administrators.sql',import.meta.url),'utf8'));
+ await db.exec(`INSERT INTO "StoreAdministrator"("objectId",company,"userId","storeObjectId","createdBy") VALUES ('binding','a','store-login','store-a','admin');`);
+ const d=await ctx.supportRequestsPage({...admin,storeId:10},{pageSize:1});assert.equal(d.total,2);assert.equal(d.items[0].userId,'1');
+ assert.equal((await ctx.supportRequestsPage({...admin,storeId:10},{search:'老师乙'})).total,0);
+ const local={...admin,storeId:10,current:{id:'store-login',get:()=> '门店管理员'}};
+ await assert.rejects(ctx.supportRequestProcess(local,{targetId:'leave',status:'approved',comment:'跨店处理',version:'forged'}),/不属于/);
+ await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='store-a'`);
+ await assert.rejects(ctx.supportRequestProcess(local,{targetId:'new',status:'processed',comment:'停用门店',version:'forged'}),/停用/);
+});

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä