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

feat(payroll): settle review rewards once per scheduled task

彭峰 1 месяц назад
Родитель
Сommit
00decdaa12

+ 32 - 0
docs/features/review-rewards.md

@@ -0,0 +1,32 @@
+# 复习奖励与工资结算
+
+## 业务口径
+
+每条计划复习任务首次完成奖励 1 元,归属任务绑定的复习老师。重复学习不限制;重复完成不新增奖励,也不改写首次完成时间、老师或收入月份。月份使用 Asia/Shanghai。不同计划日期的任务独立计薪。
+
+历史已完成记录生成零金额 `historical` 凭据,异常数据生成零金额 `exception` 凭据,均不自动补发。上线时尚未完成的任务首次完成后生成 `earned` 凭据。上线后新导入且已经完成的记录仍视为历史待核对。
+
+## 数据与接口
+
+- `ReviewReward` 是 SQL 专用不可变账本,不注册为开放的 Parse class。按帐套与任务 objectId 唯一;有效奖励另按来源计划唯一。快照包括老师、学生、门店、课程、计划日期、完成时间和金额。
+- 数据库触发器在复习状态更新的同一事务中写凭据;任何写入失败都会回滚完成状态。旧 `content_update` 的 Model 60 完成动作调用 `xiaoshu_complete_review`,只接受 `fxzt=1` 与兼容的 `wcsj` 字段,实际完成时间由数据库决定。
+- 任务来源身份为学生、来源学习记录与计划日期,保存在 `rewardTaskKey`;既有重复记录保留并全部标记异常,为该来源保留唯一身份,禁止继续重复生成。
+- `PayrollLine.kind` 新增 `review_reward`,携带 `reviewRewardId/reviewTaskId/plannedDate`;数据库校正金额及归属快照,拒绝历史或异常奖励,唯一约束防止跨批次重复计薪。已审核工资及其明细不可改写或删除。
+- 工资汇总新增 `reviewCount/reviewAmount`;课次与工时只计算课酬明细。工资批次同时计算课酬、奖励和调整,未结算旧月份的有效奖励可进入后续月份批次。
+- `ops/payroll/review-rewards` 支持 `month/storeId/coachId/search/status/page/pageSize`。状态为 earned/historical/exception,返回 items、total 和筛选范围汇总。无法确定收入月份的异常记录始终显示,避免漏掉。
+- 老师端 `e_get_21list` 的金额、`e_get_21list_tj` 的收入总额及账户首页读取同一账本。收入合计包含有效奖励,不代表已实际发放。
+
+## 发布顺序
+
+1. 备份数据库,确认现有 PayrollBatch 每帐套每月没有重复批次。出现重复会使迁移事务失败,不会静默删除数据。
+2. 使用已有部署环境凭据执行 `npm run review-rewards:migrate -- --apply`。不传 --apply 只显示说明。脚本登记新增 Payroll 字段,在一个 SQL 事务中回填历史证据、安装索引和触发器;重复执行不会补发。临时管理员及迁移函数会在 finally 清理,清理失败明确报出 ID。
+3. 发布 app gateway 与两个 ops gateway(`scripts/deploy-admin-functions.mjs --only=...`),然后发布移动端和管理后台构建。只更新 app gateway 而未执行迁移时,完成复习会失败,不能跳过数据库前置步骤。
+4. 使用授权测试账号验证:首次完成 1 元,第二次完成仍 1 元;老师端与后台一致;工资草稿刷新不新增同一奖励。核对真实业务完成记录及历史待核对总数,再启用正式结算。
+
+数据库迁移是计薪启用时点。历史核对和补结需单独确认;本模块不提供未经核对的批量补发。代码回退时保留数据库凭据和防重约束,不删除账本来恢复旧版。
+
+## 验证
+
+`npm run test:review-rewards` 使用 PGlite 执行真实 PostgreSQL DDL、触发器、唯一约束和事务回滚,并运行网关汇总函数。覆盖重复迁移、重复提交、跨月改写、独立任务、重复来源、缺失老师/来源、失败回滚、跨批次唯一、锁定与租户隔离。PGlite 单连接下的并发请求会排队,不等同于生产多连接负载验证。
+
+`npm run test:admin:ci` 包含奖励列表筛选、历史展示、请求乱序和错误反馈测试;另运行移动端测试、双端构建与 cloud:validate。测试成功不代表生产迁移或真实工资发放已验证。

+ 8 - 0
package-lock.json

@@ -28,6 +28,7 @@
         "@angular-devkit/build-angular": "^18.2.21",
         "@angular/cli": "^18.2.21",
         "@angular/compiler-cli": "^18.2.0",
+        "@electric-sql/pglite": "^0.5.8",
         "@types/jasmine": "~5.1.0",
         "@types/mustache": "^4.2.6",
         "jasmine-core": "~5.2.0",
@@ -2484,6 +2485,13 @@
         "node": ">=14.17.0"
       }
     },
+    "node_modules/@electric-sql/pglite": {
+      "version": "0.5.8",
+      "resolved": "https://registry.npmmirror.com/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+      "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.23.0",
       "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",

+ 4 - 1
package.json

@@ -41,7 +41,9 @@
     "test:admin": "ng test xiaoshu-admin",
     "test:ci": "npm run test:mobile:ci && npm run test:admin:ci",
     "test:mobile:ci": "ng test xiaoshu-mobile --no-watch --no-progress --browsers=ChromeHeadless",
-    "test:admin:ci": "ng test xiaoshu-admin --no-watch --no-progress --browsers=ChromeHeadless"
+    "test:admin:ci": "ng test xiaoshu-admin --no-watch --no-progress --browsers=ChromeHeadless",
+    "test:review-rewards": "node --test scripts/tests/review-rewards.test.mjs",
+    "review-rewards:migrate": "node scripts/migrate-review-rewards.mjs"
   },
   "private": true,
   "dependencies": {
@@ -65,6 +67,7 @@
     "@angular-devkit/build-angular": "^18.2.21",
     "@angular/cli": "^18.2.21",
     "@angular/compiler-cli": "^18.2.0",
+    "@electric-sql/pglite": "^0.5.8",
     "@types/jasmine": "~5.1.0",
     "@types/mustache": "^4.2.6",
     "jasmine-core": "~5.2.0",

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

@@ -758,6 +758,8 @@ export interface PayrollSummary extends Record<string, unknown> {
   lessonCount: number;
   totalHours: number;
   baseAmount: number;
+  reviewCount?: number;
+  reviewAmount?: number;
   adjustmentAmount: number;
   payableAmount: number;
   exceptionCount: number;
@@ -771,7 +773,10 @@ export interface PayrollSummary extends Record<string, unknown> {
 
 export interface PayrollLine extends Record<string, unknown> {
   objectId: string;
-  kind: 'lesson' | 'adjustment';
+  kind: 'lesson' | 'adjustment' | 'review_reward';
+  reviewRewardId?: string;
+  reviewTaskId?: string;
+  plannedDate?: string;
   coachId: number;
   coachName: string;
   coachObjectId?: string;
@@ -823,6 +828,8 @@ export interface PayrollCoachSummary extends Record<string, unknown> {
   lessonCount: number;
   totalHours: number;
   baseAmount: number;
+  reviewCount?: number;
+  reviewAmount?: number;
   adjustmentAmount: number;
   payableAmount: number;
   averageScore?: number;
@@ -941,3 +948,15 @@ export interface MediaUploadTicket {
   headers: Record<string, string>;
   expiresAt: string;
 }
+
+export interface ReviewReward {
+  objectId: string; taskId: string; coachId: number; coachName: string; studentName: string;
+  courseName: string; storeId: number; storeName: string; plannedDate: string; completedAt: string;
+  incomeMonth: string; amount: number; status: 'earned' | 'historical' | 'exception';
+  statusLabel: string; exception: string; batchId?: string; settlementMonth?: string;
+}
+export interface ReviewRewardPage {
+  items: ReviewReward[]; total: number; page: number; pageSize: number;
+  summary: { reviewCount: number; reviewAmount: number; historicalCount: number; exceptionCount: number };
+}
+export interface ReviewRewardFilters { month: string; storeId: number; search: string; coachId: number; revision?: number; }

+ 4 - 0
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -100,6 +100,10 @@ export class OperationsService {
     return this.functions.operations<TeachingStandardsData>('ops/teaching-rules/list');
   }
 
+  reviewRewards(filters: import('./admin.models').ReviewRewardFilters, page = 1, status = '', pageSize = 20): Promise<import('./admin.models').ReviewRewardPage> {
+    return this.functions.operations('ops/payroll/review-rewards', { ...filters, page, pageSize, status });
+  }
+
   payrolls(page = 1, pageSize = 20): Promise<OperationsPage<PayrollSummary>> {
     return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize });
   }

+ 5 - 4
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.html

@@ -8,6 +8,7 @@
       @if(data();as detail){<div class="coach-header-meta"><span>ID {{detail.coach.userId}}</span><span><lucide-icon [img]="icons.Building2" [size]="13"/>{{detail.profile.storeName||'未绑定门店'}}</span><strong>{{detail.profile.statusLabel}}</strong></div>}
     </div>
   </div>
+  @if(data();as detail){<a class="ui-button secondary" routerLink="/admin/payroll" [queryParams]="{tab:'rewards',coachId:detail.coach.userId}">复习奖励明细</a>}
   <button class="ui-button secondary refresh-action" type="button" (click)="load(true)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>{{loading()?'正在刷新':'刷新实时数据'}}</button>
 </header>
 
@@ -39,7 +40,7 @@
     <article class="summary-card"><span>今日排课</span><strong>{{detail.coach.todayAppointments}}</strong><small>未来待完成 {{upcomingCount()}} 节</small></article>
     <article class="summary-card"><span>{{detail.payrollMonth}} 有效课次</span><strong>{{detail.currentPayroll.lessonCount}}</strong><small>30分钟 {{detail.currentPayroll.type30Count}} · 60分钟 {{detail.currentPayroll.type60Count}}</small></article>
     <article class="summary-card"><span>{{detail.payrollMonth}} 工时</span><strong>{{detail.currentPayroll.totalHours|number:'1.1-1'}}h</strong><small>体验课 {{detail.currentPayroll.trialCount}} 节</small></article>
-    <article class="summary-card payroll-card"><span>{{detail.payrollMonth}} 预估工资</span><strong>¥{{detail.currentPayroll.payableAmount|number:'1.2-2'}}</strong><small>与工资结算实时同步</small></article>
+    <article class="summary-card payroll-card"><span>{{detail.payrollMonth}} 预估工资</span><strong>¥{{detail.currentPayroll.payableAmount|number:'1.2-2'}}</strong><small>含复习奖励 ¥{{detail.currentPayroll.reviewAmount||0|number:'1.2-2'}}</small></article>
     <article class="summary-card"><span>平均评分</span><strong>{{detail.currentPayroll.averageScore?(detail.currentPayroll.averageScore|number:'1.1-1'):'—'}}</strong><small>{{detail.reviews.length}} 条有效评价</small></article>
     <article class="summary-card"><span>21天复习任务</span><strong>{{detail.reviewTaskCount}}</strong><small>原系统默认不计薪</small></article>
   </section>
@@ -77,9 +78,9 @@
       <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>规则</th><th>日期/星期</th><th>时段</th><th>状态</th><th>原因</th><th>操作</th></tr></thead><tbody>@for(rule of detail.availability;track rule.objectId){<tr><td>{{rule.ruleType==='weekly'?'每周':'指定日期'}}</td><td>{{rule.ruleType==='weekly'?('星期'+['日','一','二','三','四','五','六'][rule.weekday]):rule.date}}</td><td>{{rule.startTime}}–{{rule.endTime}}</td><td><span class="badge">{{rule.available?'可排课':'休息/请假'}}</span></td><td>{{rule.reason}}</td><td><button class="icon-action danger" type="button" aria-label="删除可用时间规则" (click)="removeAvailability(rule.objectId,rule.updatedAt)"><lucide-icon [img]="icons.Trash2" [size]="16"/></button></td></tr>}@empty{<tr><td colspan="6"><div class="empty">尚未配置;排课时允许选择,但会提示未配置可用时间</div></td></tr>}</tbody></table></div>
     }@else{
       <div class="section-heading"><div><strong>{{payrollMonth()}} 老师工资记录</strong><span>只筛选工资预估和已生成工资批次,不影响排课和上课明细</span></div><div class="section-month-filter"><span>工资月份</span><button class="month-picker-trigger" data-testid="payroll-month" type="button" aria-label="选择工资月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='payroll'" [class.active]="activeMonthPicker()==='payroll'" (click)="toggleMonthPicker('payroll',$event)" [disabled]="loading()||isSectionLoading('payroll')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(payrollMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('payroll')?'正在读取…':(detail.payrolls.length+' 条工资记录')}}</small>@if(activeMonthPicker()==='payroll'){<div class="month-calendar-popover" role="dialog" aria-label="选择工资月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><strong>{{monthPickerYear()}}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="payrollMonth()===monthOptionValue($index)" [attr.aria-pressed]="payrollMonth()===monthOptionValue($index)" (click)="selectSectionMonth('payroll',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('payroll')">回到本月</button></footer></div>}</div></div>
-      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>月份</th><th>状态</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>调整</th><th>应发金额</th></tr></thead><tbody>
-        @for(row of detail.payrolls;track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.objectId.startsWith('preview:')">{{row.statusLabel}}</span></td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td></tr>}
-        @empty{<tr><td colspan="7"><div class="empty">{{isSectionLoading('payroll')?'正在读取工资记录…':'该月暂无工资记录'}}</div></td></tr>}
+      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>月份</th><th>状态</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>复习奖励</th><th>调整</th><th>应发金额</th></tr></thead><tbody>
+        @for(row of detail.payrolls;track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.objectId.startsWith('preview:')">{{row.statusLabel}}</span></td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td></tr>}
+        @empty{<tr><td colspan="8"><div class="empty">{{isSectionLoading('payroll')?'正在读取工资记录…':'该月暂无工资记录'}}</div></td></tr>}
       </tbody></table></div>
     }
   </section>

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

@@ -1,15 +1,18 @@
-<header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按旧系统有效上课记录核算收益,并完成月度审核与发放登记</span></div><div class="header-actions"><button class="ui-button refresh-action" type="button" (click)="refreshLive()" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{refreshing()?'正在刷新':'刷新实时数据'}}</button><button class="ui-button export-action" type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
+<header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按有效上课记录与复习奖励核算工资,完成月度审核与发放登记</span></div><div class="header-actions"><button class="ui-button refresh-action" type="button" (click)="refreshLive()" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{refreshing()?'正在刷新':'刷新实时数据'}}</button>@if(tab()!=='rewards'){<button class="ui-button export-action" type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button>}</div></header>
 @if(message()){<p class="notice" role="status">{{message()}}</p>} @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
-<nav class="tabs payroll-tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />排课与工资标准</button></nav>
-@if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><label><span>门店</span><select [ngModel]="storeId()" (ngModelChange)="storeId.set(+$event)"><option [ngValue]="0">全部门店</option>@for(store of overview()?.stores||[];track store.storeId){<option [ngValue]="store.storeId">{{store.storeName==='未绑定门店'?'未绑定门店(ID '+store.storeId+')':store.storeName}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名或手机号" (keyup.enter)="applyFilters()" /></label><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'" role="status"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}
+<nav class="tabs payroll-tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='rewards'" (click)="openRewards()">复习奖励</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />排课与工资标准</button></nav>
+@if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><label><span>门店</span><select [ngModel]="storeId()" (ngModelChange)="storeId.set(+$event)"><option [ngValue]="0">全部门店</option>@for(store of overview()?.stores||[];track store.storeId){<option [ngValue]="store.storeId">{{store.storeName==='未绑定门店'?'未绑定门店(ID '+store.storeId+')':store.storeName}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" [placeholder]="tab()==='rewards'?'老师姓名或 ID':'姓名或手机号'" (keyup.enter)="applyFilters()" /></label><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'" role="status"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}
 @if(tab()==='overview'){
  @if(overview();as data){
   @if(data.sourceStatus.mode==='cache'){<p class="notice error">{{data.sourceStatus.message}}。当前可以查看缓存,但不能生成或审核工资单。</p>}
-  <section class="payroll-metrics" aria-label="工资收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有上课记录</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础工资</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>按历史生效单价</small></article><article><span>应发工资</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>生成草稿后可调账</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>生成工资前必须处理</small></article></section>
-  <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师可查看每一节课的计薪依据</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.available||data.exceptionCount>0"><lucide-icon [img]="icons.Plus" [size]="17" />生成全公司工资草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验课</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础工资</th><th>应发</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师上课明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="11"><div class="empty">当前筛选范围没有有效上课记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
+  <section class="payroll-metrics" aria-label="工资收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有课酬或复习奖励</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础工资</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>按历史生效单价</small></article><article><span>复习奖励</span><strong>¥{{data.reviewAmount||0|number:'1.2-2'}}</strong><small>有效复习 {{data.reviewCount||0}} 次</small></article><article><span>应发工资</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>生成草稿后可调账</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>生成工资前必须处理</small></article></section>
+  <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师查看课酬与复习奖励明细</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.available||data.exceptionCount>0"><lucide-icon [img]="icons.Plus" [size]="17" />生成全公司工资草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验课</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础工资</th><th>复习次数</th><th>复习奖励</th><th>应发</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师上课明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="13"><div class="empty">当前筛选范围没有有效计薪记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
  }
+} @else if(tab()==='rewards'){
+ @if(rewardCoachId()){<p class="notice">当前查看老师 ID {{rewardCoachId()}} <button class="ui-button secondary compact" (click)="clearRewardCoach()">查看全部老师</button></p>}
+ <app-review-rewards [filters]="rewardFilters()" />
 } @else if(tab()==='batches'){
- <section class="content-card"><header><div><strong>全公司月度工资批次</strong><span>每月仅一张;门店筛选不会拆分工资单</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>月份</th><th>状态</th><th>陪练</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>调整</th><th>应发</th><th>异常</th><th>操作</th></tr></thead><tbody>@for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="10"><div class="empty">尚未生成工资批次。请先在“收益统计”核对实时数据。</div></td></tr>}</tbody></table></div></section>
+ <section class="content-card"><header><div><strong>全公司月度工资批次</strong><span>每月仅一张;门店筛选不会拆分工资单</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>月份</th><th>状态</th><th>陪练</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>复习次数</th><th>复习奖励</th><th>调整</th><th>应发</th><th>异常</th><th>操作</th></tr></thead><tbody>@for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="12"><div class="empty">尚未生成工资批次。请先在“收益统计”核对实时数据。</div></td></tr>}</tbody></table></div></section>
 } @else {
  @if(standards();as config){
   <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>
@@ -34,7 +37,7 @@
  }
 }
 @if(detail()||selectedCoachId()){
- <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" role="dialog" aria-modal="true" aria-label="工资明细"><header><div><p>{{detail()?.objectId?'PAYROLL BATCH':'COACH LESSONS'}}</p><h2>@if(detail();as batch){ {{batch.month}} · {{batch.statusLabel}} }@else{ {{selectedCoachName()||'老师上课明细'}} }</h2><span>{{coachLines().length}} 条来源记录</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>@if(detail()?.exceptionCount){<p class="notice error">存在 {{detail()?.exceptionCount}} 条异常,处理完成前不能审核。</p>}<div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>学员 / 课程</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th><th>说明</th></tr></thead><tbody>@for(line of coachLines();track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}<small>{{line.storeName}}</small></td><td>{{line.studentName}}<small>{{line.courseName}}</small></td><td>{{line.lessonAt|date:'MM-dd HH:mm'}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button class="ui-button danger compact" type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>
+ <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" role="dialog" aria-modal="true" aria-label="工资明细"><header><div><p>{{detail()?.objectId?'PAYROLL BATCH':'COACH LESSONS'}}</p><h2>@if(detail();as batch){ {{batch.month}} · {{batch.statusLabel}} }@else{ {{selectedCoachName()||'老师上课明细'}} }</h2><span>{{coachLines().length}} 条来源记录</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>@if(detail()?.exceptionCount){<p class="notice error">存在 {{detail()?.exceptionCount}} 条异常,处理完成前不能审核。</p>}<div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>学员 / 课程</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th><th>说明</th></tr></thead><tbody>@for(line of coachLines();track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}<small>{{line.storeName}}</small></td><td>{{line.studentName}}<small>{{line.courseName}}</small></td><td>{{line.lessonAt|date:'MM-dd HH:mm'}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.reviewTaskId||line.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button class="ui-button danger compact" type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>
  @if(detail();as batch){@if(batch.status==='draft'&&isManager()){<section class="panel"><h3>新增工资调整</h3><div class="form-grid"><label>陪练老师<select [ngModel]="adjustmentCoachId()" (ngModelChange)="adjustmentCoachId.set(+$event)"><option [ngValue]="0">请选择陪练</option>@for(coach of overview()?.coachItems||[];track coach.coachId){<option [ngValue]="coach.coachId">{{coachLabel(coach.coachName,coach.coachId)}} · {{coach.storeName}}</option>}</select></label><label>调整金额<input type="number" step="0.01" [ngModel]="adjustmentAmount()" (ngModelChange)="adjustmentAmount.set(+$event)" placeholder="负数表示扣减" /></label><label class="wide">调整 / 排除原因<input [ngModel]="adjustmentReason()" (ngModelChange)="adjustmentReason.set($event)" placeholder="必填,将写入审计日志" /></label><button class="ui-button secondary" type="button" (click)="addAdjustment()">添加调整</button></div></section>}@if(batch.status==='reviewed'&&isManager()){<section class="panel"><h3>登记线下发放</h3><div class="form-grid"><label>发放方式<input [ngModel]="paymentMethod()" (ngModelChange)="paymentMethod.set($event)" /></label><label>凭证编号<input [ngModel]="paymentReference()" (ngModelChange)="paymentReference.set($event)" placeholder="银行流水或内部凭证号" /></label></div></section>}}
  <footer><button class="ui-button secondary" type="button" (click)="exportExcel()"><lucide-icon [img]="icons.Download" [size]="16" />导出 Excel</button>@if(detail();as batch){@if(isManager()&&batch.status==='draft'){<button class="ui-button secondary" type="button" (click)="refreshDraft()" [disabled]="saving()">刷新并增量重算</button><button class="ui-button primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="ui-button primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
 }

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

@@ -144,7 +144,7 @@
 
 .payroll-metrics {
   display: grid;
-  grid-template-columns: repeat(6, minmax(0, 1fr));
+  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
   gap: 14px;
   margin: 18px 0;
 }

Разница между файлами не показана из-за своего большого размера
+ 4 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


+ 12 - 0
projects/xiaoshu-admin/src/app/pages/review-rewards.component.html

@@ -0,0 +1,12 @@
+@if(error()){<p class="notice error" role="alert">{{error()}} <button class="ui-button secondary compact" (click)="load()">重新加载</button></p>}
+<section class="content-card reward-card" [attr.aria-busy]="loading()">
+  <header><div><strong>复习奖励</strong><span>每条计划任务首次完成奖励 1 元,重复复习不重复计薪。历史待核对和异常记录不计入工资。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="earned">有效奖励</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出奖励明细'}}</button></div></header>
+  @if(data();as result){
+    <div class="reward-summary"><span>有效复习 <strong>{{result.summary.reviewCount}}</strong> 次</span><span>奖励合计 <strong>¥{{result.summary.reviewAmount|number:'1.2-2'}}</strong></span><span>历史待核对 {{result.summary.historicalCount}} 条</span><span>异常待核对 {{result.summary.exceptionCount}} 条</span></div>
+    <div class="table-scroll"><table class="data-table"><thead><tr><th>老师 / 门店</th><th>学生 / 课程</th><th>计划日期</th><th>首次完成时间</th><th>金额</th><th>结算状态</th><th>说明 / 任务</th></tr></thead><tbody>
+      @for(row of result.items;track row.objectId){<tr><td><strong>{{row.coachName}}</strong><small>{{row.storeName}}</small></td><td>{{row.studentName}}<small>{{row.courseName||'未记录课程'}}</small></td><td>{{row.plannedDate||'未记录'}}</td><td>{{row.completedAt?(row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'):'待核对'}}</td><td class="money">¥{{row.amount|number:'1.2-2'}}</td><td><span class="badge" [class.warn]="row.status!=='earned'">{{row.statusLabel}}</span><small>{{row.settlementMonth}}</small></td><td>{{row.exception|| (row.status==='historical'?'需核对历史发放情况':'首次完成,仅计薪一次')}}<small>{{row.taskId}}</small></td></tr>}
+      @empty{<tr><td colspan="7"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无复习奖励记录'}}</div></td></tr>}
+    </tbody></table></div>
+    <footer class="reward-pagination"><span>共 {{result.total}} 条 · 第 {{result.page}} 页</span><button class="ui-button secondary compact" (click)="load(result.page-1)" [disabled]="loading()||result.page<=1">上一页</button><button class="ui-button secondary compact" (click)="load(result.page+1)" [disabled]="loading()||result.page*result.pageSize>=result.total">下一页</button></footer>
+  }@else if(loading()){<p class="empty" role="status">正在加载复习奖励…</p>}
+</section>

+ 1 - 0
projects/xiaoshu-admin/src/app/pages/review-rewards.component.scss

@@ -0,0 +1 @@
+:host{display:block}.reward-card{overflow:hidden}.reward-card>header{align-items:flex-start;gap:20px}.reward-card>header>div:first-child{max-width:640px}.reward-card header span{line-height:1.7}.reward-actions{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap}.reward-actions label{display:grid;gap:6px;font-size:13px;color:var(--text-muted,#687a76)}.reward-actions select{min-width:140px}.reward-summary{display:flex;gap:24px;flex-wrap:wrap;padding:16px 20px;background:var(--surface-subtle,#f3f8f6);font-size:14px}.reward-summary strong{color:var(--primary,#1e7d6b)}.reward-pagination{display:flex;justify-content:flex-end;align-items:center;gap:12px;padding:16px 20px}.reward-pagination>span{margin-right:auto;font-size:13px;color:#687a76}table{min-width:960px}td:last-child{max-width:260px;overflow-wrap:anywhere}@media(max-width:700px){.reward-card>header{flex-direction:column}.reward-summary{gap:12px 20px}.reward-pagination{padding:12px;gap:8px}}

+ 30 - 0
projects/xiaoshu-admin/src/app/pages/review-rewards.component.spec.ts

@@ -0,0 +1,30 @@
+import { TestBed } from '@angular/core/testing';
+import { ReviewRewardsComponent } from './review-rewards.component';
+import { OperationsService } from '../operations.service';
+import { ReviewRewardPage } from '../admin.models';
+
+describe('ReviewRewardsComponent',()=>{
+  const empty:ReviewRewardPage={items:[],total:0,page:1,pageSize:20,summary:{reviewCount:0,reviewAmount:0,historicalCount:0,exceptionCount:0}};
+  let service:jasmine.SpyObj<OperationsService>;
+  beforeEach(async()=>{
+    service=jasmine.createSpyObj('OperationsService',['reviewRewards']);service.reviewRewards.and.resolveTo(empty);
+    await TestBed.configureTestingModule({imports:[ReviewRewardsComponent],providers:[{provide:OperationsService,useValue:service}]}).compileComponents();
+  });
+  it('passes coach, store, month and status filters, resetting pagination when filters change',async()=>{
+    const fixture=TestBed.createComponent(ReviewRewardsComponent),component=fixture.componentInstance;
+    fixture.componentRef.setInput('filters',{month:'2026-09',coachId:10,storeId:40,search:'老师'});fixture.detectChanges();await fixture.whenStable();
+    expect(service.reviewRewards).toHaveBeenCalledWith(component.filters,1,'');component.status.set('historical');await component.load(2);expect(service.reviewRewards).toHaveBeenCalledWith(component.filters,2,'historical');
+    fixture.componentRef.setInput('filters',{...component.filters,month:'2026-10'});fixture.detectChanges();await fixture.whenStable();expect(service.reviewRewards).toHaveBeenCalledWith(component.filters,1,'historical');
+  });
+  it('shows historical evidence as zero value pending review, separately from earned totals',async()=>{
+    service.reviewRewards.and.resolveTo({...empty,total:1,summary:{...empty.summary,historicalCount:1},items:[{objectId:'r',taskId:'t',coachId:10,coachName:'老师',studentName:'学生',courseName:'单词',storeId:40,storeName:'门店',plannedDate:'2026-08-01',completedAt:'',incomeMonth:'',amount:0,status:'historical',statusLabel:'历史待核对',exception:''}]});
+    const fixture=TestBed.createComponent(ReviewRewardsComponent);fixture.componentRef.setInput('filters',{month:'2026-09',coachId:0,storeId:0,search:''});fixture.detectChanges();await fixture.whenStable();fixture.detectChanges();
+    expect(fixture.nativeElement.textContent).toContain('需核对历史发放情况');expect(fixture.nativeElement.textContent).toContain('¥0.00');
+  });
+  it('keeps the newest response and displays read errors instead of a false zero income',async()=>{
+    const fixture=TestBed.createComponent(ReviewRewardsComponent),component=fixture.componentInstance;component.filters={month:'2026-09',coachId:0,storeId:0,search:''};
+    let resolveOld!:(value:ReviewRewardPage)=>void;service.reviewRewards.and.returnValues(new Promise(resolve=>resolveOld=resolve),Promise.resolve({...empty,total:2}));
+    const old=component.load();await component.load();resolveOld({...empty,total:1});await old;expect(component.data()?.total).toBe(2);
+    service.reviewRewards.and.rejectWith(new Error('服务不可用'));await component.load();expect(component.error()).toBe('服务不可用');expect(component.data()).toBeNull();
+  });
+});

+ 34 - 0
projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts

@@ -0,0 +1,34 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input, OnChanges, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ReviewRewardFilters, ReviewRewardPage } from '../admin.models';
+import { OperationsService } from '../operations.service';
+import { utils, writeFile } from 'xlsx';
+
+@Component({selector:'app-review-rewards',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+export class ReviewRewardsComponent implements OnChanges {
+  @Input({required:true}) filters!: ReviewRewardFilters;
+  readonly data=signal<ReviewRewardPage|null>(null);
+  readonly status=signal(''); readonly loading=signal(false); readonly exporting=signal(false); readonly error=signal('');
+  private requestId=0;
+  private readonly operations=inject(OperationsService);
+  ngOnChanges():void { void this.load(); }
+  async load(page=1):Promise<void> {
+    const requestId=++this.requestId;this.loading.set(true);this.error.set('');
+    try { const data=await this.operations.reviewRewards(this.filters,page,this.status());if(requestId===this.requestId)this.data.set(data); }
+    catch(error){if(requestId===this.requestId){this.data.set(null);this.error.set(error instanceof Error?error.message:'复习奖励加载失败');}}
+    finally{if(requestId===this.requestId)this.loading.set(false);}
+  }
+  async export():Promise<void> {
+    if(this.exporting())return;this.exporting.set(true);this.error.set('');
+    const filters={...this.filters},status=this.status();
+    try {
+      const rows:Record<string,unknown>[]=[];let page=1,total=0;
+      do {const data=await this.operations.reviewRewards(filters,page,status,100);total=data.total;
+        rows.push(...data.items.map(row=>({'奖励凭据':row.objectId,'复习任务':row.taskId,'老师':row.coachName,'门店':row.storeName,'学生':row.studentName,'课程':row.courseName,'计划日期':row.plannedDate,'首次完成时间':row.completedAt,'收入月份':row.incomeMonth,'奖励金额':row.amount,'结算状态':row.statusLabel,'结算月份':row.settlementMonth||'','异常原因':row.exception})));
+        if(!data.items.length)break;page++;
+      }while(rows.length<total);
+      const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'复习奖励');writeFile(book,`复习奖励_${filters.month}.xlsx`);
+    }catch(error){this.error.set(error instanceof Error?error.message:'导出失败');}finally{this.exporting.set(false);}
+  }
+}

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -91,7 +91,7 @@
         } @else if (meta.path === 'pages/member/myke') {
           <article class="progress-item" (click)="open(item)"><lucide-icon [img]="icons.RotateCcw" [size]="21" /><div><strong>{{ title(item) }}</strong><p>完成度:{{ item['yxx'] || 0 }}/{{ item['cksl2'] || item['cksl'] || 0 }}</p></div><span>复习</span></article>
         } @else if (meta.path === 'pages/member/ke_fuxi') {
-          <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} {{ item['kywsj'] }}</p></div><b>¥{{ item['money'] || 0 }}</b></article>
+          <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} {{ item['kywsj'] }}</p>@if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计薪</small>}@else if(item['rewardStatus']==='exception'){<small>奖励异常待核对</small>}</div><b>¥{{ item['money'] || 0 }}</b></article>
         } @else {
           <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
         }

+ 4 - 4
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -531,14 +531,14 @@ export class StudyRecordsPageComponent implements OnInit {
 
   private loadIncome(uid: number): void {
     forkJoin({
-      list: this.api.post<unknown>('e_get_21list', { status: 1, shouru: 1, uid, cpage: 1, psize: 20 }).pipe(catchError(() => of({ retcode: 1, result: [] }))),
+      list: this.api.post<unknown>('e_get_21list', { status: 1, shouru: 1, uid, cpage: 1, psize: 20 }),
       total: this.session.groupId() === 3
-        ? this.api.post<unknown>('e_get_21list_tj', { uid }).pipe(catchError(() => of({ retcode: 1, result: [] })))
+        ? this.api.post<unknown>('e_get_21list_tj', { uid })
         : of({ retcode: 1, result: [] }),
-    }).pipe(finalize(() => this.loading.set(false))).subscribe(({ list, total }) => {
+    }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ list, total }) => {
       this.items.set(this.asList(list.result));
       this.totalIncome.set(Number(this.asList(total.result)[0]?.['num'] ?? 0));
-    });
+    }, error: (error: ApiError) => this.error.set(error.message || '复习收入加载失败') });
   }
 
   private asList(value: unknown): Item[] {

+ 38 - 0
scripts/cloud/review-rewards.js

@@ -0,0 +1,38 @@
+// Included verbatim in both gateway bundles. Read-only queries share the immutable ledger.
+async function reviewIncomeTotal(companyId, coachId) {
+  const row = await Psql.one('SELECT COALESCE(SUM("amount"),0) AS total FROM "ReviewReward" WHERE "company"=$1 AND "coachId"=$2 AND "status"=\'earned\'', [companyId, Number(coachId)]);
+  return Number(row.total);
+}
+function rewardPayrollLine(row, month) {
+  const iso = row.completedAt ? new Date(row.completedAt).toISOString() : '';
+  return { objectId: 'review:' + row.objectId, kind: 'review_reward', reviewRewardId: row.objectId,
+    reviewTaskId: row.taskId, plannedDate: row.plannedDate, coachId: Number(row.coachId), coachName: row.coachName,
+    storeId: Number(row.storeId), storeName: row.storeName, studentName: row.studentName, courseName: row.courseName,
+    legacyGeneralId: 0, lessonId: '', lessonAt: iso, originalMonth: row.incomeMonth, settlementMonth: month,
+    classType: 0, classTypeLabel: '复习奖励', courseCategoryName: '复习奖励', courseCategoryKey: '',
+    durationMinutes: 0, durationHours: 0, deliveryMode: '', deliveryLabel: '', rateAmount: Number(row.amount),
+    amount: Number(row.amount), score: 0, exception: '', description: '计划复习首次完成 · ' + row.plannedDate };
+}
+async function reviewRewardPayrollLines(companyId, month, includeUsed) {
+  if (!companyId) throw new Error('复习奖励必须指定帐套');
+  if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
+  const rows = await Psql.query('SELECT r.* FROM "ReviewReward" 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 l."kind"=\'review_reward\' AND COALESCE(l."excluded",false)=false)') +
+    ' ORDER BY r."completedAt",r."objectId"', [companyId, month]);
+  return rows.map((row) => rewardPayrollLine(row, month));
+}
+async function reviewRewardPage(companyId, input) {
+  if (!companyId) throw new Error('复习奖励必须指定帐套');
+  const page = Math.max(1, Math.floor(Number(input.page) || 1)), pageSize = Math.min(100, Math.max(1, Math.floor(Number(input.pageSize) || 20)));
+  const values = [companyId], clauses = ['r."company"=$1'];
+  const add = (condition, value) => { values.push(value); clauses.push(condition.replaceAll('?', '$' + values.length)); };
+  if (input.month) { if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.month)) throw new Error('月份格式无效'); add('(r."incomeMonth"=? OR r."incomeMonth"=\'\')', input.month); }
+  if (Number(input.storeId)) add('r."storeId"=?', Number(input.storeId));
+  if (Number(input.coachId)) add('r."coachId"=?', Number(input.coachId));
+  if (input.search) add('(r."coachName" ILIKE ? OR r."coachId"::text ILIKE ?)', '%' + String(input.search).trim() + '%');
+  if (['earned','historical','exception'].includes(input.status)) add('r."status"=?', input.status);
+  const where = clauses.join(' AND ');
+  const summary = await Psql.one('SELECT COUNT(*)::int total, COUNT(*) FILTER (WHERE r."status"=\'earned\')::int "reviewCount", COALESCE(SUM(r."amount"),0) "reviewAmount",COUNT(*) FILTER (WHERE r."status"=\'historical\')::int "historicalCount",COUNT(*) FILTER (WHERE r."status"=\'exception\')::int "exceptionCount" FROM "ReviewReward" r WHERE '+where, values);
+  const rows = await Psql.query('SELECT r.*,l."batchId",b."month" AS "settlementMonth",b."status" AS "batchStatus" FROM "ReviewReward" r LEFT JOIN "PayrollLine" l ON l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND l."kind"=\'review_reward\' AND COALESCE(l."excluded",false)=false LEFT JOIN "PayrollBatch" b ON b."company"=l."company" AND b."objectId"=l."batchId" WHERE '+where+' ORDER BY r."completedAt" DESC NULLS FIRST,r."objectId" LIMIT $'+(values.length+1)+' OFFSET $'+(values.length+2), [...values,pageSize,(page-1)*pageSize]);
+  return { items: rows.map((row)=>({...row,amount:Number(row.amount),completedAt:row.completedAt?new Date(row.completedAt).toISOString():'',statusLabel:row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')})), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
+}

Разница между файлами не показана из-за своего большого размера
+ 10 - 10
scripts/deploy-admin-functions.mjs


+ 39 - 0
scripts/migrate-review-rewards.mjs

@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+const sql=readFileSync(new URL('./sql/review-rewards.sql',import.meta.url),'utf8');
+if(!process.argv.includes('--apply')) {
+  console.log('只读检查:复习奖励迁移脚本已就绪。使用 --apply 执行;历史已完成记录只生成零金额待核对凭据。');
+  process.exit(0);
+}
+const master=process.env.XIAOSHU_MASTER_KEY;
+if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY');
+const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+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;
+}
+// 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]));
+  if(Object.keys(missing).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields:missing})});
+}
+const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if(!company)throw new Error('未找到帐套');
+const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/review-rewards-'+suffix;
+let userId='',functionId='';
+try {
+  const account=await request('/users',{method:'POST',body:JSON.stringify({username:'review_migration_'+suffix,password:randomBytes(32).toString('base64url'),isAdmin:true,adminRoleKey:'super-admin',roles:['admin','super-admin'],company:{__type:'Pointer',className:'Company',objectId:company.objectId},testCreatedBy:'review-rewards-migration'})});userId=account.objectId;
+  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));
+} finally {
+  // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind.
+  const failures=[];
+  if(functionId)try{await request('/classes/Function/'+functionId,{method:'DELETE'});}catch{failures.push('临时函数 '+functionId);}
+  if(userId)try{await request('/users/'+userId,{method:'DELETE'});}catch{failures.push('临时管理员 '+userId);}
+  if(failures.length)throw new Error('需要清理:'+failures.join('、'));
+}

+ 174 - 0
scripts/sql/review-rewards.sql

@@ -0,0 +1,174 @@
+-- Applied as one transaction before publishing the app/operations gateways.
+BEGIN;
+LOCK TABLE "MemoryPracticeRecord" IN SHARE ROW EXCLUSIVE MODE;
+ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "rewardTaskKey" text;
+ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "rewardDuplicate" boolean;
+ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "reviewRewardId" text;
+ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "reviewTaskId" text;
+ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "plannedDate" text;
+ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "reviewCount" double precision;
+ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "reviewAmount" double precision;
+CREATE TABLE IF NOT EXISTS "ReviewReward" (
+  "objectId" text PRIMARY KEY, "company" text NOT NULL, "taskId" text NOT NULL,
+  "taskKey" text, "coachId" double precision NOT NULL DEFAULT 0, "coachName" text NOT NULL DEFAULT '',
+  "studentId" double precision NOT NULL DEFAULT 0, "studentName" text NOT NULL DEFAULT '',
+  "courseName" text NOT NULL DEFAULT '', "storeId" double precision NOT NULL DEFAULT 0, "storeName" text NOT NULL DEFAULT '',
+  "plannedDate" text NOT NULL DEFAULT '', "completedAt" timestamptz, "incomeMonth" text NOT NULL DEFAULT '',
+  "amount" numeric(12,2) NOT NULL DEFAULT 0, "status" text NOT NULL,
+  "exception" text NOT NULL DEFAULT '', "createdAt" timestamptz NOT NULL DEFAULT now(),
+  UNIQUE ("company","taskId"), CHECK ("status" IN ('earned','historical','exception')),
+  CHECK (("status"='earned' AND "amount"=1 AND "completedAt" IS NOT NULL AND "coachId">0) OR ("status"<>'earned' AND "amount"=0))
+);
+CREATE UNIQUE INDEX IF NOT EXISTS review_reward_once_per_plan ON "ReviewReward" ("company","taskKey") WHERE "status"='earned';
+CREATE INDEX IF NOT EXISTS review_reward_coach_month ON "ReviewReward" ("company","coachId","incomeMonth");
+CREATE UNIQUE INDEX IF NOT EXISTS payroll_company_month_once ON "PayrollBatch" ("company","month");
+CREATE UNIQUE INDEX IF NOT EXISTS payroll_review_reward_once ON "PayrollLine" ("company","reviewRewardId") WHERE "kind"='review_reward' AND COALESCE("excluded",false)=false;
+
+CREATE OR REPLACE FUNCTION xiaoshu_review_key(student text, study text, day text) RETURNS text
+LANGUAGE sql IMMUTABLE AS $$
+ SELECT CASE WHEN student ~ '^[1-9][0-9]*$' AND study ~ '^[1-9][0-9]*$' AND day ~ '^\d{4}-\d{2}-\d{2}$'
+ THEN student||':'||study||':'||day ELSE NULL END
+$$;
+-- Preserve duplicate legacy rows, reserve their identity, and flag all copies for review.
+WITH ranked AS (
+ SELECT "objectId", xiaoshu_review_key("yhid"::text,"xxjlid"::text,left("kywrq"::text,10)) k,
+ row_number() OVER (PARTITION BY "company","yhid","xxjlid",left("kywrq"::text,10) ORDER BY "objectId") rn,
+ count(*) OVER (PARTITION BY "company","yhid","xxjlid",left("kywrq"::text,10)) copies
+ FROM "MemoryPracticeRecord"
+)
+UPDATE "MemoryPracticeRecord" m SET "rewardTaskKey"=CASE WHEN r.rn=1 THEN r.k END,
+ "rewardDuplicate"=(r.copies>1 AND r.k IS NOT NULL)
+FROM ranked r WHERE m."objectId"=r."objectId" AND m."rewardDuplicate" IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS memory_review_plan_once ON "MemoryPracticeRecord" ("company","rewardTaskKey") WHERE "rewardTaskKey" IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION xiaoshu_record_review_reward(task "MemoryPracticeRecord", historical boolean) RETURNS void
+LANGUAGE plpgsql AS $$
+DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text;
+BEGIN
+ IF task."company" IS NULL THEN RAISE EXCEPTION '复习任务缺少帐套'; END IF;
+ IF EXISTS (SELECT 1 FROM "ReviewReward" WHERE "company"=task."company" AND "taskId"=task."objectId") THEN RETURN; END IF;
+ SELECT * INTO coach FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=task."plid"::text LIMIT 1;
+ SELECT * INTO student FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=task."yhid"::text LIMIT 1;
+ SELECT * INTO store FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=coach."legacyUserData"->>'ParentUserID' LIMIT 1;
+ SELECT "nodeName" INTO course FROM "Node" WHERE "company"=task."company" AND "nodeId"::text=task."kcid"::text LIMIT 1;
+ BEGIN
+   finished := CASE WHEN historical THEN NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai' ELSE statement_timestamp() END;
+ EXCEPTION WHEN OTHERS THEN finished := NULL; END;
+ IF task."rewardDuplicate" THEN problem := '来源复习任务重复';
+ ELSIF task."rewardTaskKey" IS NULL OR NOT EXISTS (SELECT 1 FROM "CommonModel" WHERE "company"=task."company" AND "modelId"::text='56' AND "generalId"::text=task."xxjlid"::text) THEN problem := '缺少有效来源学习记录或计划日期';
+ ELSIF coach."objectId" IS NULL OR COALESCE(task."plid"::text,'0') !~ '^[1-9][0-9]*$' THEN problem := '缺少有效复习老师';
+ ELSIF student."objectId" IS NULL THEN problem := '缺少有效学员';
+ ELSIF finished IS NULL THEN problem := '缺少有效完成时间'; END IF;
+ reward_status := CASE WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' ELSE 'earned' END;
+ INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception")
+ VALUES (md5(task."company"||':review:'||task."objectId"),task."company",task."objectId",task."rewardTaskKey",
+ CASE WHEN task."plid"::text ~ '^[1-9][0-9]*$' THEN task."plid"::double precision ELSE 0 END,
+ COALESCE(NULLIF(coach."realName",''),NULLIF(coach."nickname",''),coach."legacyUserData"->>'HoneyName',coach."username",'未识别老师'),
+ CASE WHEN task."yhid"::text ~ '^[1-9][0-9]*$' THEN task."yhid"::double precision ELSE 0 END,
+ COALESCE(NULLIF(student."realName",''),NULLIF(student."nickname",''),student."legacyUserData"->>'HoneyName',student."username",'未识别学员'),COALESCE(course,''),
+ CASE WHEN coach."legacyUserData"->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach."legacyUserData"->>'ParentUserID')::double precision ELSE 0 END,
+ COALESCE(NULLIF(store."realName",''),NULLIF(store."nickname",''),store."legacyUserData"->>'HoneyName',store."username",'未绑定门店'),
+ COALESCE(left(task."kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),CASE WHEN reward_status='earned' THEN 1 ELSE 0 END,reward_status,problem)
+ 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;
+END $$;
+
+CREATE OR REPLACE FUNCTION xiaoshu_review_before_write() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP='UPDATE' THEN
+   IF OLD."company" IS DISTINCT FROM NEW."company" OR OLD."objectId" IS DISTINCT FROM NEW."objectId" THEN RAISE EXCEPTION '不能修改复习任务身份'; END IF;
+   IF OLD."rewardTaskKey" IS NOT NULL OR OLD."rewardDuplicate" OR OLD."fxzt"::text='1' THEN
+     IF ROW(OLD."yhid",OLD."xxjlid",OLD."kywrq") IS DISTINCT FROM ROW(NEW."yhid",NEW."xxjlid",NEW."kywrq") THEN RAISE EXCEPTION '不能修改复习任务来源'; END IF;
+     NEW."rewardTaskKey" := OLD."rewardTaskKey"; NEW."rewardDuplicate" := OLD."rewardDuplicate";
+   ELSE
+     NEW."rewardTaskKey" := xiaoshu_review_key(NEW."yhid"::text,NEW."xxjlid"::text,left(NEW."kywrq"::text,10)); NEW."rewardDuplicate" := false;
+   END IF;
+   IF OLD."fxzt"::text='1' THEN
+     NEW."fxzt" := OLD."fxzt"; NEW."wcsj" := OLD."wcsj"; NEW."plid" := OLD."plid";
+     RETURN NEW;
+   END IF;
+   IF NEW."fxzt"::text='1' THEN
+     NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
+   END IF;
+ ELSE
+   NEW."rewardTaskKey" := xiaoshu_review_key(NEW."yhid"::text,NEW."xxjlid"::text,left(NEW."kywrq"::text,10)); NEW."rewardDuplicate" := false;
+ END IF;
+ RETURN NEW;
+END $$;
+CREATE OR REPLACE FUNCTION xiaoshu_review_after_write() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF NEW."fxzt"::text='1' THEN
+   IF TG_OP='INSERT' THEN PERFORM xiaoshu_record_review_reward(NEW,true);
+   ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1'); END IF;
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS review_reward_before_write ON "MemoryPracticeRecord";
+CREATE TRIGGER review_reward_before_write BEFORE INSERT OR UPDATE ON "MemoryPracticeRecord" FOR EACH ROW EXECUTE FUNCTION xiaoshu_review_before_write();
+DROP TRIGGER IF EXISTS review_reward_after_write ON "MemoryPracticeRecord";
+CREATE TRIGGER review_reward_after_write AFTER INSERT OR UPDATE ON "MemoryPracticeRecord" FOR EACH ROW EXECUTE FUNCTION xiaoshu_review_after_write();
+
+-- The compatibility endpoint calls this, so an unapplied migration fails closed.
+CREATE OR REPLACE FUNCTION xiaoshu_complete_review(tenant text, task_id text) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=statement_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
+ IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
+END $$;
+CREATE OR REPLACE FUNCTION xiaoshu_reward_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION '复习奖励凭据不可改写或删除,请使用工资调整'; END $$;
+DROP TRIGGER IF EXISTS review_reward_immutable ON "ReviewReward";
+CREATE TRIGGER review_reward_immutable BEFORE UPDATE OR DELETE ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION xiaoshu_reward_immutable();
+
+CREATE OR REPLACE FUNCTION xiaoshu_payroll_reward_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE batch record; reward record; tenant text; batch_id text;
+BEGIN
+ tenant := CASE WHEN TG_OP='DELETE' THEN OLD."company" ELSE NEW."company" END;
+ batch_id := CASE WHEN TG_OP='DELETE' THEN OLD."batchId" ELSE NEW."batchId" END;
+ IF TG_OP='UPDATE' AND ROW(OLD."company",OLD."batchId",OLD."kind",OLD."reviewRewardId") IS DISTINCT FROM ROW(NEW."company",NEW."batchId",NEW."kind",NEW."reviewRewardId") THEN RAISE EXCEPTION '不能修改工资明细归属'; END IF;
+ SELECT * INTO batch FROM "PayrollBatch" WHERE "company"=tenant AND "objectId"=batch_id FOR UPDATE;
+ IF batch."objectId" IS NULL OR batch."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定或不存在'; END IF;
+ IF TG_OP='DELETE' THEN RETURN OLD; END IF;
+ IF NEW."kind"='review_reward' THEN
+   SELECT * INTO reward FROM "ReviewReward" WHERE "company"=tenant AND "objectId"=NEW."reviewRewardId";
+   IF reward."status" IS DISTINCT FROM 'earned' OR reward."incomeMonth">batch."month" THEN RAISE EXCEPTION '复习奖励不可结算'; END IF;
+   IF NEW."excluded" THEN RAISE EXCEPTION '有效复习奖励不能排除,请使用工资调整'; END IF;
+   NEW."coachId" := reward."coachId"; NEW."coachName" := reward."coachName";
+   NEW."studentName" := reward."studentName"; NEW."courseName" := reward."courseName";
+   NEW."storeId" := reward."storeId"; NEW."storeName" := reward."storeName";
+   NEW."reviewTaskId" := reward."taskId"; NEW."plannedDate" := reward."plannedDate";
+   NEW."amount" := reward."amount"; NEW."rateAmount" := reward."amount"; NEW."durationHours" := 0;
+   NEW."originalMonth" := reward."incomeMonth"; NEW."settlementMonth" := batch."month"; NEW."lessonAt" := reward."completedAt";
+   NEW."exception" := ''; NEW."classType" := 0; NEW."courseCategoryName" := '复习奖励';
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS payroll_reward_guard ON "PayrollLine";
+CREATE TRIGGER payroll_reward_guard BEFORE INSERT OR UPDATE OR DELETE ON "PayrollLine" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_reward_guard();
+CREATE OR REPLACE FUNCTION xiaoshu_payroll_totals_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF OLD."status" IN ('reviewed','paid') THEN
+   IF NEW."status" IS DISTINCT FROM OLD."status" AND NOT (OLD."status"='reviewed' AND NEW."status"='paid') THEN RAISE EXCEPTION '已审核工资不可重新打开'; END IF;
+   IF ROW(NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."payableAmount",NEW."month") IS DISTINCT FROM ROW(OLD."baseAmount",OLD."reviewAmount",OLD."adjustmentAmount",OLD."payableAmount",OLD."month") THEN RAISE EXCEPTION '已审核工资金额不可修改'; END IF;
+   RETURN NEW;
+ END IF;
+ SELECT COALESCE(sum("amount") FILTER (WHERE "kind"='lesson'),0),COALESCE(sum("amount") FILTER (WHERE "kind"='review_reward'),0),COALESCE(sum("amount") FILTER (WHERE "kind"='adjustment'),0),count(*) FILTER (WHERE "kind"='review_reward'),count(*) FILTER (WHERE "kind"='lesson'),COALESCE(sum("durationHours") FILTER (WHERE "kind"='lesson'),0)
+ INTO NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."reviewCount",NEW."lessonCount",NEW."totalHours"
+ FROM "PayrollLine" WHERE "company"=NEW."company" AND "batchId"=NEW."objectId" AND COALESCE("excluded",false)=false AND COALESCE("exception",'')='';
+ NEW."payableAmount" := NEW."baseAmount"+NEW."reviewAmount"+NEW."adjustmentAmount";
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS payroll_totals_guard ON "PayrollBatch";
+CREATE TRIGGER payroll_totals_guard BEFORE UPDATE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_totals_guard();
+CREATE OR REPLACE FUNCTION xiaoshu_payroll_delete_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF OLD."status" IN ('reviewed','paid') OR EXISTS (SELECT 1 FROM "PayrollLine" WHERE "company"=OLD."company" AND "batchId"=OLD."objectId") THEN RAISE EXCEPTION '工资批次已锁定或仍有明细,不能删除'; END IF;
+ RETURN OLD;
+END $$;
+DROP TRIGGER IF EXISTS payroll_delete_guard ON "PayrollBatch";
+CREATE TRIGGER payroll_delete_guard BEFORE DELETE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_delete_guard();
+COMMIT;

+ 123 - 0
scripts/tests/review-rewards.test.mjs

@@ -0,0 +1,123 @@
+import { test, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { PGlite } from '@electric-sql/pglite';
+import vm from 'node:vm';
+const db = new PGlite();
+const migration = readFileSync(new URL('../sql/review-rewards.sql', import.meta.url),'utf8');
+const query = async (sql, params=[]) => (await db.query(sql,params)).rows;
+const one = async (sql,params=[]) => (await query(sql,params))[0];
+const complete = (id) => query('SELECT xiaoshu_complete_review($1,$2)',['c',id]);
+async function task(id, day, extra={}) {
+ const {coach='10',status='0',study='100',student='20'}=extra;
+ await query('INSERT INTO "MemoryPracticeRecord" ("objectId","company","id","plid","yhid","xxjlid","kywrq","kywsj","kcid","fxzt","wcsj") VALUES ($1,\'c\',1,$2,$3,$4,$5,$5,30,$6,\'2026-08-01 10:00:00\')',[id,coach,student,study,day,status]);
+}
+before(async()=>{
+ await db.exec(`CREATE TABLE "_User" ("objectId" text,"company" text,"legacyUserId" double precision,"legacyUserData" jsonb,"realName" text,"nickname" text,"username" text);
+ CREATE TABLE "Node" ("company" text,"nodeId" double precision,"nodeName" text);
+ CREATE TABLE "CommonModel" ("objectId" text,"company" text,"modelId" double precision,"generalId" double precision);
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"plid" text,"yhid" text,"xxjlid" text,"kywrq" text,"kywsj" text,"kcid" double precision,"fxzt" text,"wcsj" text,"updatedAt" timestamptz);
+ CREATE TABLE "PayrollBatch" ("objectId" text PRIMARY KEY,"company" text,"month" text,"status" text,"baseAmount" double precision,"adjustmentAmount" double precision,"payableAmount" double precision,"totalHours" double precision,"lessonCount" double precision);
+ CREATE TABLE "PayrollLine" ("objectId" text PRIMARY KEY,"company" text,"batchId" text,"kind" text,"excluded" boolean,"coachId" double precision,"coachName" text,"studentName" text,"courseName" text,"storeId" double precision,"storeName" text,"amount" double precision,"rateAmount" double precision,"durationHours" double precision,"originalMonth" text,"settlementMonth" text,"lessonAt" timestamptz,"exception" text,"classType" double precision,"courseCategoryName" text);
+ INSERT INTO "_User" VALUES ('teacher','c',10,'{"ParentUserID":40}','老师','','teacher'),('student','c',20,'{}','学生','','student'),('store','c',40,'{}','门店','','store');
+ INSERT INTO "Node" VALUES ('c',30,'单词课'); INSERT INTO "CommonModel" VALUES ('study','c',56,100);`);
+ await task('history','2026-08-01',{status:'1'});
+ await task('dup1','2026-08-02');await task('dup2','2026-08-02');
+ await db.exec(migration);
+});
+after(async()=>db.close());
+test('历史完成零金额,重复完成和重复迁移不补发', async()=>{
+ await complete('history');await db.exec(migration);
+ const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'history\'');assert.equal(r.status,'historical');assert.equal(Number(r.amount),0);
+ assert.equal((await one('SELECT "wcsj" FROM "MemoryPracticeRecord" WHERE "objectId"=\'history\'')).wcsj,'2026-08-01 10:00:00');
+});
+test('首次完成一元,快照老师和北京时间月份;并发重试不改变完成时间',async()=>{
+ await task('first','2026-09-01');await Promise.all([complete('first'),complete('first'),complete('first')]);
+ const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');assert.equal(Number(r.amount),1);assert.equal(r.coachId,10);assert.equal(r.incomeMonth,new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}).slice(0,7));
+ await query('UPDATE "MemoryPracticeRecord" SET "fxzt"=\'0\',"wcsj"=\'2099-01-01\',"plid"=\'999\' WHERE "objectId"=\'first\'');
+ await complete('first');const again=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');assert.deepEqual(again,r);
+ const m=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'first\'');assert.equal(m.fxzt,'1');assert.equal(m.plid,'10');assert.notEqual(m.wcsj,'2099-01-01');
+ await assert.rejects(query('UPDATE "ReviewReward" SET "amount"=2 WHERE "taskId"=\'first\''),/不可改写/);
+});
+test('独立计划分别奖励;新任务生成重复被数据库拒绝',async()=>{
+ await task('next','2026-09-03');await complete('next');
+ await assert.rejects(task('next-copy','2026-09-03'),/unique/);
+ assert.equal(Number((await one('SELECT SUM("amount") amount FROM "ReviewReward" WHERE "status"=\'earned\'')).amount),2);
+});
+test('历史重复来源和缺失老师、来源的记录不计薪',async()=>{
+ await complete('dup1');await complete('dup2');await task('missing-coach','2026-09-04',{coach:'999'});await complete('missing-coach');await task('missing-source','2026-09-05',{study:'999'});await complete('missing-source');
+ const rows=await query('SELECT * FROM "ReviewReward" WHERE "taskId"=ANY($1::text[])',[['dup1','dup2','missing-coach','missing-source']]);assert.equal(rows.length,4);assert.ok(rows.every(r=>r.status==='exception'&&Number(r.amount)===0));
+});
+test('已完成导入任务进入历史待核对',async()=>{
+ await task('import','2026-09-06',{status:'1'});await complete('import');assert.equal((await one('SELECT "status" FROM "ReviewReward" WHERE "taskId"=\'import\'')).status,'historical');
+});
+test('奖励插入失败时完成状态一起回滚',async()=>{
+ await task('rollback','2026-09-07');
+ await db.exec(`CREATE FUNCTION test_fail_reward() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW."taskId"='rollback' THEN RAISE EXCEPTION 'injected failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_fail_reward BEFORE INSERT ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION test_fail_reward();`);
+ await assert.rejects(complete('rollback'),/injected failure/);assert.equal((await one('SELECT "fxzt" FROM "MemoryPracticeRecord" WHERE "objectId"=\'rollback\'')).fxzt,'0');
+ await db.exec('DROP TRIGGER test_fail_reward ON "ReviewReward"');await complete('rollback');
+});
+test('跨月批次防重,数据库校正金额,审核后禁止变更和删除',async()=>{
+ const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');
+ await query('INSERT INTO "PayrollBatch" ("objectId","company","month","status") VALUES (\'batch1\',\'c\',$1,\'draft\'),(\'batch2\',\'c\',\'2099-01\',\'draft\')',[r.incomeMonth]);
+ const add=(id,batch,reward=r.objectId)=>query('INSERT INTO "PayrollLine" ("objectId","company","batchId","kind","reviewRewardId","amount","coachId") VALUES ($1,\'c\',$2,\'review_reward\',$3,999,999)',[id,batch,reward]);
+ await add('line1','batch1');await assert.rejects(add('line2','batch2'),/unique/);
+ const line=await one('SELECT * FROM "PayrollLine" WHERE "objectId"=\'line1\'');assert.equal(line.amount,1);assert.equal(line.coachId,10);
+ await query('UPDATE "PayrollBatch" SET "status"=\'reviewed\',"payableAmount"=999 WHERE "objectId"=\'batch1\'');
+ const batch=await one('SELECT * FROM "PayrollBatch" WHERE "objectId"=\'batch1\'');assert.equal(batch.payableAmount,1);assert.equal(batch.reviewCount,1);assert.equal(batch.lessonCount,0);
+ await assert.rejects(query('DELETE FROM "PayrollLine" WHERE "objectId"=\'line1\''),/已锁定/);
+ await assert.rejects(query('UPDATE "PayrollLine" SET "excluded"=true WHERE "objectId"=\'line1\''),/已锁定/);
+ await assert.rejects(query('UPDATE "PayrollBatch" SET "status"=\'draft\' WHERE "objectId"=\'batch1\''),/不可重新打开/);
+ await query('UPDATE "PayrollBatch" SET "status"=\'paid\' WHERE "objectId"=\'batch1\'');
+ await assert.rejects(query('DELETE FROM "PayrollBatch" WHERE "objectId"=\'batch1\''),/不能删除/);
+ const history=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'history\'');await assert.rejects(add('history-line','batch2',history.objectId),/不可结算/);
+});
+test('老师端收入、奖励列表和工资来源同一凭据;分页筛选与跨月未结算',async()=>{
+ const ctx={Psql:{query,one},Date};vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),ctx);
+ const total=await ctx.reviewIncomeTotal('c',10);assert.equal(total,3);
+ const page=await ctx.reviewRewardPage('c',{search:'老师',coachId:10,status:'earned',page:1,pageSize:2});assert.equal(page.total,3);assert.equal(page.items.length,2);assert.equal(page.summary.reviewAmount,3);
+ const pending=await ctx.reviewRewardPayrollLines('c','2099-01',false);assert.equal(pending.length,2);assert.ok(pending.every(r=>r.reviewTaskId!=='first'));
+ assert.equal(await ctx.reviewIncomeTotal('other',10),0);
+});
+
+test('工资汇总分开课酬、奖励与调整;筛选和导出不泄漏其他老师或排除项',()=>{
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const code=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+ const context={};vm.createContext(context);vm.runInContext(code,context);
+ const base={coachId:10,coachName:'老师',storeId:40,storeName:'门店',durationHours:0,amount:0,classType:0,lessonAt:'2026-09-09T01:00:00.000Z'};
+ const lines=[{...base,kind:'lesson',amount:40,durationHours:1,classType:2},{...base,kind:'review_reward',amount:1},{...base,kind:'adjustment',amount:-5},{...base,kind:'review_reward',amount:1,excluded:true},{...base,coachId:99,coachName:'其他',storeId:80,kind:'review_reward',amount:1}];
+ const summary=context.payrollResult('2026-09',lines,{refreshedAt:''},{storeId:40});
+ assert.equal(summary.baseAmount,40);assert.equal(summary.reviewAmount,1);assert.equal(summary.reviewCount,1);assert.equal(summary.adjustmentAmount,-5);assert.equal(summary.payableAmount,36);assert.equal(summary.lessonCount,1);assert.equal(summary.totalHours,1);assert.equal(summary.lines.length,3);assert.equal(summary.coachItems[0].payableAmount,36);
+});
+
+test('实际工资草稿刷新路径反复调用仍只有两条待结算奖励,审核不重复吸收',async()=>{
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const context={Date,Psql:{query,one},Parse:{}};vm.createContext(context);
+ vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),context);
+ let sequence=0;
+ const columns={};for(const table of ['PayrollBatch','PayrollLine'])columns[table]=(await query('SELECT column_name FROM information_schema.columns WHERE table_name=$1',[table])).map(r=>r.column_name);
+ class Model {
+   constructor(table,data={}){this.table=table;this.values={...data};this.id=data.objectId;this.updatedAt=new Date();}
+   get(key){return this.values[key];} set(key,value){this.values[key]=value;return this;}
+   toJSON(){return {...this.values,objectId:this.id};}
+   async save(){
+     const exists=Boolean(this.id);if(!this.id)this.id='generated-line-'+(++sequence);
+     const values={...this.values,objectId:this.id},keys=Object.keys(values).filter(key=>columns[this.table].includes(key)&&values[key]!==undefined);
+     const args=keys.map(key=>values[key]);
+     const statement=exists?`UPDATE "${this.table}" SET ${keys.map((key,i)=>`"${key}"=$${i+1}`).join(',')} WHERE "objectId"=$${keys.length+1} RETURNING *`:`INSERT INTO "${this.table}" (${keys.map(k=>'"'+k+'"').join(',')}) VALUES (${keys.map((_,i)=>'$'+(i+1)).join(',')}) RETURNING *`;
+     this.values=await one(statement,exists?[...args,this.id]:args);return this;
+   }
+ }
+ context.Parse.Object=Model;
+ context.requireRole=()=>{};context.audit=async()=>{};context.companyIdOf=()=> 'c';context.companyPointer=()=> 'c';
+ context.payrollBatchObject=async(_context,_input,id)=>new Model('PayrollBatch',await one('SELECT * FROM "PayrollBatch" WHERE "objectId"=$1',[id]));
+ context.payrollLinesForBatch=async(_context,_input,id)=>(await query('SELECT * FROM "PayrollLine" WHERE "batchId"=$1',[id])).map(row=>new Model('PayrollLine',row));
+ context.payrollPreviewData=async(_context,input,month,includeUsed)=>context.payrollResult(month,await context.reviewRewardPayrollLines('c',month,includeUsed),{refreshedAt:new Date().toISOString()},input);
+ const input={targetId:'batch2',reason:'测试增量刷新'};
+ await context.refreshPayrollDraft({},input);await context.refreshPayrollDraft({},input);
+ assert.equal((await one('SELECT COUNT(*)::int count FROM "PayrollLine" WHERE "batchId"=\'batch2\'')).count,2);
+ assert.equal((await one('SELECT "payableAmount" FROM "PayrollBatch" WHERE "objectId"=\'batch2\'')).payableAmount,2);
+ await context.payrollWorkflow({current:{id:'manager'}},input,'review',{});
+ assert.equal((await one('SELECT "status" FROM "PayrollBatch" WHERE "objectId"=\'batch2\'')).status,'reviewed');
+ assert.equal((await context.reviewRewardPayrollLines('c','2099-02',false)).length,0);
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов