Просмотр исходного кода

fix(stores): remove store management from branch backends

彭峰 4 часов назад
Родитель
Сommit
64e362f5e8

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

@@ -43,7 +43,6 @@ export const STORE_NAVIGATION: readonly AdminNavigationGroup[] = [
  {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'}]},

+ 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(?:\/[^/]+)?|stores(?:\/[^/]+)?|coaches(?:\/[^/]+)?|schedule|payroll|lessons|learning-reports|support-requests|change-password|forbidden)$/.test(path))return inject(Router).createUrlTree(['/admin/forbidden']);
+      if(!/^\/admin\/(store-home|members(?:\/[^/]+)?|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/store-home.component.ts

@@ -7,7 +7,7 @@ interface StoreHome {storeId:number;storeObjectId:string;storeName:string;summar
 <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><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"/>}
+@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/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('');

+ 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/vocabulary','/admin/privacy-requests','/admin/operator-accounts','/tech-admin/dashboard']){
+  for(const path of ['/admin/stores','/admin/stores/a','/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/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();
+  for(const path of ['/admin/members','/admin/members/a','/admin/schedule','/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/stores','/admin/schedule','/admin/coaches','/admin/payroll','/admin/lessons','/admin/learning-reports','/admin/support-requests','/admin/change-password']);});
+ it('展示八项门店模块和个人密码菜单,不含门店管理',()=>{expect(STORE_NAVIGATION.flatMap(g=>g.items).map(i=>i.route)).toEqual(['/admin/store-home','/admin/members','/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();

+ 5 - 12
scripts/cloud/store-scope.js

@@ -6,7 +6,7 @@ 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/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',
@@ -69,7 +69,7 @@ function storePublicSchedule(data) {
 async function storeSchedule(context,input) {
  const from=text(input.dateFrom)||businessDate(),to=text(input.dateTo)||from;
  const [members,appointments,coachRows,summary]=await Promise.all([
-  queryUsers(context,input),appointmentRowsInRange(context,input,from,to),
+  queryUsers(context,input),storeAppointmentRows(context,input,from,to),
   Psql.query('SELECT "objectId","legacyUserId","realName",nickname,username FROM "_User" WHERE company=$1 AND "identityType"=\'coach\' AND "legacyUserData"->>\'ParentUserID\'=$2 AND COALESCE("isDisabled",false)=false AND COALESCE("isDeleted",false)=false ORDER BY "legacyUserId"',[pointerId(context.company),String(context.storeId)]),
   Psql.one('SELECT count(*)::int total,min(left(a.yysj,10)) first,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 COALESCE(s."isDeleted",false)=false',[pointerId(context.company),String(context.storeId)])
  ]);
@@ -96,7 +96,7 @@ 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)
+  storeAppointmentRows(context,input,month+'-01',monthEnd),payrollPreviewData(context,input,month,true,false,{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};
@@ -120,13 +120,6 @@ 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);
@@ -136,8 +129,8 @@ async function storeModuleRequest(context,input,payload) {
  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:'结算来源由总部统一核验,本店沿用已核验的来源。'};}
 }
+
+async function storeAppointmentRows(context,input,dateFrom,dateTo) { const companyId=companyIdOf(context,input),from=text(dateFrom),to=text(dateTo);if(!/^\d{4}-\d{2}-\d{2}$/.test(from)||!/^\d{4}-\d{2}-\d{2}$/.test(to)||from>to)fail(400,'排课日期范围无效');const raw=await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."status" AS "commonStatus",c."updatedAt" AS "commonUpdatedAt",a.*,s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."legacyUserData" AS "studentLegacy",p."objectId" AS "coachObjectId",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",n."nodeName" AS "courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"=c."itemId" LEFT JOIN "_User" s ON s."company"=c."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(a."szyh" AS text) LEFT JOIN "_User" p ON p."company"=c."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),CAST(a."fxpl" AS text)) LEFT JOIN "Node" n ON n."company"=c."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND c."modelId"=54 AND LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) BETWEEN $2 AND $3 AND ($4::text=\'\' OR (s."identityType"=\'member\' AND s."legacyUserData"->>\'ParentUserID\'=$4 AND COALESCE(s."isDeleted",false)=false)) ORDER BY COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')) ASC,c."updatedAt" DESC LIMIT 10000',[companyId,from,to,context.storeId?String(context.storeId):'']);return raw.map(appointmentProjection);}

+ 14 - 1
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/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},{}),/无权/);
+ const c=env();for(const operation of ['ops/stores/list','ops/stores/detail','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);
@@ -106,3 +106,16 @@ test('老师对象和可用时段始终校验本店归属',async()=>{
  await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/coaches/create'},{parentUserId:11}),/其他门店/);
  await assert.rejects(c.storeAssertObject(ctx,{},'CoachAvailability',user('availability',{coachId:31})),/不属于本店/);
 });
+test('门店预约使用真实数值关联字段,并在查询内过滤门店和日期',async()=>{
+ const db=new PGlite();try{await db.exec(`
+ CREATE TABLE "CommonModel"("objectId" text,"generalId" numeric,status numeric,"updatedAt" timestamptz,company text,"itemId" numeric,"modelId" numeric);
+ CREATE TABLE "CourseAppointment"(id numeric,company text,szyh text,pl text,fxpl text,kcid text,yysj text,bxrq text);
+ CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"legacyUserData" jsonb,"identityType" text,"isDeleted" boolean,"realName" text,nickname text,username text);
+ CREATE TABLE "Node"(company text,"nodeId" numeric,"nodeName" text);
+ INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyUserData","identityType") VALUES ('s1','co',20,'{"ParentUserID":10}','member'),('s2','co',21,'{"ParentUserID":11}','member');
+ INSERT INTO "CourseAppointment" (id,company,szyh,yysj) VALUES (1,'co','20','2026-10-20 10:00'),(2,'co','21','2026-10-20 10:00'),(3,'co','20','2026-09-20 10:00');
+ INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId") VALUES ('a1','co',54,1),('a2','co',54,2),('a3','co',54,3);`);
+ const c=env();c.Psql={query:async(q,args)=>(await db.query(q,args)).rows};c.appointmentProjection=r=>r;
+ assert.deepEqual(plain(await c.storeAppointmentRows(ctx,{},'2026-10-01','2026-10-31')).map(r=>r.targetId),['a1']);
+ }finally{await db.close();}
+});