Ver código fonte

feat(payroll): align course taxonomy and wage standards

彭峰 1 mês atrás
pai
commit
7942ef77a4

+ 6 - 1
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -610,7 +610,7 @@ export interface ScheduleCalendarData {
   appointments: ScheduleAppointment[];
   unassigned: ScheduleAppointment[];
   members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string; liveOnly?: boolean; writeReady?: boolean; readOnly?: boolean; readOnlyReason?: string }>;
-  courses: Array<{ courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean; categoryKey?: string; categoryName?: string; mappingConfirmed?: boolean; suggestedCategoryName?: string; supportedDurations?: number[] }>;
+  courses: Array<{ courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean; stageKey?: TeachingStageKey | ''; stageName?: string; categoryKey?: string; categoryName?: string; mappingConfirmed?: boolean; suggestedCategoryName?: string; supportedDurations?: number[] }>;
   sync?: OperationsSyncIndicator;
 }
 
@@ -664,6 +664,7 @@ export interface PayRate extends Record<string, unknown> {
 
 export type TeachingDeliveryMode = 'online' | 'offline';
 export type TeachingCreditAccount = 'Purse' | 'SilverCoin' | 'UserExp' | 'UserPoint';
+export type TeachingStageKey = 'primary' | 'middle' | 'high' | 'other';
 
 export interface TeachingCourseCategory extends Record<string, unknown> {
   objectId: string;
@@ -700,10 +701,14 @@ export interface CourseTeachingMapping extends Record<string, unknown> {
   objectId: string;
   courseId: number;
   courseName: string;
+  stageKey: TeachingStageKey | '';
+  stageName: string;
   categoryKey: string;
   categoryName: string;
   suggestedCategoryKey: string;
   suggestedCategoryName: string;
+  categoryConfirmed: boolean;
+  stageConfirmed: boolean;
   confirmed: boolean;
   updatedAt: string;
 }

+ 5 - 5
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html

@@ -18,13 +18,13 @@
   <section class="standards-summary"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
   <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button></nav>
   @if(standardsSection()==='rules'){
-   <section class="content-card"><header><div><strong>工资与课时规则</strong><span>按课程分类、时长、线上/线下和生效日期配置;已审核工资永久使用原快照</span></div></header>
-   @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="ruleCategoryKey.set($event)">@for(category of config.categories;track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)"><option [ngValue]="30">30 分钟</option><option [ngValue]="60">60 分钟</option></select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
-   <div class="table-scroll"><table class="data-table standards-table"><thead><tr><th>课程分类</th><th>时长</th><th>方式</th><th>教师工资</th><th>课时扣减</th><th>生效日期</th><th>版本</th><th>操作</th></tr></thead><tbody>@for(rule of config.rules;track rule.objectId){<tr><td><strong>{{rule.categoryName}}</strong></td><td>{{rule.durationMinutes}}分钟</td><td><span class="delivery-badge" [class.offline]="rule.deliveryMode==='offline'">{{rule.deliveryLabel}}</span></td><td class="money">¥{{rule.teacherPay|number:'1.2-2'}}</td><td>{{creditSummary(rule)}}</td><td>{{rule.effectiveFrom}}</td><td><span class="badge" [class.warn]="!rule.active">{{rule.active?'生效':'停用'}}</span><small>{{rule.isDefault?'初始标准':'管理员版本'}}</small></td><td>@if(isManager()&&!rule.isDefault&&rule.active){<button class="ui-button danger compact" type="button" (click)="deactivateTeachingRule(rule)" [disabled]="saving()">停用</button>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
+   <section class="content-card"><header><div><strong>当前课程工资标准</strong><span>按最新价目表统一为课程分类、时长、线上和线下单价;新版本从生效日期起计算,历史工资仍使用原规则</span></div></header>
+   @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="updateRuleCategory($event)">@for(category of activeCategories();track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)">@for(duration of ruleDurations();track duration){<option [ngValue]="duration">{{duration}} 分钟</option>}</select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
+   <div class="table-scroll"><table class="data-table standards-table rate-matrix"><thead><tr><th>课程分类</th><th>课程时长</th><th>线上单价</th><th>线下单价</th><th>课时扣减</th><th>当前版本</th><th>操作</th></tr></thead><tbody>@for(row of standardMatrix();track row.categoryKey+':'+row.durationMinutes){<tr><td><strong>{{row.categoryName}}</strong></td><td>{{row.durationMinutes}}分钟</td><td class="money">¥{{row.online?.teacherPay|number:'1.2-2'}}</td><td class="money offline-price">¥{{row.offline?.teacherPay|number:'1.2-2'}}</td><td>{{row.online?creditSummary(row.online):row.offline?creditSummary(row.offline):'—'}}</td><td>{{row.online?.effectiveFrom||row.offline?.effectiveFrom}}<small>{{row.online?.isDefault&&row.offline?.isDefault?'系统价目':'管理员版本'}}</small></td><td>@if(isManager()){<span class="matrix-actions"><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'online')">编辑线上</button><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'offline')">编辑线下</button></span>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
   } @else if(standardsSection()==='mappings'){
-   <section class="content-card"><header><div><strong>课程分类映射</strong><span>未确认映射的课程不允许新增排课,系统建议仅供管理员确认</span></div><label class="mapping-search"><span class="sr-only">搜索课程</span><input [ngModel]="mappingSearch()" (ngModelChange)="mappingSearch.set($event)" placeholder="搜索课程名称或 ID" /></label></header><div class="table-scroll"><table class="data-table mapping-table"><thead><tr><th>课程</th><th>系统建议</th><th>确认分类</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of filteredMappings();track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId}}</small></td><td>{{row.suggestedCategoryName}}</td><td><select [ngModel]="mappingSelections()[row.courseId]" (ngModelChange)="setMapping(row.courseId,$event)">@for(category of config.categories;track category.key){<option [value]="category.key">{{category.name}}</option>}</select></td><td><span class="badge" [class.warn]="!row.confirmed">{{row.confirmed?'已确认':'待确认'}}</span></td><td>@if(isManager()){<button class="ui-button secondary compact" type="button" (click)="confirmMapping(row)" [disabled]="saving()">{{row.confirmed?'更新':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="5"><div class="empty">没有匹配的课程</div></td></tr>}</tbody></table></div></section>
+   <section class="content-card"><header><div><strong>历史课程映射</strong><span>课程大类由数据库的课程层级自动读取;管理员只需确认具体课程分类</span></div><label class="mapping-search"><span class="sr-only">搜索课程</span><input [ngModel]="mappingSearch()" (ngModelChange)="mappingSearch.set($event)" placeholder="搜索课程、学段、分类或 ID" /></label></header><div class="table-scroll"><table class="data-table mapping-table"><thead><tr><th>历史课程</th><th>系统建议</th><th>数据库大类</th><th>课程分类</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of filteredMappings();track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId}}</small></td><td>{{row.suggestedCategoryName}}</td><td><span class="stage-badge" [class.missing]="!row.stageConfirmed">{{row.stageName||'数据库未归类'}}</span></td><td><select [ngModel]="mappingSelections()[row.courseId]" (ngModelChange)="setMapping(row.courseId,$event)" [disabled]="!row.stageConfirmed">@if(mappingCategoryUnavailable(row)){<option [value]="row.categoryKey" disabled>{{row.categoryName||'旧分类'}}(不再使用)</option>}@for(category of mappingCategories(row);track category.key){<option [value]="category.key">{{category.name}}</option>}</select></td><td><span class="badge" [class.warn]="!row.confirmed">{{row.confirmed?'已确认':!row.stageConfirmed?'数据库大类缺失':'待确认'}}</span></td><td>@if(isManager()){<button class="ui-button secondary compact" type="button" (click)="confirmMapping(row)" [disabled]="saving()||!row.stageConfirmed">{{row.confirmed?'更新':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6"><div class="empty">没有匹配的课程</div></td></tr>}</tbody></table></div></section>
   } @else if(standardsSection()==='categories') {
-   <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类编码永久稳定;管理员可调整显示名称或停用分类,历史数据不受影响</span></div></header><div class="category-grid">@for(category of config.categories;track category.key){<article><span>{{category.key}}</span>@if(isManager()){<input [ngModel]="categoryNames()[category.key]" (ngModelChange)="setCategoryName(category.key,$event)" [disabled]="saving()" aria-label="课程分类显示名称" /><div class="category-actions"><button class="ui-button secondary compact" type="button" (click)="saveCategory(category)" [disabled]="saving()">保存名称</button><button class="ui-button compact" [class.danger]="category.active" type="button" (click)="saveCategory(category,!category.active)" [disabled]="saving()">{{category.active?'停用':'恢复'}}</button></div>}@else{<strong>{{category.name}}</strong>}<small>{{category.active?'使用中':'已停用'}}</small></article>}</div></section>
+   <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类名称与价目表完全一致,不再显示或使用旧分类</span></div></header><div class="stage-overview" aria-label="数据库课程大类">@for(stage of stageOptions;track stage.key){<span>{{stage.name}}·数据库大类</span>}</div><div class="category-grid">@for(category of activeCategories();track category.key){<article><span>{{category.key}}</span><strong>{{category.name}}</strong><small>固定分类</small></article>}</div></section>
   } @else if(standardsSection()==='reservations') {
    <section class="content-card reconciliation-card"><header><div><strong>历史未来预约课时预留</strong><span>按预约时间顺序核对现有余额;确认后仅补建预留,不扣减实际课时</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReservations()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reservationReconciliation()?.summary?.total){<button class="ui-button primary" type="button" (click)="commitReservations()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认补建预留</button>}</div></header>
    @if(reservationReconciliation();as result){<div class="reconciliation-summary"><article><span>待核对预约</span><strong>{{result.summary.total}}</strong></article><article><span>可补建预留</span><strong>{{result.summary.ready}}</strong></article><article><span>待补课时</span><strong>{{result.summary.insufficient}}</strong></article><article><span>映射/数据异常</span><strong>{{result.summary.exceptions}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员</th><th>课程</th><th>预约时间</th><th>标准</th><th>课时预留</th><th>处理结果</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentName}}<small>ID {{row.studentId}}</small></td><td>{{row.courseName}}</td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{row.categoryName||'未映射'}}<small>{{row.durationMinutes}}分钟 · {{row.deliveryLabel}}</small></td><td>@for(cost of row.creditCosts;track cost.account){<span>{{cost.label}} {{cost.amount}}</span>}</td><td><span class="badge" [class.warn]="row.status!=='ready'">{{row.statusLabel}}</span><small>{{row.reason}}</small></td></tr>}@empty{<tr><td colspan="7"><div class="empty">所有未来预约均已建立课时预留</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对历史未来预约……</div>}

+ 11 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss

@@ -324,10 +324,19 @@
 .credit-preview span { color:#71837d;font-size:11px; }
 .credit-preview strong { margin-top:4px;color:#23493e;font-size:12px; }
 .standards-table td,.mapping-table td { white-space:nowrap; }
+.rate-matrix th:nth-child(3),.rate-matrix td:nth-child(3) { background:#f3faf7; }
+.rate-matrix th:nth-child(4),.rate-matrix td:nth-child(4) { background:#fff8ed; }
+.rate-matrix .offline-price { color:#9b6417; }
+.matrix-actions { display:flex;gap:6px; }
 .delivery-badge { display:inline-flex;padding:4px 9px;border-radius:999px;color:#156c54;background:#e9f8f2;font-size:12px;font-weight:750; }
 .delivery-badge.offline { color:#855411;background:#fff2d9; }
 .mapping-search { width:min(340px,100%); }
 .mapping-search input,.mapping-table select { min-height:42px;border:1px solid #d7e3df;border-radius:11px;background:#fff; }
+.mapping-table select { min-width:138px;padding:0 34px 0 11px; }
+.stage-badge { display:inline-flex;min-height:32px;align-items:center;padding:0 11px;border:1px solid #b9dace;border-radius:999px;color:#176c55;background:#edf8f4;font-size:12px;font-weight:800; }
+.stage-badge.missing { border-color:#efc7c2;color:#b42318;background:#fff2f0; }
+.stage-overview { display:flex;gap:10px;padding:18px 18px 0; }
+.stage-overview span { display:inline-flex;min-width:92px;min-height:38px;align-items:center;justify-content:center;border:1px solid #b9dace;border-radius:999px;color:#176c55;background:#edf8f4;font-weight:800; }
 .category-grid { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:18px; }
 .category-grid article { padding:18px;border:1px solid #dce7e3;border-radius:14px;background:#fbfdfc; }
 .category-grid span,.category-grid strong,.category-grid small { display:block; }
@@ -447,6 +456,8 @@
     display: grid;
     grid-template-columns: 1fr;
   }
+
+  .stage-overview { overflow-x:auto; }
 }
 
 @media (max-width: 420px) {

+ 40 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts

@@ -0,0 +1,40 @@
+import { TeachingCourseCategory, TeachingRule } from '../admin.models';
+import { TEACHING_STAGE_OPTIONS, teachingRateMatrix } from './operations-payroll.component';
+
+describe('operations payroll teaching standards', () => {
+  it('uses the three standard school stages when presenting database course ancestry', () => {
+    expect(TEACHING_STAGE_OPTIONS.map((stage) => stage.name)).toEqual(['小学', '初中', '高中']);
+  });
+
+  it('builds the current online and offline price matrix without using an older version', () => {
+    const categories = [
+      { key: 'trial', name: '体验课', sortOrder: 10, active: true },
+      { key: 'word_self_study', name: '单词自学课', sortOrder: 20, active: true },
+    ] as TeachingCourseCategory[];
+    const rule = (categoryKey: string, deliveryMode: 'online' | 'offline', teacherPay: number, effectiveFrom: string): TeachingRule => ({
+      objectId: `${categoryKey}:${deliveryMode}:${effectiveFrom}`,
+      categoryKey,
+      categoryName: categories.find((category) => category.key === categoryKey)?.name || categoryKey,
+      durationMinutes: 60,
+      deliveryMode,
+      deliveryLabel: deliveryMode === 'online' ? '线上' : '线下',
+      teacherPay,
+      creditCosts: [],
+      effectiveFrom,
+      active: true,
+      updatedAt: '',
+    });
+    const matrix = teachingRateMatrix([
+      rule('trial', 'online', 40, '2026-09-01'),
+      rule('trial', 'online', 30, '2026-09-09'),
+      rule('trial', 'offline', 40, '2026-09-09'),
+      rule('word_self_study', 'online', 20, '2026-09-09'),
+      rule('word_self_study', 'offline', 30, '2026-09-09'),
+    ], categories, '2026-09-09');
+
+    expect(matrix.map((row) => [row.categoryName, row.durationMinutes, row.online?.teacherPay, row.offline?.teacherPay])).toEqual([
+      ['体验课', 60, 30, 40],
+      ['单词自学课', 60, 20, 30],
+    ]);
+  });
+});

Diferenças do arquivo suprimidas por serem muito extensas
+ 6 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -114,12 +114,12 @@
         @if(activeSearch()==='course'){
           <span class="search-results" id="course-search-results" role="listbox">
             @for(course of filteredCourses();track course.courseId){
-              <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId" (click)="chooseCourse(course)"><span><strong>{{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?course.categoryName:'待确认分类'}}</small></span></button>
+              <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId" (click)="chooseCourse(course)"><span><strong>{{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?(course.stageName+' · '+course.categoryName):'待处理课程分类'}}</small></span></button>
             } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
           </span>
         }
       </label>
-      <label class="type-step">课程标准<select [ngModel]="durationMinutes()" (ngModelChange)="updateDuration(+$event)" [disabled]="!selectedCourse()?.mappingConfirmed">@for(duration of selectedCourse()?.supportedDurations||[30,60];track duration){<option [ngValue]="duration">{{selectedCourse()?.categoryName||'待配置'}} · {{duration}}分钟</option>}</select>@if(selectedCourse()&&!selectedCourse()?.mappingConfirmed){<small class="exception">该课程尚未确认分类,不能排课</small>}</label>
+      <label class="type-step">课程标准<select [ngModel]="durationMinutes()" (ngModelChange)="updateDuration(+$event)" [disabled]="!selectedCourse()?.mappingConfirmed">@for(duration of selectedCourse()?.supportedDurations||[30,60];track duration){<option [ngValue]="duration">{{selectedCourse()?.stageName||'数据库大类缺失'}} · {{selectedCourse()?.categoryName||'待映射'}} · {{duration}}分钟</option>}</select>@if(selectedCourse()&&!selectedCourse()?.mappingConfirmed){<small class="exception">该课程尚未完成分类映射,或数据库课程大类缺失,不能排课</small>}</label>
       <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
       <app-operations-date-filter class="date-step" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
       <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>

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

@@ -157,7 +157,7 @@ export class OperationsScheduleComponent implements OnInit {
   updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.openSearch('course');}
   updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
   chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId());this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);if(selectedCourse&&!scheduleCourseAvailable(selectedCourse,member.userId)){this.courseId.set(0);this.courseSearch.set('');}this.activeSearch.set(null);this.preview.set(null);}
-  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该课程还未确认标准分类,需要先在工资结算的“排课与工资标准”中确认。');}
+  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该课程尚未完成标准分类映射,或数据库课程大类缺失,需要先在工资结算的“排课与工资标准”中处理。');}
   chooseCoach(coach:ScheduleCalendarData['coaches'][number]):void{this.coachId.set(coach.userId);this.coachSearch.set(`${coach.displayName}${coach.mobileMasked?` · ${coach.mobileMasked}`:''}`);this.activeSearch.set(null);this.preview.set(null);}
   clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
   updateDraftDate(value:string):void{if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return;this.date.set(value);this.preview.set(null);void this.load();}

Diferenças do arquivo suprimidas por serem muito extensas
+ 13 - 8
scripts/deploy-admin-functions.mjs


+ 6 - 2
scripts/migrate-review-rewards.mjs

@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 import { readFileSync } from 'node:fs';
 import { randomBytes } from 'node:crypto';
+import { request as httpsRequest } from 'node:https';
 const sql=readFileSync(new URL('./sql/review-rewards.sql',import.meta.url),'utf8');
 if(!process.argv.includes('--apply')) {
   console.log('只读检查:复习奖励迁移脚本已就绪。使用 --apply 执行;历史已完成记录只生成零金额待核对凭据。');
@@ -14,6 +15,9 @@ async function request(path,options={}) {
   const res=await fetch(parseUrl+path,{...options,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});
   const data=await res.json();if(!res.ok||data.error)throw new Error(data.error||`HTTP ${res.status}`);return data;
 }
+function invokeMigration(url,token) {
+  return new Promise((resolve,reject)=>{const body=JSON.stringify({token,params:{}}),req=httpsRequest(url,{method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},(res)=>{let raw='';res.setEncoding('utf8');res.on('data',(chunk)=>raw+=chunk);res.on('end',()=>{try{resolve({ok:Number(res.statusCode)>=200&&Number(res.statusCode)<300,data:JSON.parse(raw)})}catch(error){reject(error)}});});req.on('error',reject);req.write(body);req.end();});
+}
 // Register only the new API-visible payroll fields before installing physical constraints.
 for(const [name,fields] of Object.entries({PayrollLine:{reviewRewardId:{type:'String'},reviewTaskId:{type:'String'},plannedDate:{type:'String'}},PayrollBatch:{reviewCount:{type:'Number'},reviewAmount:{type:'Number'}}})) {
   const schema=await request('/schemas/'+name),missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields[key]));
@@ -28,8 +32,8 @@ try {
   if(!account.sessionToken)throw new Error('临时管理员没有会话');
   const code=`async function handler(request,response){try{const current=request.user;if(!current)return response.status(401).json({success:false});await current.fetch({useMasterKey:true});if(current.id!==${JSON.stringify(userId)}||current.get('adminRoleKey')!=='super-admin')return response.status(403).json({success:false});await Psql.none(${JSON.stringify(sql)});const counts=await Psql.query('SELECT "status",COUNT(*)::int AS count,SUM("amount") AS amount FROM "ReviewReward" GROUP BY "status"');return response.json({success:true,counts});}catch(error){return response.status(500).json({success:false,message:String(error.message||error)});}}`;
   const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'复习奖励一次性数据库迁移',enabled:true,params:[],paramList:[],respType:'json'})});functionId=fn.objectId;
-  const res=await fetch(parseUrl.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:account.sessionToken,params:{}})});
-  const data=await res.json();if(!res.ok||!data.success)throw new Error(data.message||'迁移失败');console.log(JSON.stringify(data,null,2));
+  const res=await invokeMigration(parseUrl.replace(/\/parse$/,'/api/functions/')+path,account.sessionToken);
+  if(!res.ok||!res.data.success)throw new Error(res.data.message||'迁移失败');console.log(JSON.stringify(res.data,null,2));
 } finally {
   // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind.
   const failures=[];

+ 20 - 6
scripts/smoke-operations-functions.mjs

@@ -97,11 +97,9 @@ try {
     const deniedCourse = await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberWithUnboundCourse.member.userId, courseId: memberWithUnboundCourse.course.courseId, coachId: 0, classType: 1, date: schedule.dataDateTo || today, startTime: '09:00', recurrence: 'once', occurrences: 1 } }, 409);
     if (!String(deniedCourse.message || '').includes('未绑定到当前会员')) throw new Error('排课预检没有阻止选择其他会员的课程');
   }
-  const memberForPreflight = schedule.members.find((member) => Number(member.userId) > 0 && !member.readOnly && ((Number(member.periods30) >= 2 && Number(member.userPoint) >= 1) || (Number(member.periods60) >= 2 && Number(member.userPoint) >= 2) || Number(member.userPoint) >= 2));
-  const boundCourseForPreflight = memberForPreflight && schedule.courses.find((course) => Number(course.courseId) > 0 && (course.availableToAll === true || course.memberIds.includes(memberForPreflight.userId)));
-  if (memberForPreflight && boundCourseForPreflight) {
-    const classType = Number(memberForPreflight.periods30) >= 2 && Number(memberForPreflight.userPoint) >= 1 ? 1 : Number(memberForPreflight.periods60) >= 2 && Number(memberForPreflight.userPoint) >= 2 ? 2 : 4;
-    const preflight = (await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberForPreflight.userId, courseId: boundCourseForPreflight.courseId, coachId: 0, classType, date: '2099-01-05', startTime: '09:00', recurrence: 'weekly', occurrences: 2 } })).data;
+  const preflightPair = schedule.members.filter((member) => Number(member.userId) > 0 && !member.readOnly).flatMap((member) => schedule.courses.filter((course) => Number(course.courseId) > 0 && course.mappingConfirmed && (course.availableToAll === true || course.memberIds.includes(member.userId))).map((course) => { const classType = course.categoryKey === 'trial' && Number(member.trialPeriods) >= 2 ? 3 : course.supportedDurations?.includes(30) && Number(member.periods30) >= 2 && Number(member.userPoint) >= 1 ? 1 : course.supportedDurations?.includes(60) && Number(member.periods60) >= 2 && Number(member.userPoint) >= 2 ? 2 : 0; return { member, course, classType }; })).find((item) => item.classType);
+  if (preflightPair) {
+    const preflight = (await ops({ operation: 'ops/appointments/preflight', payload: { studentId: preflightPair.member.userId, courseId: preflightPair.course.courseId, coachId: 0, classType: preflightPair.classType, date: '2099-01-05', startTime: '09:00', recurrence: 'weekly', occurrences: 2 } })).data;
     if (!Array.isArray(preflight.candidates) || !Array.isArray(preflight.conflicts) || !Array.isArray(preflight.conflictIndexes) || typeof preflight.issueCount !== 'number' || preflight.availableCount + preflight.conflictCount !== preflight.candidates.length || preflight.conflictCount !== preflight.conflictIndexes.length) throw new Error('批量排课逐项预检结构或按课次去重统计无效');
   }
   if (!schedule.totalAppointments || !schedule.dataDateFrom || !schedule.dataDateTo) throw new Error(`历史排课总量或数据日期范围缺失:${JSON.stringify({ keys: Object.keys(schedule), totalAppointments: schedule.totalAppointments, dataDateFrom: schedule.dataDateFrom, dataDateTo: schedule.dataDateTo })}`);
@@ -116,6 +114,21 @@ try {
     if (!rates.items?.some((item) => item.classType === expected.type && item.amount === expected.amount && item.durationHours === expected.hours)) throw new Error(`原系统课酬规则缺失:课型 ${expected.type}`);
   }
   if (rates.items?.some((item) => item.classType === 4 && item.isDefault)) throw new Error('课型4不应存在默认工资规则');
+  const teachingStandards = (await ops({ operation: 'ops/teaching-rules/list' })).data;
+  const expectedTeachingMatrix = [
+    ['word', 30, 15, 20], ['word', 60, 30, 40], ['trial', 60, 30, 40],
+    ['primary_writing', 30, 20, 25], ['middle_writing', 30, 25, 30], ['high_writing', 30, 32.5, 37.5],
+    ['primary_writing', 60, 40, 50], ['middle_writing', 60, 50, 60], ['high_writing', 60, 65, 75],
+    ['word_self_study', 60, 20, 30],
+  ];
+  const currentRule = (categoryKey, durationMinutes, deliveryMode) => teachingStandards.rules
+    .filter((rule) => rule.active && rule.categoryKey === categoryKey && rule.durationMinutes === durationMinutes && rule.deliveryMode === deliveryMode && rule.effectiveFrom <= today)
+    .sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom))[0];
+  for (const [categoryKey, durationMinutes, onlinePay, offlinePay] of expectedTeachingMatrix) {
+    if (currentRule(categoryKey, durationMinutes, 'online')?.teacherPay !== onlinePay || currentRule(categoryKey, durationMinutes, 'offline')?.teacherPay !== offlinePay) throw new Error(`课程工资标准不符合新价目表:${categoryKey} ${durationMinutes}分钟`);
+  }
+  const databaseStages = new Set(teachingStandards.mappings.map((item) => item.stageName));
+  if (!teachingStandards.mappings.every((item) => item.stageConfirmed && item.stageName) || !['小学', '初中', '高中'].every((name) => databaseStages.has(name)) || teachingStandards.mappings.some((item) => item.confirmed && !item.categoryKey)) throw new Error('历史课程没有原样返回数据库大类');
   const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
   if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
   const payrollOverview = (await ops({ operation: 'ops/payroll/overview', month: '2026-08', storeId: 0, search: '', refresh: true })).data;
@@ -164,7 +177,8 @@ try {
   const learningReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09' }, refresh: true })).data;
   if (!Array.isArray(learningReports.items) || typeof learningReports.total !== 'number' || !learningReports.summary || !learningReports.sourceStatus) throw new Error('学习报表列表、摘要或同步状态结构无效');
   if (learningReports.sourceStatus.state === 'not_configured') throw new Error('学习记录持续同步桥尚未配置,无法保证报表包含最新数据');
-  if (!learningReports.items.some((item) => Number(item.generalId) === 855751) || !learningReports.items.some((item) => String(item.studyDate || '') >= '2026-09-04')) throw new Error(`学习报表没有同步旧系统 2026-09-04 样本:${JSON.stringify({ total: learningReports.total, sourceStatus: learningReports.sourceStatus, ids: learningReports.items.map((item) => item.generalId) })}`);
+  const historicalReport = (await ops({ operation: 'ops/learning-reports/detail', targetId: '855751' })).data;
+  if (Number(historicalReport.generalId) !== 855751 || !learningReports.items.some((item) => String(item.studyDate || '') >= '2026-09-04')) throw new Error(`学习报表没有同步旧系统样本或最新记录:${JSON.stringify({ total: learningReports.total, sourceStatus: learningReports.sourceStatus, historicalId: historicalReport.generalId, ids: learningReports.items.map((item) => item.generalId) })}`);
   const studentReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '熊诗琦', filters: { month: '2026-09' } })).data;
   if (!studentReports.total || studentReports.items.some((item) => !String(item.studentName || '').includes('熊诗琦'))) throw new Error(`学习报表学员姓名筛选无效:${JSON.stringify({ total: studentReports.total, names: studentReports.items.map((item) => item.studentName) })}`);
   const coachReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09', coachSearch: '樊颖' } })).data;

+ 56 - 4
scripts/sql/review-rewards.sql

@@ -72,11 +72,63 @@ BEGIN
  ON CONFLICT ("company","taskId") DO NOTHING;
 END $$;
 -- Backfill creates zero-value, non-payable evidence; rerunning migration cannot award it.
-DO $$ DECLARE task "MemoryPracticeRecord"; BEGIN
- FOR task IN SELECT * FROM "MemoryPracticeRecord" WHERE "fxzt"::text='1' AND "company" IS NOT NULL LOOP
-   PERFORM xiaoshu_record_review_reward(task,true);
- END LOOP;
+-- Use one set-based statement so the full history is scanned once instead of issuing
+-- several lookup queries for every completed task.
+CREATE OR REPLACE FUNCTION xiaoshu_safe_shanghai_timestamp(value text) RETURNS timestamptz
+LANGUAGE plpgsql IMMUTABLE AS $$
+BEGIN
+ RETURN NULLIF(value,'')::timestamp AT TIME ZONE 'Asia/Shanghai';
+EXCEPTION WHEN OTHERS THEN RETURN NULL;
 END $$;
+WITH users AS MATERIALIZED (
+ SELECT DISTINCT ON ("company",COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID'))
+  "company",COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID') AS legacy_id,
+  "objectId","realName","nickname","username","legacyUserData"
+ FROM "_User"
+ WHERE COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID') IS NOT NULL
+ ORDER BY "company",COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID'),"objectId"
+), studies AS MATERIALIZED (
+ SELECT DISTINCT "company","generalId"::text AS general_id
+ FROM "CommonModel" WHERE "modelId"::text='56'
+), courses AS MATERIALIZED (
+ SELECT DISTINCT ON ("company","nodeId"::text) "company","nodeId"::text AS node_id,"nodeName"
+ FROM "Node" ORDER BY "company","nodeId"::text,"nodeName"
+), facts AS (
+ SELECT task.*,xiaoshu_safe_shanghai_timestamp(task."wcsj"::text) AS finished,
+  coach."objectId" AS coach_object_id,coach."realName" AS coach_real_name,coach."nickname" AS coach_nickname,
+  coach."username" AS coach_username,coach."legacyUserData" AS coach_legacy,
+  student."objectId" AS student_object_id,student."realName" AS student_real_name,student."nickname" AS student_nickname,
+  student."username" AS student_username,student."legacyUserData" AS student_legacy,
+  store."realName" AS store_real_name,store."nickname" AS store_nickname,store."username" AS store_username,
+  store."legacyUserData" AS store_legacy,study.general_id AS study_general_id,course."nodeName" AS course_name
+ FROM "MemoryPracticeRecord" task
+ LEFT JOIN users coach ON coach."company"=task."company" AND coach.legacy_id=task."plid"::text
+ LEFT JOIN users student ON student."company"=task."company" AND student.legacy_id=task."yhid"::text
+ LEFT JOIN users store ON store."company"=task."company" AND store.legacy_id=coach."legacyUserData"->>'ParentUserID'
+ LEFT JOIN studies study ON study."company"=task."company" AND study.general_id=task."xxjlid"::text
+ LEFT JOIN courses course ON course."company"=task."company" AND course.node_id=task."kcid"::text
+ WHERE task."fxzt"::text='1' AND task."company" IS NOT NULL
+), prepared AS (
+ SELECT facts.*,
+  CASE WHEN "rewardDuplicate" THEN '来源复习任务重复'
+   WHEN "rewardTaskKey" IS NULL OR study_general_id IS NULL THEN '缺少有效来源学习记录或计划日期'
+   WHEN coach_object_id IS NULL OR COALESCE("plid"::text,'0') !~ '^[1-9][0-9]*$' THEN '缺少有效复习老师'
+   WHEN student_object_id IS NULL THEN '缺少有效学员'
+   WHEN finished IS NULL THEN '缺少有效完成时间' ELSE '' END AS problem
+ FROM facts
+)
+INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception")
+SELECT md5("company"||':review:'||"objectId"),"company","objectId","rewardTaskKey",
+ CASE WHEN "plid"::text ~ '^[1-9][0-9]*$' THEN "plid"::double precision ELSE 0 END,
+ COALESCE(NULLIF(coach_real_name,''),NULLIF(coach_nickname,''),coach_legacy->>'HoneyName',coach_username,'未识别老师'),
+ CASE WHEN "yhid"::text ~ '^[1-9][0-9]*$' THEN "yhid"::double precision ELSE 0 END,
+ COALESCE(NULLIF(student_real_name,''),NULLIF(student_nickname,''),student_legacy->>'HoneyName',student_username,'未识别学员'),COALESCE(course_name,''),
+ CASE WHEN coach_legacy->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach_legacy->>'ParentUserID')::double precision ELSE 0 END,
+ COALESCE(NULLIF(store_real_name,''),NULLIF(store_nickname,''),store_legacy->>'HoneyName',store_username,'未绑定门店'),
+ COALESCE(left("kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),0,
+ CASE WHEN problem='' THEN 'historical' ELSE 'exception' END,problem
+FROM prepared
+ON CONFLICT ("company","taskId") DO NOTHING;
 
 CREATE OR REPLACE FUNCTION xiaoshu_review_before_write() RETURNS trigger LANGUAGE plpgsql AS $$
 BEGIN

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff