Explorar o código

feat(stores): add scoped operations trend to workbench

彭峰 hai 4 horas
pai
achega
0fc4bc7a13

+ 52 - 0
projects/xiaoshu-admin/src/app/components/store-operations-trend.component.html

@@ -0,0 +1,52 @@
+  <section class="trend-card" aria-labelledby="store-trend-title" aria-describedby="store-trend-description">
+    <header>
+      <div><span>OPERATIONS PULSE</span><h2 id="store-trend-title">本店运营数据</h2><p id="store-trend-description">仅统计本店学员的每日排课与排课学员人数</p></div>
+      <div class="trend-header-actions">
+        <div class="trend-periods" aria-label="排课趋势时间维度">
+          <button type="button" [class.active]="trendPeriod()==='week'" (click)="selectTrendPeriod('week')">周</button>
+          <button type="button" [class.active]="trendPeriod()==='month'" (click)="selectTrendPeriod('month')">月</button>
+          <button type="button" [class.active]="trendPeriod()==='year'" (click)="selectTrendPeriod('year')">年</button>
+        </div>
+        @if(selectedTrendPoint();as selected){
+          <div class="selected-day"><span>{{selected.isToday?'今日运营':'当日运营'}}</span><strong>{{selected.label}}</strong><small>已完课 {{selected.completedCount}} 节</small></div>
+        }
+      </div>
+    </header>
+    @if(trendLoading()){
+      <div class="trend-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24"/>正在汇总排课趋势…</div>
+    }@else if(trendError()){
+      <div class="trend-state error" role="alert">{{trendError()}}<button type="button" (click)="selectTrendPeriod(trendPeriod())">重新加载</button></div>
+    }@else{
+    @if(trend();as chart){
+      <div class="trend-summary overview-metrics">
+        @if(selectedTrendPoint();as selected){
+          <div class="trend-stat courses"><span>{{selected.isToday?'今日排课':'当日排课'}}</span><strong>{{selected.courseCount | number}}</strong><small>节课程</small></div>
+          <div class="trend-stat students"><span>{{selected.isToday?'今日学员':'当日学员'}}</span><strong>{{selected.studentCount | number}}</strong><small>位不同学员</small></div>
+        }
+
+      </div>
+      <div class="trend-chart-heading"><div><strong>每日排课趋势</strong><span>滑动图表或选择数据点查看每天数据</span></div><div class="trend-legend"><span class="courses">排课节数</span><span class="students">学员人数</span></div></div>
+      <div #trendScroller class="trend-scroller" tabindex="0" aria-label="可横向滑动的每日排课折线图">
+        <svg class="trend-chart" [attr.viewBox]="'0 0 '+chartWidth()+' 290'" [style.width.px]="chartWidth()" role="img" [attr.aria-label]="chart.periodLabel+'排课趋势,共 '+chart.totalCourseCount+' 节课程、'+chart.totalStudentCount+' 位学员'">
+          @for(gridValue of gridValues();track $index){
+            <line class="grid-line" x1="54" [attr.y1]="chartY(gridValue)" [attr.x2]="chartWidth()-36" [attr.y2]="chartY(gridValue)"/>
+            <text class="axis-label" x="42" [attr.y]="chartY(gridValue)+4" text-anchor="end">{{gridValue}}</text>
+          }
+          <polyline class="trend-line students-line" fill="none" [attr.points]="chartLine('studentCount')"/>
+          <polyline class="trend-line courses-line" fill="none" [attr.points]="chartLine('courseCount')"/>
+          @for(point of chart.points;track point.key;let index=$index){
+            @if(selectedTrendIndex()===index){<line class="selected-guide" [attr.x1]="chartX(index)" y1="50" [attr.x2]="chartX(index)" y2="248"/>}
+            <g class="trend-point" [class.selected]="selectedTrendIndex()===index" tabindex="0" role="button" [attr.aria-label]="point.label+',排课'+point.courseCount+'节,学员'+point.studentCount+'人'" (mouseenter)="selectTrendPoint(index)" (click)="selectTrendPoint(index)" (keydown)="selectTrendPointByKey($event,index)">
+              <rect class="point-column" [attr.x]="chartX(index)-26" y="48" width="52" height="222" rx="8"/>
+              <circle class="point-hit" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.courseCount)" r="17"/>
+              <circle class="student-point" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.studentCount)" r="4"/>
+              <circle class="course-point" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.courseCount)" r="5"/>
+              <text class="date-label" [attr.x]="chartX(index)" y="270" text-anchor="middle">{{point.shortLabel}}</text>
+            </g>
+          }
+        </svg>
+      </div>
+      <footer><span>{{chart.dateFrom}} 至 {{chart.dateTo}}</span><span>本店排课数据</span></footer>
+    }@else{<div class="trend-state">暂无排课趋势数据</div>}
+    }
+  </section>

+ 50 - 0
projects/xiaoshu-admin/src/app/components/store-operations-trend.component.spec.ts

@@ -0,0 +1,50 @@
+import { TestBed } from '@angular/core/testing';
+import { OperationsService } from '../operations.service';
+import { ScheduleTrendData, ScheduleTrendPeriod } from '../admin.models';
+import { StoreOperationsTrendComponent } from './store-operations-trend.component';
+
+const trend = (period: ScheduleTrendPeriod): ScheduleTrendData => ({
+  period, periodLabel: period, dateFrom: '2026-10-20', dateTo: '2026-10-20',
+  points: [{key:'2026-10-20',label:'10月20日',shortLabel:'20日',courseCount:3,studentCount:2,completedCount:1,isToday:true}],
+  todayCourseCount:3,todayStudentCount:2,totalCourseCount:3,totalStudentCount:2,maxValue:3,
+  refreshedAt:'2026-10-20T04:00:00Z',liveSourceAvailable:true,liveSourceMessage:'新数据库排课投影',
+});
+
+describe('StoreOperationsTrendComponent', () => {
+  let operations: jasmine.SpyObj<OperationsService>;
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj('OperationsService', ['scheduleTrend']);
+    operations.scheduleTrend.and.callFake(async period => trend(period));
+    await TestBed.configureTestingModule({imports:[StoreOperationsTrendComponent],providers:[{provide:OperationsService,useValue:operations}]}).compileComponents();
+  });
+  function create() {
+    const fixture=TestBed.createComponent(StoreOperationsTrendComponent);
+    fixture.componentRef.setInput('refreshVersion',1);fixture.detectChanges();return fixture;
+  }
+  it('shows the weekly chart, daily counts and retains the selected period on workbench refresh',async()=>{
+    const fixture=create();await fixture.whenStable();fixture.detectChanges();
+    expect(fixture.nativeElement.querySelectorAll('polyline').length).toBe(2);
+    expect(fixture.nativeElement.querySelector('.trend-stat.courses').textContent).toContain('3');
+    expect(fixture.nativeElement.querySelector('.trend-stat.students').textContent).toContain('2');
+    await fixture.componentInstance.selectTrendPeriod('month');
+    fixture.componentRef.setInput('refreshVersion',2);fixture.detectChanges();await fixture.whenStable();
+    expect(operations.scheduleTrend.calls.allArgs()).toEqual([['week'],['month'],['month']]);
+  });
+  it('ignores an older response after the user switches periods',async()=>{
+    let resolveWeek!: (value:ScheduleTrendData)=>void;
+    operations.scheduleTrend.and.callFake(period=>period==='week'?new Promise(resolve=>resolveWeek=resolve):Promise.resolve(trend(period)));
+    const fixture=create();await fixture.componentInstance.selectTrendPeriod('year');
+    resolveWeek(trend('week'));await fixture.whenStable();
+    expect(fixture.componentInstance.trend()?.period).toBe('year');
+    expect(fixture.componentInstance.trendLoading()).toBeFalse();
+  });
+  it('allows retrying a failed period without blocking the rest of the workbench',async()=>{
+    operations.scheduleTrend.and.rejectWith(new Error('网络异常'));
+    const fixture=create();await fixture.whenStable();fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('网络异常');
+    operations.scheduleTrend.and.resolveTo(trend('week'));
+    await fixture.componentInstance.selectTrendPeriod('week');fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull();
+    expect(fixture.componentInstance.trend()?.totalCourseCount).toBe(3);
+  });
+});

+ 71 - 0
projects/xiaoshu-admin/src/app/components/store-operations-trend.component.ts

@@ -0,0 +1,71 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild, computed, inject, signal } from '@angular/core';
+import { LoaderCircle, LucideAngularModule } from 'lucide-angular';
+import { ScheduleTrendData, ScheduleTrendPeriod } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+@Component({
+  selector: 'app-store-operations-trend',
+  standalone: true,
+  imports: [CommonModule, LucideAngularModule],
+  templateUrl: './store-operations-trend.component.html',
+  styleUrls: ['../pages/admin-dashboard-trend.component.scss'],
+  styles: [':host{display:block}.trend-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.spin{animation:trend-spin 1s linear infinite}@keyframes trend-spin{to{transform:rotate(360deg)}}'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class StoreOperationsTrendComponent implements OnChanges, OnDestroy {
+  @Input() refreshVersion = 0;
+  @ViewChild('trendScroller') set scroller(element: ElementRef<HTMLElement> | undefined) {
+    this.resizeObserver?.disconnect();
+    if (!element) return;
+    this.chartViewportWidth.set(element.nativeElement.clientWidth);
+    this.resizeObserver = new ResizeObserver(([entry]) => this.chartViewportWidth.set(Math.round(entry.contentRect.width)));
+    this.resizeObserver.observe(element.nativeElement);
+    element.nativeElement.scrollLeft = element.nativeElement.scrollWidth;
+  }
+  private resizeObserver?: ResizeObserver;
+  private readonly operations = inject(OperationsService);
+  private requestVersion = 0;
+  readonly icons = { LoaderCircle };
+  readonly trend = signal<ScheduleTrendData | null>(null);
+  readonly trendPeriod = signal<ScheduleTrendPeriod>('week');
+  readonly trendLoading = signal(false);
+  readonly trendError = signal('');
+  readonly selectedTrendIndex = signal(0);
+  readonly selectedTrendPoint = computed(() => this.trend()?.points[this.selectedTrendIndex()] ?? null);
+  readonly chartViewportWidth = signal(0);
+  readonly chartWidth = computed(() => Math.max(720, this.chartViewportWidth(), (this.trend()?.points.length ?? 0) * 58 + 84));
+
+  ngOnChanges(): void { void this.loadTrend(this.trendPeriod()); }
+  ngOnDestroy(): void { this.requestVersion++; this.resizeObserver?.disconnect(); }
+  async selectTrendPeriod(period: ScheduleTrendPeriod): Promise<void> {
+    if (this.trendPeriod() === period && (this.trendLoading() || (this.trend()?.period === period && !this.trendError()))) return;
+    this.trendPeriod.set(period);
+    await this.loadTrend(period);
+  }
+  private async loadTrend(period: ScheduleTrendPeriod): Promise<void> {
+    const request = ++this.requestVersion;
+    this.trendLoading.set(true);
+    this.trendError.set('');
+    try {
+      const data = await this.operations.scheduleTrend(period);
+      if (request !== this.requestVersion) return;
+      this.trend.set(data);
+      this.selectedTrendIndex.set(Math.max(0, data.points.length - 1));
+    } catch (error) {
+      if (request === this.requestVersion) this.trendError.set(error instanceof Error ? error.message : '排课趋势加载失败');
+    } finally {
+      if (request === this.requestVersion) this.trendLoading.set(false);
+    }
+  }
+  selectTrendPoint(index: number): void { this.selectedTrendIndex.set(index); }
+  selectTrendPointByKey(event: KeyboardEvent, index: number): void {
+    if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.selectTrendPoint(index); }
+    if (event.key === 'ArrowLeft') { event.preventDefault(); this.selectTrendPoint(Math.max(0, index - 1)); }
+    if (event.key === 'ArrowRight') { event.preventDefault(); this.selectTrendPoint(Math.min((this.trend()?.points.length ?? 1) - 1, index + 1)); }
+  }
+  chartX(index: number): number { return 54 + index * ((this.chartWidth() - 90) / Math.max(1, (this.trend()?.points.length ?? 0) - 1)); }
+  chartY(value: number): number { return 238 - (value / Math.max(1, this.trend()?.maxValue ?? 1)) * 176; }
+  chartLine(metric: 'courseCount' | 'studentCount'): string { return (this.trend()?.points ?? []).map((point, index) => `${this.chartX(index)},${this.chartY(point[metric])}`).join(' '); }
+  gridValues(): number[] { const max = Math.max(1, this.trend()?.maxValue ?? 1); return [max, Math.round(max * .75), Math.round(max * .5), Math.round(max * .25), 0]; }
+}

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

@@ -1,15 +1,16 @@
 import {Component,inject,signal,OnInit} from '@angular/core';
 import {Component,inject,signal,OnInit} from '@angular/core';
 import {OperationsService} from '../operations.service';
 import {OperationsService} from '../operations.service';
+import {StoreOperationsTrendComponent} from '../components/store-operations-trend.component';
 import {StoreCreditAccountComponent} from '../components/store-credit-account.component';
 import {StoreCreditAccountComponent} from '../components/store-credit-account.component';
 interface StoreHome {storeId:number;storeObjectId:string;storeName:string;summary:{total:number;todayNew:number;studying:number};}
 interface StoreHome {storeId:number;storeObjectId:string;storeName:string;summary:{total:number;todayNew:number;studying:number};}
-@Component({selector:'app-store-home',standalone:true,imports:[StoreCreditAccountComponent],template:`
+@Component({selector:'app-store-home',standalone:true,imports:[StoreCreditAccountComponent,StoreOperationsTrendComponent],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(error()){<p class="error" role="alert">{{error()}}</p>}
 @if(loading()&&!data()){<p>正在读取本店数据…</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><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><app-store-operations-trend [refreshVersion]="refreshVersion()"/><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}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}}`]})
 `,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}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}}`]})
 export class StoreHomeComponent implements OnInit {
 export class StoreHomeComponent implements OnInit {
- private readonly operations=inject(OperationsService);data=signal<StoreHome|null>(null);loading=signal(false);error=signal('');
+ private readonly operations=inject(OperationsService);data=signal<StoreHome|null>(null);loading=signal(false);error=signal('');refreshVersion=signal(0);
  ngOnInit(){void this.load();}
  ngOnInit(){void this.load();}
- async load(){if(this.loading())return;this.loading.set(true);this.error.set('');try{this.data.set(await this.operations.request<StoreHome>('ops/store/home'));}catch(e){this.error.set(e instanceof Error?e.message:'本店数据加载失败');}finally{this.loading.set(false);}}
+ async load(){if(this.loading())return;this.loading.set(true);this.error.set('');try{this.data.set(await this.operations.request<StoreHome>('ops/store/home'));this.refreshVersion.update(value=>value+1);}catch(e){this.error.set(e instanceof Error?e.message:'本店数据加载失败');}finally{this.loading.set(false);}}
 }
 }

+ 1 - 1
scripts/cloud/store-scope.js

@@ -1,7 +1,7 @@
 // Store requests use an explicit capability list; new headquarters endpoints
 // Store requests use an explicit capability list; new headquarters endpoints
 // stay closed until their ownership rules have been reviewed.
 // stay closed until their ownership rules have been reviewed.
 const STORE_OPERATIONS = new Set([
 const STORE_OPERATIONS = new Set([
- 'ops/store/home','ops/live/versions','ops/users/list','ops/users/detail','ops/users/create',
+ 'ops/store/home','ops/dashboard/schedule-trend','ops/live/versions','ops/users/list','ops/users/detail','ops/users/create',
  'ops/users/update-profile','ops/users/reset-password','ops/users/save-note','ops/users/notes',
  'ops/users/update-profile','ops/users/reset-password','ops/users/save-note','ops/users/notes',
  'ops/users/audit','ops/users/export','ops/users/reveal-mobile','ops/users/change-status',
  '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/users/credit-accounts','ops/users/credit-preview','ops/users/adjust-balance','ops/users/credit-ledger',

+ 1 - 1
scripts/deploy-admin-functions.mjs

@@ -3613,7 +3613,7 @@ function scheduleTrendFromSnapshot(snapshot,period,today=businessDate()) {
 async function scheduleTrendData(context,input) { const period=text(input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),snapshot=await appointmentSnapshot(context,input,range.dateFrom);return scheduleTrendFromSnapshot(snapshot,period,today); }
 async function scheduleTrendData(context,input) { const period=text(input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),snapshot=await appointmentSnapshot(context,input,range.dateFrom);return scheduleTrendFromSnapshot(snapshot,period,today); }
 async function professionalDashboard(context,input) { const date=businessDate(),payroll=await payrollOverviewData(context,{...input,month:date.slice(0,7),storeId:0,search:'',refresh:true}),data=await dashboard(context,input,payroll),range=scheduleTrendRange('week',date),snapshot=await appointmentSnapshot(context,input,range.dateFrom),todayRows=snapshot.appointments.filter((row)=>!row.cancelled&&row.startsAt&&businessDate(row.startsAt)===date),currentLiveRows=snapshot.live.allItems.filter((row)=>row.startsAt&&businessDate(row.startsAt)>=date),coachSnapshot={...snapshot,live:{...snapshot.live,allItems:currentLiveRows},appointments:mergeAppointments(snapshot.stored,currentLiveRows)},coachPage=await professionalCoachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}},coachSnapshot,payroll),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:todayRows.length}:row.key==='coaches'?{...row,value:coachPage.total,hint:snapshot.live.available?'新系统与实时业务账号去重统计':row.hint}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:todayRows.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts,scheduleTrend:scheduleTrendFromSnapshot(snapshot,'week',date),refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message,sync:await operationalSyncIndicator(context,input)}; }
 async function professionalDashboard(context,input) { const date=businessDate(),payroll=await payrollOverviewData(context,{...input,month:date.slice(0,7),storeId:0,search:'',refresh:true}),data=await dashboard(context,input,payroll),range=scheduleTrendRange('week',date),snapshot=await appointmentSnapshot(context,input,range.dateFrom),todayRows=snapshot.appointments.filter((row)=>!row.cancelled&&row.startsAt&&businessDate(row.startsAt)===date),currentLiveRows=snapshot.live.allItems.filter((row)=>row.startsAt&&businessDate(row.startsAt)>=date),coachSnapshot={...snapshot,live:{...snapshot.live,allItems:currentLiveRows},appointments:mergeAppointments(snapshot.stored,currentLiveRows)},coachPage=await professionalCoachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}},coachSnapshot,payroll),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:todayRows.length}:row.key==='coaches'?{...row,value:coachPage.total,hint:snapshot.live.available?'新系统与实时业务账号去重统计':row.hint}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:todayRows.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts,scheduleTrend:scheduleTrendFromSnapshot(snapshot,'week',date),refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message,sync:await operationalSyncIndicator(context,input)}; }
 async function scheduleTrendFast(context,input,requestedPeriod=''){
 async function scheduleTrendFast(context,input,requestedPeriod=''){
-  const period=text(requestedPeriod||input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),companyId=companyIdOf(context,input),rows=await Psql.query('SELECT LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) AS day,COUNT(*)::int AS course_count,COUNT(*) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) IN (\'30\',\'40\'))::int AS completed_count,ARRAY_AGG(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE COALESCE(CAST(a."szyh" AS text),\'\')<>\'\') AS students FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\' AND LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) BETWEEN $2 AND $3 GROUP BY day',[companyId,range.dateFrom,range.dateTo]),byDay=new Map(rows.map((row)=>[text(row.day),row])),points=[],weekdays=['周日','周一','周二','周三','周四','周五','周六'],allStudents=new Set();
+  const period=text(requestedPeriod||input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),companyId=companyIdOf(context,input),rows=await Psql.query('SELECT LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) AS day,COUNT(*)::int AS course_count,COUNT(*) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) IN (\'30\',\'40\'))::int AS completed_count,ARRAY_AGG(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE COALESCE(CAST(a."szyh" AS text),\'\')<>\'\') AS students FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\' 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 EXISTS (SELECT 1 FROM "_User" s WHERE s.company=a.company AND s."legacyUserId"::text=a."szyh"::text AND s."identityType"=\'member\' AND s."legacyUserData"->>\'ParentUserID\'=$4 AND NOT COALESCE(s."isDeleted",false))) GROUP BY day',[companyId,range.dateFrom,range.dateTo,context.storeId?String(context.storeId):'']),byDay=new Map(rows.map((row)=>[text(row.day),row])),points=[],weekdays=['周日','周一','周二','周三','周四','周五','周六'],allStudents=new Set();
   for(let key=range.dateFrom;key<=range.dateTo;key=addBusinessDays(key,1)){const row=byDay.get(key)||{},students=Array.isArray(row.students)?row.students.map(text).filter(Boolean):[];for(const id of students)allStudents.add(id);const month=number(key.slice(5,7)),day=number(key.slice(8,10)),weekday=new Date(key+'T12:00:00+08:00').getDay();points.push({key,label:month+'月'+day+'日',shortLabel:period==='week'?weekdays[weekday]:period==='year'?month+'/'+day:day+'日',courseCount:number(row.course_count),studentCount:new Set(students).size,completedCount:number(row.completed_count),isToday:key===today});}
   for(let key=range.dateFrom;key<=range.dateTo;key=addBusinessDays(key,1)){const row=byDay.get(key)||{},students=Array.isArray(row.students)?row.students.map(text).filter(Boolean):[];for(const id of students)allStudents.add(id);const month=number(key.slice(5,7)),day=number(key.slice(8,10)),weekday=new Date(key+'T12:00:00+08:00').getDay();points.push({key,label:month+'月'+day+'日',shortLabel:period==='week'?weekdays[weekday]:period==='year'?month+'/'+day:day+'日',courseCount:number(row.course_count),studentCount:new Set(students).size,completedCount:number(row.completed_count),isToday:key===today});}
   const todayPoint=points.find((point)=>point.isToday)||{courseCount:0,studentCount:0},totalCourseCount=points.reduce((sum,point)=>sum+point.courseCount,0);return{period,periodLabel:range.periodLabel,dateFrom:range.dateFrom,dateTo:range.dateTo,points,todayCourseCount:todayPoint.courseCount,todayStudentCount:todayPoint.studentCount,totalCourseCount,totalStudentCount:allStudents.size,maxValue:Math.max(1,...points.flatMap((point)=>[point.courseCount,point.studentCount])),refreshedAt:new Date().toISOString(),liveSourceAvailable:true,liveSourceMessage:'新数据库排课投影'};
   const todayPoint=points.find((point)=>point.isToday)||{courseCount:0,studentCount:0},totalCourseCount=points.reduce((sum,point)=>sum+point.courseCount,0);return{period,periodLabel:range.periodLabel,dateFrom:range.dateFrom,dateTo:range.dateTo,points,todayCourseCount:todayPoint.courseCount,todayStudentCount:todayPoint.studentCount,totalCourseCount,totalStudentCount:allStudents.size,maxValue:Math.max(1,...points.flatMap((point)=>[point.courseCount,point.studentCount])),refreshedAt:new Date().toISOString(),liveSourceAvailable:true,liveSourceMessage:'新数据库排课投影'};
 }
 }

+ 30 - 0
scripts/tests/store-scope.test.mjs

@@ -119,3 +119,33 @@ test('门店预约使用真实数值关联字段,并在查询内过滤门店
  assert.deepEqual(plain(await c.storeAppointmentRows(ctx,{},'2026-10-01','2026-10-31')).map(r=>r.targetId),['a1']);
  assert.deepEqual(plain(await c.storeAppointmentRows(ctx,{},'2026-10-01','2026-10-31')).map(r=>r.targetId),['a1']);
  }finally{await db.close();}
  }finally{await db.close();}
 });
 });
+
+test('门店运营趋势按服务器身份隔离,去重学员、补零并排除取消和已删除学生',async()=>{
+ const db=new PGlite();try{await db.exec(`
+ CREATE TABLE "CommonModel"(company text,"itemId" numeric,"modelId" numeric,status numeric);
+ CREATE TABLE "CourseAppointment"(id numeric,company text,szyh text,yysj text,bxrq text,dszt text);
+ CREATE TABLE "_User"(company text,"legacyUserId" numeric,"identityType" text,"legacyUserData" jsonb,"isDeleted" boolean);
+ INSERT INTO "_User" VALUES ('co',20,'member','{"ParentUserID":10}',false),('co',21,'member','{"ParentUserID":11}',false),('co',22,'member','{"ParentUserID":10}',true),('other',20,'member','{"ParentUserID":10}',false);
+ INSERT INTO "CourseAppointment" VALUES
+ (1,'co','20','2026-10-20 10:00',null,'30'),(2,'co','20','2026-10-20 11:00',null,'20'),
+ (3,'co','21','2026-10-20 12:00',null,'30'),(4,'co','22','2026-10-20 13:00',null,'30'),
+ (5,'co','20','2026-10-20 14:00',null,'30'),(6,'other','20','2026-10-20 15:00',null,'30'),
+ (7,'co','20','2026-10-01 10:00',null,'40'),(8,'co','20','2026-01-01 10:00',null,'30');
+ INSERT INTO "CommonModel" SELECT company,id,54,CASE WHEN id=5 THEN -2 ELSE 99 END FROM "CourseAppointment";
+ `);
+ const c=env();c.businessDate=value=>value?new Date(value).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'2026-10-20';
+ c.Psql={query:async(q,args)=>(await db.query(q,args)).rows};
+ for(const [period,count,days] of [['week',2,7],['month',3,20],['year',4,293]]){
+  const input={operation:'ops/dashboard/schedule-trend',period,storeId:11,_storeId:11,filters:{parentUserId:11}};
+  await c.prepareStoreRequest(ctx,input,{});const result=await c.scheduleTrendFast(ctx,input);
+  assert.equal(result.totalCourseCount,count);assert.equal(result.totalStudentCount,1);assert.equal(result.points.length,days);
+  assert.equal(result.todayStudentCount,1);assert.equal(result.todayCourseCount,2);assert.equal(result.points.at(-1).completedCount,1);
+  assert.ok(result.points.some(p=>p.courseCount===0));
+ }
+ const other=await c.scheduleTrendFast({...ctx,storeId:11},{period:'week'});assert.equal(other.totalCourseCount,1);
+ const empty=await c.scheduleTrendFast({...ctx,storeId:12},{period:'week'});assert.equal(empty.totalCourseCount,0);assert.equal(empty.maxValue,1);
+ const headquarters=await c.scheduleTrendFast({...ctx,storeId:0},{period:'week'});assert.equal(headquarters.totalCourseCount,4);
+ await assert.rejects(c.scheduleTrendFast(ctx,{period:'invalid'}),/无效/);
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/dashboard/summary'},{}),/无权/);
+ }finally{await db.close();}
+});