Преглед на файлове

fix(payroll): unify wage sources and enforce atomic settlement

彭峰 преди 2 седмици
родител
ревизия
98d1dac658

+ 49 - 0
docs/features/payroll-integrity.md

@@ -0,0 +1,49 @@
+# 工资结算事务与核对修复
+
+## 计薪和接口
+
+`payroll-integrity.js` 在运营网关最后加载,覆盖旧工资实现而保留原接口路径。旧系统上课接口只保留作独立核对用途,不再替代新库计薪来源。
+
+- `xs_payroll_sources` 读取 LessonRecord,关联预约和 CommonModel;LessonRecordFact 只补充新库没有的标准课次。作废记录不能因历史投影重新进入计薪。
+- 工资优先取完课快照、再取预约快照,零元有效;无快照的历史记录才按上课日期选择规则。工资月份、规则日期、导出日期采用北京时间。
+- 来源版本覆盖课次、分类映射、新旧工资规则。创建、重算、审核提交时重新检查版本;当前草稿参与重算,其他批次占用的来源排除。来源失效的既有课次保留异常,不能静默丢失。
+- `xs_payroll_command` 在帐套事务锁下执行创建、重算、审核、调整、排除与发放登记。请求编号、批次版本、明细、金额汇总与 SysLog 审计同事务处理。网络重试返回原结果。
+- 普通课次和抗遗忘凭据均有数据库有效计薪唯一约束;已审核和已发放批次锁定。异常未处理不能审核;发放方式与凭证编号必填。
+- 调整金额校验到分,固定老师、门店与结算月份,采用独立老师列表;无当月收入的老师也可以选择。
+- 工资规则同日修订新增不可变版本;旧课型单价兼容接口也不再覆盖旧行。新版本不修改预约或完课快照。
+- 经理可财务写入;员工、审计员只读;未知运营角色拒绝访问。财务写入失败或审计失败整体回滚。
+
+## 页面与其他业务
+
+- 当月收入与批次结算应发分别展示,结算卡显示人工调整及补结金额。老师收入明细遵循当月收入口径,打开批次后展示该批次结算明细,导出沿用当前口径。
+- 导出统一门店、搜索、老师筛选范围,计薪、异常、已排除记录分表并附金额核对。排除原因可查看;金额不含已排除明细。
+- 抗遗忘导出使用固定创建时间边界和 objectId 游标,客户端验证总数及去重。首次完成 1 元、历史任务不自动补发等规则保持。
+- 工资批次完整分页;查询及明细响应有顺序保护;重置保留月份,清空门店、搜索、老师与抗遗忘记录类型。部分接口失败时显示具体区域,并说明保留旧结果。
+- 数据来源显示“新数据库”及北京时间,不再把新库结果标为缓存。历史课次的门店名称按保存的门店编号解析。
+- 历史预约预览为纯查询;补建使用账户事务锁并再次核对预约版本、规则与余额,预约、预留、审计原子写入。
+- 复习课课时退差额接入账户流水事务,扣除已退金额、同预约只退一次,预览展示已处理状态。
+- 课程映射新增“按学员核对语法写作”入口,逐预约确认小学/中学/高中及证据。保留此前 79 条确认结果,通用“阅读写作”不能全局映射为单一学段。旧预约状态清理不重做。
+
+## 安装与发布
+
+1. 先安装 `member-enrollment.sql`、`review-rewards.sql`。
+2. 设置服务端 `XIAOSHU_MASTER_KEY`、`XIAOSHU_COMPANY_ID` 和私有 `XIAOSHU_PAYROLL_REPORT_DIR`;执行 `npm run payroll:integrity:migrate -- --apply`。
+3. 迁移先备份现有批次、明细、规则;数据库约束发现历史重复时整体失败,不能删除已结算数据来通过安装。
+4. `node scripts/deploy-payroll-integrity.mjs` 只补丁发布现有运营 V1、V3 和管理身份网关,保留其他任务的共享函数修改。发布前备份、检查 updatedAt,发布后核对源码 SHA-256。完整云函数部署同样会加载此模块。
+5. 发布管理端构建。后端上线不等于公网静态前端已发布。
+
+`PayrollCommand` 和 `PayrollReconciliation` 是服务端内部事务表,不通过浏览器暴露直接写入入口。迁移脚本产生的真实业务清单和函数备份保存在私有目录,不提交 Git。
+
+## 2026-09-09 验证结果
+
+- 工资事务回归 16 项通过;抗遗忘、课程映射和账户相关回归 39 项通过。
+- 管理端 186 项、移动端 92 项测试通过;两个生产构建和 11 个云函数定义校验通过。构建保留既有体积及 CommonJS 优化警告。
+- 生产数据库迁移完成,三个网关补丁部署后源码核对一致。
+- 线上读取 18,797 条标准完课来源:旧事实投影缺失 118 条,UTC 与北京时间月份不同 6 条。它们是待核对的来源差异,不等于应补发工资。
+- 迁移前真实工资批次和明细均为 0,因此不存在本次自动改写已审核或已发放工资单的情况;也没有自动补发历史抗遗忘工资。
+- 九月统计及导出均为 4 条课酬、90 元;抗遗忘列表含 78 条历史记录和 1 条异常,本月可计薪金额 0。课程/规则、老师列表、按学员映射、预留与退差额预览均返回成功。
+- 审计员读取成功、创建工资被 403 拒绝;未知角色读取被 403 拒绝。
+- 生产数据库回滚测试覆盖创建重试、调整重试、跨批次课次防重、异常审核拒绝、审核锁定、50 元发放登记及事务审计。全部测试写入已回滚,没有实际转账。
+- 独立 HTTP 请求并发验证覆盖同一调整请求只入账一次、过期版本拒绝、两个批次竞争同课次仅一个成功。隔离测试数据和临时访问均已清理。
+
+本轮未进行真实老师工资审批或真实付款;独立连接的“双人同时审核同一批次”未作生产写入演练,审核锁定和异常拒绝有数据库回归与生产回滚测试证据。公网前端托管配置及凭据未找到,当前只确认本地管理端运行、构建可发布;不宣称公网前端已上线。

+ 3 - 1
package.json

@@ -46,7 +46,9 @@
     "review-rewards:migrate": "node scripts/migrate-review-rewards.mjs",
     "test:member-enrollment": "node --test scripts/tests/member-enrollment.test.mjs scripts/tests/member-enrollment-gateway.test.mjs",
     "members:migrate": "node scripts/migrate-member-enrollment.mjs",
-    "members:smoke": "node scripts/smoke-member-enrollment.mjs"
+    "members:smoke": "node scripts/smoke-member-enrollment.mjs",
+    "test:payroll-integrity": "node --test scripts/tests/payroll-integrity.test.mjs",
+    "payroll:integrity:migrate": "node scripts/migrate-payroll-integrity.mjs"
   },
   "private": true,
   "dependencies": {

+ 7 - 2
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -756,6 +756,7 @@ export interface CreditReservationReconciliation {
 export type PayrollStatus = 'draft' | 'reviewed' | 'paid';
 
 export interface PayrollSummary extends Record<string, unknown> {
+  version?: number;
   objectId: string;
   month: string;
   status: PayrollStatus;
@@ -811,6 +812,7 @@ export interface PayrollLine extends Record<string, unknown> {
   description: string;
   exception: string;
   excluded?: boolean;
+  exclusionReason?: string;
 }
 
 export interface PayrollDetail extends PayrollSummary {
@@ -844,13 +846,14 @@ export interface PayrollCoachSummary extends Record<string, unknown> {
 
 export interface PayrollSourceStatus extends Record<string, unknown> {
   available: boolean;
-  mode: 'live' | 'cache';
+  mode: 'live' | 'cache' | 'parse';
   message: string;
   total: number;
   refreshedAt: string;
 }
 
 export interface PayrollOverview extends PayrollDetail {
+  incomeAmount?: number; settlementAmount?: number | null; settlementAdjustmentAmount?: number; settlementCarryAmount?: number;
   coachItems: PayrollCoachSummary[];
   sourceStatus: PayrollSourceStatus;
   batches: PayrollSummary[];
@@ -858,6 +861,7 @@ export interface PayrollOverview extends PayrollDetail {
 }
 
 export interface PayrollExportData extends Record<string, unknown> {
+  excluded?: PayrollLine[];
   fileName: string;
   summary: PayrollSummary;
   coachItems: PayrollCoachSummary[];
@@ -962,7 +966,8 @@ export interface ReviewReward {
   statusLabel: string; exception: string; batchId?: string; settlementMonth?: string;
 }
 export interface ReviewRewardPage {
+  snapshot?: string; nextCursor?: string;
   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; }
+export interface ReviewRewardFilters { month: string; storeId: number; search: string; coachId: number; revision?: number; resetRevision?: number; snapshot?: string; afterId?: string; exportMode?: boolean; }

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

@@ -104,8 +104,8 @@ export class OperationsService {
     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 });
+  payrolls(page = 1, pageSize = 20, month = ''): Promise<OperationsPage<PayrollSummary>> {
+    return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize, month });
   }
 
   payrollPreview(month: string): Promise<PayrollDetail> {
@@ -120,8 +120,8 @@ export class OperationsService {
     return this.functions.operations<PayrollDetail>('ops/payroll/detail', { targetId });
   }
 
-  payrollExport(month: string, targetId = '', storeId = 0, search = ''): Promise<PayrollExportData> {
-    return this.functions.operations<PayrollExportData>('ops/payroll/export', { month, targetId, storeId, search });
+  payrollExport(month: string, targetId = '', storeId = 0, search = '', coachId = 0): Promise<PayrollExportData> {
+    return this.functions.operations<PayrollExportData>('ops/payroll/export', { month, targetId, storeId, search, coachId });
   }
 
   vocabularyTree(): Promise<VocabularyTree> {

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

@@ -1,33 +1,37 @@
 <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()==='rewards'" (click)="openRewards()"><lucide-icon [img]="icons.Brain" [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]="tab()==='rewards'?'老师姓名或 ID':'姓名或手机号'" (keyup.enter)="applyFilters()" /></label><div class="filter-actions"><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><button class="ui-button secondary reset-action" type="button" (click)="resetFilters()">重置</button></div><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()!=='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><div class="filter-actions"><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><button class="ui-button secondary reset-action" type="button" (click)="resetFilters()">重置</button></div><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'" role="status"><i></i>{{overview()?.sourceStatus?.mode==='parse'?'新数据库':overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss':'+0800'}}</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.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>
+  <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><span>批次结算应发</span><strong>{{data.settlementAmount==null?'未生成':'¥'+(data.settlementAmount|number:'1.2-2')}}</strong><small>调整 ¥{{data.settlementAdjustmentAmount||0|number:'1.2-2'}} · 补结 ¥{{data.settlementCarryAmount||0|number:'1.2-2'}}</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"><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><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>
+ <section class="content-card"><header><div><strong>{{allBatchMonths()?'全部月份':month()}} 全公司工资批次</strong><span>每月仅一张;门店筛选不会拆分工资单</span><button class="ui-button secondary compact" (click)="allBatchMonths.set(true);loadBatchPage(1)">查看全部月份</button></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>
+ @if(page();as batches){<div class="filter-actions"><span>共 {{batches.total}} 个批次 · 第 {{batches.page}} 页</span><button class="ui-button secondary" (click)="loadBatchPage(batches.page-1)" [disabled]="batches.page<=1">上一页</button><button class="ui-button secondary" (click)="loadBatchPage(batches.page+1)" [disabled]="batches.page*batches.pageSize>=batches.total">下一页</button></div>}
 } @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>
   <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'){
+  <button class="ui-button secondary" type="button" (click)="loadWritingMappings()">按学员核对语法写作</button>
+ @if(standardsSection()==='students'){<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></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据'}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><input [(ngModel)]="row.evidence" placeholder="年级记录或人工核实依据" /></td><td>@if(isManager()){<button class="ui-button secondary" (click)="saveWritingMapping(row)" [disabled]="saving()||!row.categoryKey||!row.evidence.trim()">确认</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
+ @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)="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'){
+  <details class="content-card"><summary>工资规则修订记录</summary><div class="table-scroll"><table class="data-table"><thead><tr><th>分类</th><th>时长 / 方式</th><th>生效日期</th><th>工资</th><th>状态</th></tr></thead><tbody>@for(rule of standards()?.rules||[];track rule.objectId){@if(!rule.isDefault){<tr><td>{{rule.categoryName}}</td><td>{{rule.durationMinutes}}分钟 · {{rule.deliveryLabel}}</td><td>{{rule.effectiveFrom}}</td><td>¥{{rule.teacherPay|number:'1.2-2'}}</td><td>{{rule['superseded']?'历史版本':rule.active?'当前版本':'已停用'}}</td></tr>}}</tbody></table></div></details>
+ } @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><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]="!mappingCategories(row).length">@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()||!mappingCategories(row).length">{{savingMappingId()===row.courseId?'确认中…':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="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>}
+   @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':'+0800'}}</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>}
    </section>
   } @else {
    <section class="content-card reconciliation-card"><header><div><strong>2026 年 9 月起复习课课时差异</strong><span>只预览新规则生效后的已完课记录;确认前不会修改余额</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reconciliation()?.summary?.differenceCount){<button class="ui-button primary" type="button" (click)="commitReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认退回差额</button>}</div></header>
@@ -37,7 +41,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.kind==='review_reward'?'抗遗忘工资':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>}}
+ <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':'+0800'}}</td><td>{{line.kind==='review_reward'?'抗遗忘工资':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['exclusionReason']||'未填写原因'):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 adjustmentCoaches();track coach.coachId){<option [ngValue]="coach.coachId">{{coachLabel(coach.coachName,coach.coachId)}}</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()" [disabled]="saving()">添加调整</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>
 }

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

@@ -38,3 +38,47 @@ describe('operations payroll teaching standards', () => {
     ]);
   });
 });
+
+import { TestBed } from '@angular/core/testing';
+import { OperationsPayrollComponent } from './operations-payroll.component';
+import { OperationsService } from '../operations.service';
+import { AdminSessionService } from '../admin-session.service';
+import { PayrollOverview, PayrollSummary } from '../admin.models';
+
+describe('payroll interactions', () => {
+ let component:OperationsPayrollComponent;
+ let service:jasmine.SpyObj<OperationsService>;
+ const overview=(month:string)=>({month,lines:[],coachItems:[],sourceStatus:{available:true,mode:'parse'},stores:[]} as unknown as PayrollOverview);
+ beforeEach(()=>{
+  service=jasmine.createSpyObj('OperationsService',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate','payrollDetail']);
+  service.payrollOverview.and.resolveTo(overview('2026-09'));service.payrolls.and.resolveTo({items:[],total:0,page:1,pageSize:20});service.payRates.and.resolveTo({items:[],total:0,page:1,pageSize:100});
+  service.teachingStandards.and.resolveTo({categories:[],rules:[],mappings:[],summary:{courseCount:0,confirmedCount:0,unmappedCount:0,activeRuleCount:0},refreshedAt:''});service.request.and.resolveTo({items:[]});
+  TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:service},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});
+  component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
+ });
+ it('ignores an older query response after month changes',async()=>{
+  let resolveOld!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValues(new Promise(r=>resolveOld=r),Promise.resolve(overview('2026-10')));
+  const old=component.load();component.month.set('2026-10');await component.load();resolveOld(overview('2026-09'));await old;expect(component.overview()?.month).toBe('2026-10');
+ });
+ it('reset keeps month and clears search, store and the teacher deep link',async()=>{
+  component.month.set('2026-08');component.storeId.set(10);component.search.set('老师');component.rewardCoachId.set(30);component.openRewards();await component.resetFilters();expect(component.rewardFilters()).toEqual(jasmine.objectContaining({month:'2026-08',storeId:0,search:'',coachId:0,resetRevision:1}));
+ });
+ it('retains adjustment request identity after a lost response and sends batch version',async()=>{
+  component.detail.set({objectId:'batch',version:3,status:'draft'} as never);component.adjustmentCoachId.set(30);component.adjustmentAmount.set(10);component.adjustmentReason.set('补计工资');service.mutate.and.rejectWith(new Error('网络中断'));
+  await component.addAdjustment();await component.addAdjustment();const calls=service.mutate.calls.allArgs();expect(calls.length).toBe(2);expect(calls[0][5]).toBe(calls[1][5]);expect(calls[0][3]).toEqual(jasmine.objectContaining({version:3,amount:10,coachId:30}));
+ });
+ it('does not submit an adjustment twice while saving',async()=>{
+  component.saving.set(true);await component.addAdjustment();expect(service.mutate).not.toHaveBeenCalled();
+ });
+ it('loads batch pages independently of income and teacher filters',async()=>{
+  await component.loadBatchPage(2);expect(service.payrolls).toHaveBeenCalledWith(2,20,'');component.allBatchMonths.set(false);component.month.set('2026-08');await component.loadBatchPage(1);expect(service.payrolls).toHaveBeenCalledWith(1,20,'2026-08');
+ });
+ it('keeps successful regions when a secondary request fails',async()=>{
+  service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用'));
+  await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('工资标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
+ });
+ it('ignores an older batch page response',async()=>{
+  let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));
+  const old=component.loadBatchPage(2);await component.loadBatchPage(3);resolveOld({items:[],total:50,page:2,pageSize:20});await old;expect(component.page()?.page).toBe(3);
+ });
+});

Файловите разлики са ограничени, защото са твърде много
+ 3 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


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

@@ -27,4 +27,6 @@ describe('ReviewRewardsComponent',()=>{
     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();
   });
+  it('clears record type only for an explicit reset', async()=>{const fixture=TestBed.createComponent(ReviewRewardsComponent),component=fixture.componentInstance;component.filters={month:'2026-09',coachId:0,storeId:0,search:''};component.status.set('historical');fixture.componentRef.setInput('filters',{...component.filters,resetRevision:1});fixture.detectChanges();await fixture.whenStable();expect(component.status()).toBe('');});
+
 });

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

@@ -10,9 +10,9 @@ 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 requestId=0;private resetRevision=0;
   private readonly operations=inject(OperationsService);
-  ngOnChanges():void { void this.load(); }
+  ngOnChanges():void { if((this.filters.resetRevision||0)!==this.resetRevision){this.resetRevision=this.filters.resetRevision||0;this.status.set('');}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); }
@@ -21,13 +21,13 @@ export class ReviewRewardsComponent implements OnChanges {
   }
   async export():Promise<void> {
     if(this.exporting())return;this.exporting.set(true);this.error.set('');
-    const filters={...this.filters},status=this.status();
+    const filters={...this.filters,exportMode:true,snapshot:'',afterId:''},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 rows:Record<string,unknown>[]=[];let page=1,total=0;const seen=new Set<string>();
+      do {const data=await this.operations.reviewRewards(filters,page,status,100);total=data.total;if(page===1)filters.snapshot=data.snapshot||'';if(!filters.snapshot)throw new Error('导出快照未返回,请刷新重试');for(const item of data.items){if(seen.has(item.objectId))throw new Error('导出记录重复,请重新导出');seen.add(item.objectId);}
+        rows.push(...data.items.map(row=>({'计薪凭据':row.objectId,'复习任务':row.taskId,'老师':row.coachName,'门店':row.storeName,'学生':row.studentName,'课程':row.courseName,'计划日期':row.plannedDate,'首次完成时间':row.completedAt?new Date(row.completedAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'','收入月份':row.incomeMonth,'工资金额':row.amount,'结算状态':row.statusLabel,'结算月份':row.settlementMonth||'','异常原因':row.exception})));
+        filters.afterId=data.nextCursor||'';if(!filters.afterId)break;page++;
+      }while(filters.afterId);if(rows.length!==total)throw new Error('导出记录不完整,请重新导出');
       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);}
   }

+ 99 - 0
scripts/cloud/payroll-integrity.js

@@ -0,0 +1,99 @@
+// Payroll entry points share canonical sources and atomic SQL commands.
+function payrollBusinessDate(value) {
+  const iso=payrollDateIso(value);return iso?new Date(iso).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'';
+}
+function payrollDateIso(value) {
+  if(value instanceof Date)return Number.isNaN(value.getTime())?'':value.toISOString();
+  const raw=text(value?.iso||value);if(!/^\d{4}-\d{1,2}-\d{1,2}/.test(raw))return '';
+  let normalized=raw.replace(' ','T');if(!normalized.includes('T'))normalized+='T00:00:00';
+  if(!/(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(normalized))normalized+='+08:00';
+  const date=new Date(normalized);return Number.isNaN(date.getTime())?'':date.toISOString();
+}
+function payrollTenant(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'工资结算必须指定帐套');return co;}
+async function payrollSql(sql,args){try{return await Psql.one(sql,args);}catch(error){fail(/does not exist/.test(text(error.message))?503:409,text(error.message).replace(/^.*ERROR:\s*/,''));}}
+function payrollIdentity(item){return text(item.standardLessonKey)||(number(item.legacyGeneralId)?'legacy:'+number(item.legacyGeneralId):'lesson:'+item.objectId);}
+async function payrollCanonicalSnapshot(context,input) {
+  const co=payrollTenant(context,input);
+  const snapshot=await payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]);
+  const users=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1',[co]);
+  const map=new Map(users.map(u=>[number(u.legacyUserId||legacyOf(u).UserID),u]));
+  const items=(snapshot.rows||[]).map(row=>{
+    const item=payrollSourceProjection(row,'cache'),coach=map.get(item.coachId),student=map.get(item.studentId),storeId=item.storeId||number(coach&&legacyOf(coach).ParentUserID),store=map.get(storeId);
+    return {...item,objectId:text(row.lessonObjectId||row.objectId),standardLessonKey:row.standardLessonKey,source:row.source,commonStatus:number(row.commonStatus,99),
+      coachName:coach?displayName(coach):item.coachName,coachObjectId:coach?coach.objectId:'',coachMobile:coach?mobileOf(coach):'',
+      studentName:student?displayName(student):item.studentName,storeId,storeName:store?displayName(store):item.storeName||'未绑定门店',
+      teacherPaySnapshot:row.teacherPaySnapshot==null?null:Number(row.teacherPaySnapshot)};
+  });
+  return {items,version:snapshot.version,sourceStatus:{available:true,mode:'parse',message:'新数据库有效完课记录(含历史补充)',total:items.length,refreshedAt:new Date().toISOString(),complete:true,stale:false,cacheHit:false}};
+}
+async function payrollSourceRows(context,input,month,requireLive=false){const data=await payrollCanonicalSnapshot(context,input);return {...data,items:data.items.filter(i=>payrollBusinessDate(i.lessonAt).slice(0,7)===month)};}
+function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
+ const date=payrollBusinessDate(item.lessonAt),type=number(item.classType),mapping=mappings.get(item.courseId),categoryKey=text(item.courseCategoryKey||mapping?.categoryKey),categoryName=text(item.courseCategoryName||mapping?.categoryName),minutes=number(item.durationMinutes)||durationMinutes(type),mode=deliveryMode(item.deliveryMode),configured=date>='2026-09-01',rule=configured?teachingRuleFor(rules,categoryKey,minutes,mode,date):rateFor(rates,type,date),snapshot=item.teacherPaySnapshot!=null&&Number.isFinite(Number(item.teacherPaySnapshot)),amount=snapshot?Number(item.teacherPaySnapshot):configured?number(rule?.teacherPay):type===4?0:number(rule?.amount);
+ const exception=item.commonStatus===-2?'上课记录已作废':!item.coachId?'缺少陪练身份':!date?'缺少有效上课日期':item.duplicate?'来源课次重复':configured&&!categoryKey?'课程未确认标准分类':!snapshot&&configured&&!rule?'课程分类、时长和授课方式缺少有效计价规则':!snapshot&&!configured&&type!==4&&!rule?'课型未配置有效单价':amount<0?'课酬金额无效':'';
+ return {...item,kind:'lesson',standardLessonKey:payrollIdentity(item),lessonId:item.objectId,lessonAt:payrollDateIso(item.lessonAt),originalMonth:date.slice(0,7),settlementMonth,classType:type,classTypeLabel:categoryName||classType(type),courseCategoryKey:categoryKey,courseCategoryName:categoryName,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),durationMinutes:minutes,durationHours:configured?minutes/60:type===4?0:number(rule?.durationHours,minutes/60),teachingRuleId:text(item.teachingRuleId||rule?.objectId),rateAmount:amount,amount:exception?0:amount,description:snapshot?'按已保存的完课/预约工资快照':'按上课日期匹配历史工资规则',exception};
+}
+async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false){
+ if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');
+ const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,input),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
+ const other=new Set(used.filter(r=>r.batchId!==input.targetId).map(r=>r.standardLessonKey)),mappingMap=new Map(mappings.filter(r=>r.confirmed).map(r=>[r.courseId,r]));
+ const previous=month.endsWith('-01')?(Number(month.slice(0,4))-1)+'-12':month.slice(0,5)+String(Number(month.slice(5))-1).padStart(2,'0');
+ const allowLate=!includeUsed&&Boolean((await Psql.one('SELECT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE "company"=$1 AND "month"=$2 AND "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous])).value);
+ const grouped=new Map();for(const item of source.items){const key=payrollIdentity(item);if(grouped.has(key))grouped.get(key).duplicate=true;else grouped.set(key,item);}
+ const lines=Array.from(grouped.values()).filter(item=>{const m=payrollBusinessDate(item.lessonAt).slice(0,7);return (m===month||!m||allowLate&&m===previous)&& (includeUsed||!other.has(payrollIdentity(item)));}).map(item=>payrollCalculatedLine(item,rates,month,rules,mappingMap));
+ const rewards=await Psql.query('SELECT r.* FROM "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 NOT COALESCE(l."excluded",false) AND l."batchId"<>$3)')+' ORDER BY r."objectId"',includeUsed?[co,month]:[co,month,text(input.targetId)]);
+ lines.push(...rewards.map(r=>rewardPayrollLine(r,month)));
+ if(!includeUsed&&input.targetId){const existing=await Psql.query('SELECT * FROM "PayrollLine" WHERE "company"=$1 AND "batchId"=$2 AND "kind"=\'lesson\' AND NOT COALESCE("excluded",false)',[co,input.targetId]),present=new Set(lines.map(payrollIdentity));for(const row of existing)if(!present.has(row.standardLessonKey))lines.push({...payrollLine(row),standardLessonKey:row.standardLessonKey,exception:'原计薪课次已缺失、跨月或被其他批次占用,请核对',amount:0});}
+ return {...payrollResult(month,lines,source.sourceStatus,input),sourceVersion:source.version};
+}
+async function payrollPage(context,input){const co=payrollTenant(context,input),paging=pageInput(input),month=text(input.month),where='"company"=$1 AND ($2=\'\' OR "month"=$2)',rows=await Psql.query('SELECT * FROM "PayrollBatch" WHERE '+where+' ORDER BY "month" DESC,"objectId" LIMIT $3 OFFSET $4',[co,month,paging.pageSize,(paging.page-1)*paging.pageSize]),total=await Psql.one('SELECT count(*)::int AS total FROM "PayrollBatch" WHERE '+where,[co,month]);return{items:rows.map(r=>({...payrollSummary(r),version:r.version})),total:total.total,page:paging.page,pageSize:paging.pageSize};}
+async function payrollDetailData(context,input,targetId){const co=payrollTenant(context,input),batch=await Psql.oneOrNone('SELECT * FROM "PayrollBatch" WHERE "company"=$1 AND "objectId"=$2',[co,targetId]);if(!batch)fail(404,'工资单不存在');const rows=await Psql.query('SELECT * FROM "PayrollLine" WHERE "company"=$1 AND "batchId"=$2 ORDER BY "lessonAt" NULLS LAST,"objectId"',[co,targetId]),lines=rows.map(row=>({...payrollLine(row),standardLessonKey:row.standardLessonKey,exclusionReason:row.exclusionReason,coachMobile:row.coachMobile}));return{...payrollSummary(batch),version:batch.version,lines,exceptions:lines.filter(r=>r.exception&&!r.excluded),coachItems:payrollCoachItems(lines,{})};}
+async function payrollOverviewData(context,input){
+ const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7),income=await payrollPreviewData(context,{...input,storeId:0,search:''},month,true),batches=(await payrollPage(context,{...input,month,page:1,pageSize:100})).items;
+ const batch=batches[0],settlement=batch?await payrollDetailData(context,{},batch.objectId):null;
+ const visible=payrollResult(month,income.lines,income.sourceStatus,input),settlementTotals=settlement?payrollResult(month,settlement.lines,income.sourceStatus,input):null;
+ const stores=await Psql.query('SELECT "legacyUserId" AS "storeId",COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"username") AS "storeName" FROM "_User" WHERE "company"=$1 AND ("identityType"=\'store\' OR "legacyGroupId"=2) ORDER BY "legacyUserId"',[co]);
+ return {...visible,batches,stores,incomeAmount:visible.payableAmount,settlementAmount:settlementTotals?.payableAmount??null,settlementAdjustmentAmount:settlementTotals?.adjustmentAmount??0,settlementCarryAmount:settlementTotals?settlementTotals.lines.filter(l=>l.kind!=='adjustment'&&l.originalMonth!==month&&!l.exception).reduce((a,l)=>a+l.amount,0):0};
+}
+async function payrollExportData(context,input){const data=input.targetId?await payrollDetailData(context,input,input.targetId):await payrollOverviewData(context,input),filtered=payrollVisibleLines(data.lines,input).filter(l=>!number(input.coachId)||l.coachId===number(input.coachId)),included=filtered.filter(l=>!l.excluded),excluded=filtered.filter(l=>l.excluded),totals=payrollResult(data.month,included,data.sourceStatus||{refreshedAt:new Date().toISOString()},{});return{fileName:'小树陪练工资_'+data.month+'.xlsx',summary:totals,coachItems:totals.coachItems,lines:included.filter(l=>!l.exception),exceptions:included.filter(l=>l.exception),excluded};}
+async function payrollExecute(context,input,op,payload={}){
+ requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
+ if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
+ if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;}
+ const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
+ return {...payrollSummary(result.result),version:result.result.version};
+}
+async function createPayroll(context,input,payload){return payrollExecute(context,input,'create',payload);}
+async function refreshPayrollDraft(context,input){await payrollExecute(context,input,'refresh');return payrollDetailData(context,input,input.targetId);}
+async function payrollWorkflow(context,input,operation,payload){if(operation==='reopen')fail(409,'工资单审核后永久锁定,差异请进入下期调整单');return payrollExecute(context,input,operation,payload);}
+async function payrollAdjustment(context,input,payload){return payrollExecute(context,input,'adjustment',{coachId:payload.coachId,amount:payload.amount});}
+async function payrollExclude(context,input){const co=payrollTenant(context,input),line=await Psql.oneOrNone('SELECT "batchId" FROM "PayrollLine" WHERE "company"=$1 AND "objectId"=$2',[co,input.targetId]);if(!line)fail(404,'明细不存在');return payrollExecute(context,{...input,targetId:line.batchId},'exclude',{lineId:input.targetId});}
+async function payrollCoaches(context,input){const co=payrollTenant(context,input),rows=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1 AND ("identityType"=\'coach\' OR "identityType" IS NULL AND "legacyGroupId"=3) AND NOT COALESCE("isDeleted",false) ORDER BY "legacyUserId"',[co]);return{items:rows.map(u=>({coachId:number(u.legacyUserId||legacyOf(u).UserID),coachName:displayName(u),coachObjectId:u.objectId}))};}
+async function teachingRuleRows(context,input){
+ const co=payrollTenant(context,input),stored=await Psql.query('SELECT * FROM "TeachingCompensationRule" WHERE "company"=$1 ORDER BY "revision" DESC,"createdAt" DESC,"objectId"',[co]),names=new Map(TEACHING_CATEGORY_DEFAULTS.map(c=>[c.key,c.name])),latest=new Set(),rows=[];
+ for(const r of stored){const key=[r.categoryKey,r.durationMinutes,r.deliveryMode,r.effectiveFrom].join(':');const superseded=latest.has(key);latest.add(key);rows.push({...teachingRuleProjection(r,names),revision:r.revision,supersedesId:r.supersedesId,superseded,active:r.active!==false&&!superseded});}
+ return [...defaultTeachingRules().filter(r=>!latest.has([r.categoryKey,r.durationMinutes,r.deliveryMode,r.effectiveFrom].join(':'))),...rows];
+}
+async function saveTeachingRule(context,input,payload){
+ requireRole(context,['ops-manager']);const co=payrollTenant(context,input),categoryKey=text(payload.categoryKey),duration=number(payload.durationMinutes),mode=text(payload.deliveryMode),pay=Number(payload.teacherPay),effectiveFrom=text(payload.effectiveFrom),allowed={word:[30,60],trial:[60],primary_writing:[30,60],middle_writing:[30,60],high_writing:[30,60],word_self_study:[60]};
+ if(!allowed[categoryKey]?.includes(duration)||!['online','offline'].includes(mode)||!Number.isFinite(pay)||pay<0||pay>1000000000||Math.abs(Math.round(pay*100)-pay*100)>0.000001||!/^\d{4}-\d{2}-\d{2}$/.test(effectiveFrom)||payrollBusinessDate(effectiveFrom)!==effectiveFrom)fail(400,'工资规则参数无效');
+ const rules=await teachingRuleRows(context,input),previous=rules.find(r=>!r.isDefault&&!r.superseded&&r.categoryKey===categoryKey&&r.durationMinutes===duration&&r.deliveryMode===mode&&r.effectiveFrom===effectiveFrom),req={categoryKey,durationMinutes:duration,deliveryMode:mode,teacherPay:pay,effectiveFrom,creditCosts:teachingCredits(categoryKey,duration),active:payload.active!==false,supersedesId:previous?.objectId||'',reason:reasonOf(input)};
+ return (await payrollSql('SELECT xs_payroll_rule($1,$2,$3::jsonb) AS result',[co,context.current.id,JSON.stringify(req)])).result;
+}
+async function deactivateTeachingRule(context,input){const co=payrollTenant(context,input),r=await Psql.oneOrNone('SELECT * FROM "TeachingCompensationRule" WHERE "company"=$1 AND "objectId"=$2',[co,input.targetId]);if(!r)fail(404,'规则不存在');const latest=(await teachingRuleRows(context,input)).find(v=>v.objectId===r.objectId);if(latest?.superseded)fail(409,'请选择当前规则版本');return saveTeachingRule(context,input,{...r,active:false});}
+async function historicalReservationPreview(context,input){const co=payrollTenant(context,input),data=await historicalReservationPreviewLegacy(context,input),rows=await Psql.query('SELECT c."objectId",a."updatedAt" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text WHERE c."company"=$1 AND c."modelId"=54',[co]),versions=new Map(rows.map(r=>[r.objectId,r.updatedAt]));return{...data,items:data.items.map(i=>({...i,expectedUpdatedAt:versions.get(i.appointmentId)}))};}
+async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const co=payrollTenant(context,input),selected=new Set((payload.appointmentIds||[]).map(text));if(!selected.size)fail(400,'请选择预约');const before=await Psql.one('SELECT xs_payroll_source_version($1) AS version',[co]),preview=await historicalReservationPreview(context,input),items=preview.items.filter(i=>selected.has(i.appointmentId));if(!items.length)return{reserved:0,held:0};return(await payrollSql('SELECT xs_payroll_reservations($1,$2,$3::jsonb,$4,$5) AS result',[co,context.current.id,JSON.stringify(items),reasonOf(input),before.version])).result;}
+async function creditReconciliationPreview(context,input){const rows=await Psql.query('SELECT s AS row FROM xs_payroll_refund_rows($1) s',[payrollTenant(context,input)]),items=rows.map(r=>r.row);return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter(i=>i.adjustment>0).length,refundTotal:items.reduce((s,i)=>s+Number(i.adjustment),0)},refreshedAt:new Date().toISOString()};}
+async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);if(!payload.appointmentIds?.length)fail(400,'请选择要核对的预约');return(await payrollSql('SELECT xs_payroll_refunds($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(payload.appointmentIds.map(text)),reasonOf(input)])).result;}
+async function writingMappingPage(context,input){const co=payrollTenant(context,input),rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND a."dszt" IN ('0','10') ORDER BY a."yysj",c."objectId"`,[co]);
+ return{items:rows.map(r=>{const stage=teachingStageFromDatabase([r.grade,r.nickname,r.username]);return{...r,suggestedCategoryKey:stage?stage+'_writing':'',evidence:r.evidence||r.grade||'',categoryKey:r.categoryKey||'',studentName:r.studentName||r.nickname};})};}
+async function writingMappingCommit(context,input,payload){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',[payrollTenant(context,input),context.current.id,input.targetId,text(payload.categoryKey),text(payload.evidence),dateIso(input.expectedUpdatedAt)||null,reasonOf(input)])).result;}
+async function payRateRows(context,input){
+ const rows=await Psql.query('SELECT * FROM "CoachPayRate" WHERE "company"=$1 ORDER BY "createdAt" DESC,"objectId" DESC',[payrollTenant(context,input)]),seen=new Set(),current=[];
+ for(const row of rows){const key=row.classType+':'+row.effectiveFrom;if(seen.has(key))continue;seen.add(key);current.push({...row,classType:number(row.classType),classTypeLabel:classType(row.classType),amount:number(row.amount),durationHours:number(row.durationHours),active:row.active!==false,isDefault:false});}
+ return [...current,...defaultPayRates().filter(r=>!seen.has(r.classType+':'+r.effectiveFrom))].sort((a,b)=>b.effectiveFrom.localeCompare(a.effectiveFrom));
+}
+async function savePayRateUpsert(context,input,payload){
+ requireRole(context,['ops-manager']);const type=Number(payload.classType),amount=Number(payload.amount),hours=Number(payload.durationHours),effectiveFrom=text(payload.effectiveFrom);
+ if(![1,2,3,4].includes(type)||!Number.isFinite(amount)||amount<0||amount>1000000000||Math.abs(Math.round(amount*100)-amount*100)>.000001||!Number.isFinite(hours)||hours<=0||hours>24||payrollBusinessDate(effectiveFrom)!==effectiveFrom)fail(400,'课型、单价、工时或生效日期无效');
+ return(await payrollSql('SELECT xs_payroll_legacy_rate($1,$2,$3::jsonb) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify({classType:type,amount,durationHours:hours,effectiveFrom,active:payload.active!==false,reason:reasonOf(input)})])).result;
+}

+ 7 - 3
scripts/cloud/review-rewards.js

@@ -31,8 +31,12 @@ async function reviewRewardPage(companyId, input) {
   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 snapshot=input.exportMode?(input.snapshot||new Date().toISOString()):'';
+  if(snapshot){if(Number.isNaN(new Date(snapshot).getTime()))throw new Error('导出快照无效');add('r."createdAt"<=?::timestamptz',snapshot);}
+  const summaryWhere=clauses.join(' AND '),summaryValues=[...values];
+  if(input.exportMode&&input.afterId)add('r."objectId">?',String(input.afterId));
   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)} };
+  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 '+summaryWhere, summaryValues);
+  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+(input.exportMode?' ORDER BY r."objectId"':' ORDER BY r."completedAt" DESC NULLS FIRST,r."objectId"')+' LIMIT $'+(values.length+1)+' OFFSET $'+(values.length+2), [...values,pageSize,input.exportMode?0:(page-1)*pageSize]);
+  return { snapshot,nextCursor:input.exportMode&&rows.length===pageSize?rows[rows.length-1].objectId:'',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)} };
 }

Файловите разлики са ограничени, защото са твърде много
+ 3 - 2
scripts/deploy-admin-functions.mjs


+ 28 - 0
scripts/deploy-payroll-integrity.mjs

@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+// Patch only payroll entry points in the live shared gateway; preserve other releases.
+import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',master=process.env.XIAOSHU_MASTER_KEY;
+if(!master)throw Error('缺少 XIAOSHU_MASTER_KEY');const out=process.env.XIAOSHU_PAYROLL_REPORT_DIR||path.resolve('artifacts/payroll-integrity');fs.mkdirSync(out,{recursive:true,mode:0o700});
+async function api(p,method='GET',body){const res=await fetch(base+p,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined});const data=await res.json();if(!res.ok||data.error)throw Error(data.error||'HTTP '+res.status);return data;}
+const source=fs.readFileSync(new URL('./deploy-admin-functions.mjs',import.meta.url),'utf8');const raw=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const marker='\n// PAYROLL_INTEGRITY_RELEASE\n';
+for(const name of ['xiaoshu.ops.gateway','xiaoshu.ops.gateway.v3','xiaoshu.admin.gateway']){
+ const rows=(await api('/classes/Function?where='+encodeURIComponent(JSON.stringify({name})))).results;if(rows.length!==1)throw Error('云函数名称不唯一 '+name);const old=rows[0];
+ fs.writeFileSync(path.join(out,name+'.before-'+Date.now()+'.json'),JSON.stringify(old,null,2),{mode:0o600});let code=old.code;
+ if(name==='xiaoshu.admin.gateway'){
+  const desired=source.match(/^function operationsRoleOf\(context\).*$/m)[0];if(!/^function operationsRoleOf\(context\).*$/m.test(code))throw Error('身份网关版本不兼容');code=code.replace(/^function operationsRoleOf\(context\).*$/m,desired);
+ }else{
+  if(code.includes(marker))code=code.slice(0,code.indexOf(marker));
+  code=code.replace(/^function roleOf\(context\).*$/m,raw.match(/^function roleOf\(context\).*$/m)[0]);
+  if(!code.includes("context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);"))code=code.replace('context=await auth(request);',"context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);");
+  code=code.replace('async function historicalReservationPreview(context,input){','async function historicalReservationPreviewLegacy(context,input){');
+  code=code.replace("requireRole(context,['ops-manager','ops-staff','ops-auditor']);await releaseExpiredReservations(context,input);","requireRole(context,['ops-manager','ops-staff','ops-auditor']);");
+  for(const op of ['export','exclude-line']){const re=new RegExp("^    if\\(operation==='ops/payroll/"+op+"'\\).*?$",'m');if(!re.test(code))throw Error('工资路由版本不兼容 '+op);code=code.replace(re,raw.match(re)[0]);}
+  const extra=["ops/payroll/coaches","ops/course-mappings/students","ops/course-mappings/student-commit"].filter(op=>!code.includes("operation==='"+op+"'" )).map(op=>raw.split('\n').find(line=>line.includes("operation==='"+op+"'"))).join('\n');
+  if(extra)code=code.replace("    if(operation==='ops/payroll/overview')",extra+"\n    if(operation==='ops/payroll/overview')");
+  code+=marker+fs.readFileSync(new URL('./cloud/review-rewards.js',import.meta.url),'utf8')+fs.readFileSync(new URL('./cloud/payroll-integrity.js',import.meta.url),'utf8');
+ }
+ new Function(code);const current=await api('/classes/Function/'+old.objectId);if(current.updatedAt!==old.updatedAt)throw Error('云函数被其他发布更新,请重试 '+name);
+ await api('/classes/Function/'+old.objectId,'PUT',{code});const checked=await api('/classes/Function/'+old.objectId);if(checked.code!==code)throw Error('发布后代码核对失败 '+name);
+ console.log(JSON.stringify({name,sha256:createHash('sha256').update(code).digest('hex'),verified:true}));
+}

+ 21 - 0
scripts/migrate-payroll-integrity.mjs

@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+// Back up before applying. Reports contain business data and are stored privately.
+import fs from 'node:fs';import {randomBytes} from 'node:crypto';import path from 'node:path';
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',master=process.env.XIAOSHU_MASTER_KEY;
+if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY');
+const apply=process.argv.includes('--apply'),co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',out=process.env.XIAOSHU_PAYROLL_REPORT_DIR||path.resolve('artifacts/payroll-integrity');fs.mkdirSync(out,{recursive:true,mode:0o700});
+async function api(p,method='GET',body){const res=await fetch(base+p,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined});const data=await res.json();if(!res.ok||data.error)throw Error(data.error||'HTTP '+res.status);return data;}
+const suffix=Date.now()+'-'+randomBytes(6).toString('hex');let uid='',fid='';
+try{
+ const user=await api('/users','POST',{username:'payroll_integrity_'+suffix,password:randomBytes(32).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co},roles:['ops-manager'],isAdmin:true,testCreatedBy:'payroll-integrity-migration'});uid=user.objectId;
+ const sql=fs.readFileSync(new URL('./sql/payroll-integrity.sql',import.meta.url),'utf8');
+ const before=`SELECT jsonb_build_object('batches',(SELECT COALESCE(jsonb_agg(to_jsonb(b)),'[]') FROM "PayrollBatch" b WHERE "company"=$1),'lines',(SELECT COALESCE(jsonb_agg(to_jsonb(l)),'[]') FROM "PayrollLine" l WHERE "company"=$1),'rules',(SELECT COALESCE(jsonb_agg(to_jsonb(r)),'[]') FROM "TeachingCompensationRule" r WHERE "company"=$1)) AS data`;
+ const report=`SELECT jsonb_build_object('sourceCount',(SELECT count(*) FROM xs_payroll_sources($1)), 'missingFromFacts',(SELECT COALESCE(jsonb_agg(jsonb_build_object('lessonId',s->>'lessonObjectId','generalId',s->>'legacyGeneralId','lessonAt',s->>'lessonAt','snapshot',s->'teacherPaySnapshot')),'[]') FROM xs_payroll_sources($1) s WHERE s->>'source'='canonical' AND NOT EXISTS(SELECT 1 FROM "LessonRecordFact" f WHERE f."company"=$1 AND f."legacyGeneralId"::text=s->>'legacyGeneralId')), 'monthBoundary',(SELECT COALESCE(jsonb_agg(jsonb_build_object('lessonId',s->>'lessonObjectId','lessonAt',s->>'lessonAt','beijingMonth',to_char(xs_payroll_time(s->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM'))),'[]') FROM xs_payroll_sources($1) s WHERE to_char(xs_payroll_time(s->>'lessonAt') AT TIME ZONE 'UTC','YYYY-MM')<>to_char(xs_payroll_time(s->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM'))) AS data`;
+ const code=`async function handler(request,response){try{if(!request.user||request.user.id!==${JSON.stringify(uid)})return response.status(403).json({success:false});const mode=(request.body.params||{}).mode;if(mode==='apply'){await Psql.none(${JSON.stringify(sql)});return response.json({success:true});}const rows=await Psql.query(mode==='after'?${JSON.stringify(report)}:${JSON.stringify(before)},[${JSON.stringify(co)}]);return response.json({success:true,rows});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`;
+ const route='xiaoshu/system/payroll-integrity-'+suffix;const fn=await api('/classes/Function','POST',{name:route,path:route,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});fid=fn.objectId;
+ async function call(mode){const res=await fetch(base.replace(/\/parse$/,'/api/functions/')+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:user.sessionToken,params:{mode}})});const data=await res.json();if(!data.success)throw Error(data.message||'执行失败');return data;}
+ const backup=await call('before');fs.writeFileSync(path.join(out,'before-'+suffix+'.json'),JSON.stringify(backup,null,2),{mode:0o600});
+ if(apply){for(const [table,fields] of Object.entries({PayrollLine:{standardLessonKey:{type:'String'},coachObjectId:{type:'String'},coachMobile:{type:'String'}},PayrollBatch:{version:{type:'Number'}},TeachingCompensationRule:{revision:{type:'Number'},supersedesId:{type:'String'}},CourseAppointment:{categoryEvidence:{type:'String'}}})){const schema=await api('/schemas/'+table);const missing=Object.fromEntries(Object.entries(fields).filter(([k])=>!schema.fields[k]));if(Object.keys(missing).length)await api('/schemas/'+table,'PUT',{fields:missing});}
+ await call('apply');const result=await call('after');fs.writeFileSync(path.join(out,'impact-'+suffix+'.json'),JSON.stringify(result,null,2),{mode:0o600});console.log(JSON.stringify({applied:true,reportDirectory:out,sourceCount:result.rows[0]?.data?.sourceCount}));}
+ else console.log(JSON.stringify({applied:false,reportDirectory:out,batchCount:backup.rows[0]?.data?.batches?.length}));
+}finally{const failures=[];if(fid)try{await api('/classes/Function/'+fid,'DELETE');}catch{failures.push('临时函数 '+fid);}if(uid)try{await api('/users/'+uid,'DELETE');}catch{failures.push('临时管理员 '+uid);}if(failures.length)throw Error('需清理:'+failures.join('、'));}

+ 291 - 0
scripts/sql/payroll-integrity.sql

@@ -0,0 +1,291 @@
+-- Requires member-enrollment.sql and review-rewards.sql. One atomic installation.
+BEGIN;
+SELECT set_config('xiaoshu.payroll_write','yes',true);
+LOCK TABLE "PayrollBatch", "PayrollLine" IN SHARE ROW EXCLUSIVE MODE;
+CREATE INDEX IF NOT EXISTS payroll_common_item ON "CommonModel" ("company","modelId","itemId","objectId");
+CREATE INDEX IF NOT EXISTS payroll_common_general_text ON "CommonModel" ("company","modelId",("generalId"::text));
+CREATE INDEX IF NOT EXISTS payroll_appointment_item ON "CourseAppointment" ("company","id");
+ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "standardLessonKey" text;
+ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "coachObjectId" text;
+ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "coachMobile" text;
+ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 0;
+ALTER TABLE "PayrollBatch" ALTER COLUMN "version" SET DEFAULT 0;
+UPDATE "PayrollBatch" SET "version"=0 WHERE "version" IS NULL;
+ALTER TABLE "PayrollBatch" ALTER COLUMN "version" SET NOT NULL;
+ALTER TABLE "TeachingCompensationRule" ADD COLUMN IF NOT EXISTS "revision" integer NOT NULL DEFAULT 1;
+ALTER TABLE "TeachingCompensationRule" ADD COLUMN IF NOT EXISTS "supersedesId" text;
+ALTER TABLE "TeachingCompensationRule" ALTER COLUMN "revision" SET DEFAULT 1;
+ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "categoryEvidence" text;
+CREATE TABLE IF NOT EXISTS "PayrollCommand" (
+ "company" text NOT NULL,"requestId" text NOT NULL,"request" jsonb NOT NULL,"result" jsonb NOT NULL,
+ "createdAt" timestamptz NOT NULL DEFAULT now(),PRIMARY KEY("company","requestId")
+);
+CREATE TABLE IF NOT EXISTS "PayrollReconciliation" (
+ "company" text NOT NULL,"appointmentId" text NOT NULL,"amount" numeric(12,2) NOT NULL,
+ "transactionId" text NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now(),PRIMARY KEY("company","appointmentId")
+);
+CREATE OR REPLACE FUNCTION xs_payroll_time(v text) RETURNS timestamptz LANGUAGE plpgsql IMMUTABLE AS $$
+BEGIN
+ IF NULLIF(trim(v),'') IS NULL THEN RETURN NULL; END IF;
+ IF v ~ '(Z|[+-][0-9]{2}(:?[0-9]{2})?)$' AND v ~ '[ T]' THEN RETURN v::timestamptz; END IF;
+ RETURN v::timestamp AT TIME ZONE 'Asia/Shanghai';
+EXCEPTION WHEN OTHERS THEN RETURN NULL;
+END $$;
+
+-- Canonical completion records are authoritative, including tombstones. Facts only
+-- supplement records absent from the canonical table; a deletion cannot reappear.
+CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$
+ WITH canonical AS (
+ SELECT (to_jsonb(l)-'kzsj')||jsonb_build_object(
+  'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
+  'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
+  'source','canonical','commonStatus',COALESCE(c."status",99),
+  'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
+  'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
+  'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
+  'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
+  'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
+  'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
+  'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
+ ) AS row_data
+ FROM "LessonRecord" l
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c."company"=co AND c."modelId"=59 AND c."itemId"=l."id" ORDER BY c."objectId" LIMIT 1) c ON true
+ LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=l."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true
+ WHERE l."company"=co
+ ), fallback AS (
+ SELECT (to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
+  'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END) AS row_data
+ FROM "LessonRecordFact" f LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=f."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true WHERE f."company"=co AND NOT EXISTS(SELECT 1 FROM canonical c WHERE c.row_data->>'legacyGeneralId'=f."legacyGeneralId"::text AND COALESCE(f."legacyGeneralId",0)>0)
+ ) SELECT row_data FROM canonical UNION ALL SELECT row_data FROM fallback
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql STABLE AS $$
+ SELECT md5(COALESCE((SELECT string_agg(s::text,'|' ORDER BY s::text) FROM xs_payroll_sources(co) s),'')||
+ COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
+ COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||
+ COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CoachPayRate" p WHERE p."company"=co),''))
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_audit(co text,actor text,action text,target text,reason text,before_data jsonb,after_data jsonb) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),
+ 'sourceKey','payroll-audit:'||xs_object_id(),'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','工资结算',
+ 'type1','operations-admin','type2',action,'type3','PayrollBatch','clevel',0,'cadminId',(SELECT "legacyUserId" FROM "_User" WHERE "objectId"=actor),
+ 'detail',jsonb_build_object('action',action,'target',target,'actor',actor,'reason',reason,'before',before_data,'after',after_data)::text,'content1',reason,'content2',actor));
+END $$;
+
+DROP TRIGGER IF EXISTS payroll_reward_guard ON "PayrollLine";
+UPDATE "PayrollLine" SET "standardLessonKey"=CASE WHEN COALESCE("legacyGeneralId",0)>0 THEN 'legacy:'||"legacyGeneralId"::text ELSE 'lesson:'||"lessonId" END
+ WHERE "kind"='lesson' AND "standardLessonKey" IS NULL;
+-- Deliberately fails on conflicting history. Never erase or silently exclude paid lines.
+CREATE UNIQUE INDEX IF NOT EXISTS payroll_lesson_once ON "PayrollLine" ("company","standardLessonKey") WHERE "kind"='lesson' AND COALESCE("excluded",false)=false;
+CREATE OR REPLACE FUNCTION xiaoshu_payroll_reward_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE b "PayrollBatch"; r "ReviewReward";
+BEGIN
+ IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资明细只能通过工资业务事务修改'; END IF;
+ IF TG_OP='DELETE' THEN SELECT * INTO b FROM "PayrollBatch" WHERE "company"=OLD."company" AND "objectId"=OLD."batchId" FOR UPDATE;
+ ELSE SELECT * INTO b FROM "PayrollBatch" WHERE "company"=NEW."company" AND "objectId"=NEW."batchId" FOR UPDATE; END IF;
+ IF b."objectId" IS NULL OR b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定或不存在'; END IF;
+ IF TG_OP='DELETE' THEN RETURN OLD; END IF;
+ IF TG_OP='UPDATE' AND ROW(OLD."company",OLD."batchId",OLD."kind",OLD."reviewRewardId",OLD."standardLessonKey") IS DISTINCT FROM ROW(NEW."company",NEW."batchId",NEW."kind",NEW."reviewRewardId",NEW."standardLessonKey") THEN RAISE EXCEPTION '不能修改工资明细归属'; END IF;
+ IF NEW."kind" IS NULL OR NEW."kind" NOT IN ('lesson','review_reward','adjustment') OR NEW."amount" IS NULL OR NEW."amount"::text IN ('NaN','Infinity','-Infinity') OR round(NEW."amount"::numeric,2)<>NEW."amount"::numeric THEN RAISE EXCEPTION '工资类型或金额无效'; END IF;
+ IF NEW."kind"='lesson' AND NULLIF(NEW."standardLessonKey",'') IS NULL THEN RAISE EXCEPTION '课次缺少标准标识'; END IF;
+ NEW."settlementMonth":=b."month";
+ IF NEW."kind"='review_reward' THEN
+  SELECT * INTO r FROM "ReviewReward" WHERE "company"=b."company" AND "objectId"=NEW."reviewRewardId";
+  IF r."status" IS DISTINCT FROM 'earned' OR r."incomeMonth">b."month" OR NEW."excluded" THEN RAISE EXCEPTION '抗遗忘工资不可结算或排除'; END IF;
+  NEW."coachId":=r."coachId";NEW."coachName":=r."coachName";NEW."studentName":=r."studentName";NEW."courseName":=r."courseName";
+  NEW."storeId":=r."storeId";NEW."storeName":=r."storeName";NEW."reviewTaskId":=r."taskId";NEW."plannedDate":=r."plannedDate";
+  NEW."amount":=r."amount";NEW."rateAmount":=r."amount";NEW."durationHours":=0;NEW."originalMonth":=r."incomeMonth";
+  NEW."lessonAt":=r."completedAt";NEW."exception":='';NEW."classType":=0;NEW."courseCategoryName":='抗遗忘工资';
+ END IF;
+ RETURN NEW;
+END $$;
+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 xs_payroll_batch_write_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资批次只能通过工资业务事务修改'; END IF;
+ IF TG_OP='INSERT' AND NEW."status" IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION '工资批次必须从草稿开始'; END IF;
+ IF TG_OP='DELETE' THEN RETURN OLD;END IF;RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS payroll_batch_write_guard ON "PayrollBatch";
+CREATE TRIGGER payroll_batch_write_guard BEFORE INSERT OR UPDATE OR DELETE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xs_payroll_batch_write_guard();
+CREATE OR REPLACE FUNCTION xiaoshu_payroll_totals_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资批次只能通过工资业务事务修改'; END IF;
+ IF NEW."company" IS DISTINCT FROM OLD."company" OR NEW."month" IS DISTINCT FROM OLD."month" THEN RAISE EXCEPTION '不能修改工资批次归属'; END IF;
+ IF OLD."status" IN ('reviewed','paid') THEN
+  IF NOT (NEW."status"=OLD."status" OR OLD."status"='reviewed' AND NEW."status"='paid') THEN RAISE EXCEPTION '已审核工资不可重新打开'; END IF;
+  IF ROW(NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."payableAmount",NEW."lessonCount",NEW."reviewCount",NEW."coachCount",NEW."exceptionCount",NEW."totalHours") IS DISTINCT FROM ROW(OLD."baseAmount",OLD."reviewAmount",OLD."adjustmentAmount",OLD."payableAmount",OLD."lessonCount",OLD."reviewCount",OLD."coachCount",OLD."exceptionCount",OLD."totalHours") THEN RAISE EXCEPTION '已审核工资金额不可修改'; END IF;
+ ELSE
+  IF NEW."status" NOT IN ('draft','reviewed') THEN RAISE EXCEPTION '工资状态转换无效'; END IF;
+  SELECT COALESCE(sum("amount"::numeric) FILTER(WHERE "kind"='lesson'),0),COALESCE(sum("amount"::numeric) FILTER(WHERE "kind"='review_reward'),0),COALESCE(sum("amount"::numeric) FILTER(WHERE "kind"='adjustment'),0),count(*) FILTER(WHERE "kind"='lesson'),count(*) FILTER(WHERE "kind"='review_reward'),count(DISTINCT "coachId"),COALESCE(sum("durationHours") FILTER(WHERE "kind"='lesson'),0)
+  INTO NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."lessonCount",NEW."reviewCount",NEW."coachCount",NEW."totalHours"
+  FROM "PayrollLine" WHERE "company"=NEW."company" AND "batchId"=NEW."objectId" AND NOT COALESCE("excluded",false) AND COALESCE("exception",'')='';
+  SELECT count(*) INTO NEW."exceptionCount" FROM "PayrollLine" WHERE "company"=NEW."company" AND "batchId"=NEW."objectId" AND NOT COALESCE("excluded",false) AND COALESCE("exception",'')<>'';
+  NEW."payableAmount":=NEW."baseAmount"+NEW."reviewAmount"+NEW."adjustmentAmount";
+  IF NEW."status"='reviewed' AND NEW."exceptionCount">0 THEN RAISE EXCEPTION '仍有异常明细,不能审核'; END IF;
+ END IF;
+ IF NEW."status"='paid' AND (NULLIF(trim(NEW."paymentReference"),'') IS NULL OR NULLIF(trim(NEW."paymentMethod"),'') IS NULL) THEN RAISE EXCEPTION '发放方式和凭证编号必填'; END IF;
+ NEW."version":=COALESCE(OLD."version",0)+1;NEW."updatedAt":=clock_timestamp();
+ RETURN NEW;
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_payroll_command(co text,actor text,op text,target text,req jsonb,idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE b "PayrollBatch"; previous jsonb; saved "PayrollCommand"; line jsonb; candidate "PayrollLine"; key text; result jsonb; u "_User"; st "_User";
+BEGIN
+ IF co IS NULL OR length(COALESCE(idem,''))<8 OR length(trim(COALESCE(req->>'reason','')))<2 THEN RAISE EXCEPTION '缺少帐套、操作原因或请求编号'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ PERFORM set_config('xiaoshu.payroll_write','yes',true);
+ req:=req||jsonb_build_object('actor',actor,'operation',op,'target',target);
+ SELECT * INTO saved FROM "PayrollCommand" WHERE "company"=co AND "requestId"=idem;
+ IF FOUND THEN IF saved."request" IS DISTINCT FROM (req-'lines'-'sourceVersion') THEN RAISE EXCEPTION '请求编号已用于不同操作'; END IF;RETURN saved."result";END IF;
+ IF op='create' THEN
+  IF COALESCE(req->>'month','') !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
+  INSERT INTO "PayrollBatch" ("objectId","company","sourceKey","month","status","createdAt","updatedAt","createdBy") VALUES(xs_object_id(),co,'payroll:'||co||':'||(req->>'month'),req->>'month','draft',now(),now(),actor) RETURNING * INTO b;
+ ELSE
+  SELECT * INTO b FROM "PayrollBatch" WHERE "company"=co AND "objectId"=target FOR UPDATE;
+  IF NOT FOUND THEN RAISE EXCEPTION '工资单不存在'; END IF;
+  IF req->>'version' IS NULL OR (req->>'version')::int<>b."version" THEN RAISE EXCEPTION '工资单已更新,请刷新后重试'; END IF;
+ END IF;
+ previous:=to_jsonb(b);
+ IF op IN ('create','refresh','review') THEN
+  IF b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定'; END IF;
+  IF req->>'sourceVersion' IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '计薪来源已更新,请重新核对'; END IF;
+  -- Only automated lines are rebuilt; explicit exclusions and adjustments survive.
+  DELETE FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "kind" IN ('lesson','review_reward') AND NOT COALESCE("excluded",false);
+  FOR line IN SELECT value FROM jsonb_array_elements(COALESCE(req->'lines','[]')) LOOP
+   IF line->>'kind' NOT IN ('lesson','review_reward') THEN RAISE EXCEPTION '自动明细类型无效'; END IF;
+   IF line->>'kind'='lesson' AND EXISTS(SELECT 1 FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "standardLessonKey"=line->>'standardLessonKey' AND COALESCE("excluded",false)) THEN CONTINUE; END IF;
+   line:=line||jsonb_build_object('objectId',xs_object_id(),'company',co,'batchId',b."objectId",'settlementMonth',b."month",'createdAt',now(),'updatedAt',now(),'excluded',false,'sourceKey','payroll-line:'||xs_object_id());
+   candidate:=jsonb_populate_record(NULL::"PayrollLine",line); INSERT INTO "PayrollLine" SELECT candidate.*;
+  END LOOP;
+  IF op='review' THEN b."status":='reviewed'; END IF;
+ ELSIF op='adjustment' THEN
+  IF b."status"<>'draft' THEN RAISE EXCEPTION '只有草稿可以调整'; END IF;
+  IF (req->>'amount')::numeric=0 OR abs((req->>'amount')::numeric)>1000000000 OR round((req->>'amount')::numeric,2)<>(req->>'amount')::numeric THEN RAISE EXCEPTION '调整金额无效,最多两位小数'; END IF;
+  SELECT * INTO u FROM "_User" WHERE "company"=co AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=req->>'coachId' AND ("identityType"='coach' OR "identityType" IS NULL AND COALESCE("legacyGroupId"::text,"legacyUserData"->>'GroupID')='3');
+  IF NOT FOUND THEN RAISE EXCEPTION '陪练不存在'; END IF;
+  SELECT * INTO st FROM "_User" WHERE "company"=co AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=u."legacyUserData"->>'ParentUserID' LIMIT 1;
+  INSERT INTO "PayrollLine" ("objectId","company","batchId","sourceKey","kind","coachId","coachObjectId","coachName","storeId","storeName","amount","rateAmount","durationHours","originalMonth","settlementMonth","description","exception","createdAt","updatedAt")
+  VALUES(xs_object_id(),co,b."objectId",'adjustment:'||idem,'adjustment',(req->>'coachId')::numeric,u."objectId",COALESCE(NULLIF(u."realName",''),NULLIF(u."nickname",''),u."username"),COALESCE(st."legacyUserId",0),COALESCE(NULLIF(st."realName",''),NULLIF(st."nickname",''),st."username",'未绑定门店'),(req->>'amount')::numeric,0,0,b."month",b."month",req->>'reason','',now(),now());
+ ELSIF op='exclude' THEN
+  UPDATE "PayrollLine" SET "excluded"=true,"exclusionReason"=req->>'reason',"updatedAt"=now() WHERE "company"=co AND "batchId"=b."objectId" AND "objectId"=req->>'lineId' AND "kind"='lesson' AND COALESCE("exception",'')<>'';
+  IF NOT FOUND THEN RAISE EXCEPTION '只能排除异常课次'; END IF;
+ ELSIF op='mark-paid' THEN
+  IF b."status"<>'reviewed' THEN RAISE EXCEPTION '只有已审核工资单可登记发放'; END IF;b."status":='paid';
+ ELSE RAISE EXCEPTION '工资操作无效'; END IF;
+ UPDATE "PayrollBatch" SET "status"=b."status","sourceRefreshedAt"=CASE WHEN op IN ('create','refresh','review') THEN now() ELSE "sourceRefreshedAt" END,
+ "reviewedAt"=CASE WHEN op='review' THEN now() ELSE "reviewedAt" END,"reviewedBy"=CASE WHEN op='review' THEN actor ELSE "reviewedBy" END,
+ "paidAt"=CASE WHEN op='mark-paid' THEN now() ELSE "paidAt" END,"paidBy"=CASE WHEN op='mark-paid' THEN actor ELSE "paidBy" END,
+ "paymentMethod"=CASE WHEN op='mark-paid' THEN req->>'paymentMethod' ELSE "paymentMethod" END,"paymentReference"=CASE WHEN op='mark-paid' THEN req->>'paymentReference' ELSE "paymentReference" END
+ WHERE "company"=co AND "objectId"=b."objectId" RETURNING to_jsonb("PayrollBatch".*) INTO result;
+ PERFORM xs_payroll_audit(co,actor,op,b."objectId",req->>'reason',previous,result);
+ INSERT INTO "PayrollCommand" ("company","requestId","request","result") VALUES(co,idem,req-'lines'-'sourceVersion',result);
+ RETURN result;
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_payroll_rule(co text,actor text,req jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "TeachingCompensationRule"; old "TeachingCompensationRule"; rev integer; result jsonb;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ SELECT * INTO old FROM "TeachingCompensationRule" WHERE "company"=co AND "categoryKey"=req->>'categoryKey' AND "durationMinutes"=(req->>'durationMinutes')::numeric AND "deliveryMode"=req->>'deliveryMode' AND "effectiveFrom"=req->>'effectiveFrom' ORDER BY "revision" DESC,"createdAt" DESC LIMIT 1;
+ IF COALESCE(old."objectId",'')<>COALESCE(req->>'supersedesId','') THEN RAISE EXCEPTION '工资规则已有新版本,请刷新'; END IF;
+ rev:=COALESCE(old."revision",0)+1;
+ r:=jsonb_populate_record(NULL::"TeachingCompensationRule",req||jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','payroll-rule:'||xs_object_id(),'createdAt',now(),'updatedAt',now(),'revision',rev,'supersedesId',old."objectId"));
+ INSERT INTO "TeachingCompensationRule" SELECT r.*;
+ result:=to_jsonb(r);PERFORM xs_payroll_audit(co,actor,'save-rule',r."objectId",req->>'reason',to_jsonb(old),result);RETURN result;
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_rule_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION '工资规则版本不可改写,请保存新版本';END $$;
+DROP TRIGGER IF EXISTS payroll_rule_immutable ON "TeachingCompensationRule";
+CREATE TRIGGER payroll_rule_immutable BEFORE UPDATE OR DELETE ON "TeachingCompensationRule" FOR EACH ROW EXECUTE FUNCTION xs_payroll_rule_immutable();
+
+-- Keep the legacy class-type endpoint compatible without allowing old revisions
+-- to be overwritten or a successful write to escape the transaction audit.
+CREATE OR REPLACE FUNCTION xs_payroll_legacy_rate(co text,actor text,req jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "CoachPayRate"; old "CoachPayRate";
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ SELECT * INTO old FROM "CoachPayRate" WHERE "company"=co AND "classType"=(req->>'classType')::numeric AND "effectiveFrom"=req->>'effectiveFrom' ORDER BY "createdAt" DESC,"objectId" DESC LIMIT 1;
+ r:=jsonb_populate_record(NULL::"CoachPayRate",req||jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','payroll-legacy-rate:'||xs_object_id(),'createdAt',clock_timestamp(),'updatedAt',clock_timestamp()));
+ INSERT INTO "CoachPayRate" SELECT r.*;
+ PERFORM xs_payroll_audit(co,actor,'save-legacy-rate',r."objectId",req->>'reason',to_jsonb(old),to_jsonb(r));RETURN to_jsonb(r);
+END $$;
+DROP TRIGGER IF EXISTS payroll_legacy_rate_immutable ON "CoachPayRate";
+CREATE TRIGGER payroll_legacy_rate_immutable BEFORE UPDATE OR DELETE ON "CoachPayRate" FOR EACH ROW EXECUTE FUNCTION xs_payroll_rule_immutable();
+
+CREATE OR REPLACE FUNCTION xs_payroll_reservations(co text,actor text,items jsonb,reason text,source_version text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE item jsonb;c "CommonModel";a "CourseAppointment";u "_User";cost jsonb;reserved integer=0;held integer=0;status text;why text;costs jsonb;existing "CourseCreditReservation";
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ IF source_version IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '工资或课程规则已更新,请重新核对'; END IF;
+ IF jsonb_array_length(items)=0 THEN RAISE EXCEPTION '请选择预约';END IF;
+ FOR item IN SELECT value FROM jsonb_array_elements(items) LOOP
+  SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=item->>'appointmentId' AND "modelId"=54 FOR UPDATE;
+  IF NOT FOUND OR c."status"=-2 THEN RAISE EXCEPTION '预约已取消或不存在,请重新核对';END IF;
+  SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"::text=c."itemId"::text FOR UPDATE;
+  IF a."updatedAt" IS DISTINCT FROM (item->>'expectedUpdatedAt')::timestamptz OR a."dszt" NOT IN ('0','10') THEN RAISE EXCEPTION '预约已更新,请重新核对';END IF;
+  IF a."dszt"='0' AND xs_payroll_time(a."yysj")<now() THEN RAISE EXCEPTION '预约已经到期,请重新核对';END IF;
+  SELECT * INTO existing FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=c."objectId" AND "state" IN ('reserved','consumed') LIMIT 1;
+  IF FOUND THEN CONTINUE;END IF;
+  SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
+  costs:=COALESCE(item->'creditCosts','[]');status:=item->>'status';why:=item->>'reason';
+  IF status IN ('ready','insufficient') THEN
+   status:='ready';why:='';
+   IF u."objectId" IS NULL THEN status:='missing_student';why:='学员不存在'; END IF;
+   FOR cost IN SELECT value FROM jsonb_array_elements(costs) LOOP
+    IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId",cost->>'account')<(cost->>'amount')::numeric THEN status:='insufficient';why:='可用课时不足,请补充课时后重新核对';END IF;
+   END LOOP;
+  END IF;
+  UPDATE "CourseAppointment" SET "creditHoldStatus"=status,"creditHoldReason"=why,
+   "courseCategoryKey"=COALESCE(NULLIF(item->>'categoryKey',''),"courseCategoryKey"),"courseCategoryName"=COALESCE(NULLIF(item->>'categoryName',''),"courseCategoryName"),
+   "teachingRuleId"=COALESCE(NULLIF(item->>'teachingRuleId',''),"teachingRuleId"),"teacherPaySnapshot"=COALESCE("teacherPaySnapshot",(item->>'teacherPay')::numeric),
+   "creditCostsSnapshot"=CASE WHEN jsonb_array_length(costs)>0 THEN costs ELSE "creditCostsSnapshot" END,"durationMinutes"=(item->>'durationMinutes')::numeric,"deliveryMode"=item->>'deliveryMode',"updatedAt"=now() WHERE "objectId"=a."objectId";
+  IF status='ready' THEN
+   PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','payroll-reservation:'||xs_object_id(),'createdAt',now(),'updatedAt',now(),'appointmentId',c."objectId",'appointmentGeneralId',c."generalId",'studentId',u."legacyUserId",'ruleId',item->>'teachingRuleId','creditCosts',costs,'state','reserved','reservedAt',now()));reserved:=reserved+1;
+  ELSE held:=held+1;END IF;
+  PERFORM xs_payroll_audit(co,actor,'reserve-history',c."objectId",reason,to_jsonb(a),jsonb_build_object('status',status,'costs',costs));
+ END LOOP;
+ RETURN jsonb_build_object('reserved',reserved,'held',held);
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_refund_rows(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$
+ WITH rows AS (
+ SELECT c."objectId" AS aid,c."generalId",a."szyh",a."yysj",COALESCE(a."durationMinutes",60) AS duration,
+ COALESCE(NULLIF((SELECT sum((v->>'amount')::numeric) FROM jsonb_array_elements(COALESCE(a."creditCostsSnapshot"::jsonb,'[]')) v WHERE v->>'account'='UserPoint'),0),CASE WHEN a."durationMinutes"=30 THEN .5 ELSE 1 END) AS expected,
+ COALESCE((SELECT sum(-l."score") FROM "UserUserPoint" l WHERE l."company"=co AND l."score"<0 AND (l."sourceKey" IN ('cloud:e_order_update:'||c."generalId"::text||':period','cloud:e_order_update:'||c."generalId"::text||':configured:UserPoint','cloud:ops-complete:'||c."objectId"||':point') OR EXISTS(SELECT 1 FROM "LessonRecord" lr WHERE lr."company"=co AND lr."yyds"::text=c."generalId"::text AND lr."sourceKey"=l."transactionId"))),0) AS charged,
+ COALESCE((SELECT sum(l."score") FROM "UserUserPoint" l WHERE l."company"=co AND l."score">0 AND l."sourceKey"='cloud:credit-reconciliation:'||co||':'||c."generalId"::text),0)+COALESCE((SELECT sum(r."amount") FROM "PayrollReconciliation" r WHERE r."company"=co AND r."appointmentId"=c."objectId"),0) AS refunded
+ FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=co AND a."id"::text=c."itemId"::text
+ WHERE c."company"=co AND c."modelId"=54 AND c."status"<>-2 AND a."courseCategoryKey"='review' AND left(COALESCE(a."yysj",a."bxrq"),10)>='2026-09-01' AND a."dszt" IN ('11','20','30','40'))
+ SELECT jsonb_build_object('appointmentId',aid,'appointmentGeneralId',"generalId",'studentId',"szyh",'lessonAt',"yysj",'durationMinutes',duration,'expected',expected,'charged',charged,'refunded',refunded,'adjustment',GREATEST(0,charged-expected-refunded),'status',CASE WHEN refunded>0 THEN '已处理' WHEN charged>expected THEN '待退回' ELSE '无差异' END) FROM rows
+$$;
+CREATE OR REPLACE FUNCTION xs_payroll_refunds(co text,actor text,ids jsonb,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE item jsonb;u "_User";delta numeric;before_value numeric;tx text;count integer=0;total numeric=0;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));PERFORM set_config('xiaoshu.credit_write','yes',true);
+ FOR item IN SELECT s FROM xs_payroll_refund_rows(co) s WHERE ids ? (s->>'appointmentId') LOOP
+  delta:=(item->>'adjustment')::numeric;
+  IF delta<=0 OR EXISTS(SELECT 1 FROM "PayrollReconciliation" WHERE "company"=co AND "appointmentId"=item->>'appointmentId') THEN CONTINUE; END IF;
+  SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=item->>'studentId' FOR UPDATE;
+  IF NOT FOUND THEN RAISE EXCEPTION '学员不存在';END IF;
+  before_value:=COALESCE((u."legacyUserData"->>'UserPoint')::numeric,0);tx:=xs_object_id();
+  PERFORM xs_ledger(co,u."legacyUserId",'UserPoint',delta,before_value,tx,actor,reason);
+  UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),'{UserPoint}',to_jsonb(before_value+delta)),"updatedAt"=now() WHERE "objectId"=u."objectId";
+  INSERT INTO "CreditTransaction" ("objectId","company","createdAt","updatedAt","idempotencyKey","request","kind","account","amount","studentId","operatorId","reason","balances") VALUES(tx,co,now(),now(),'reconcile:'||(item->>'appointmentId'),item,'reconciliation','UserPoint',delta,u."legacyUserId",actor,reason,jsonb_build_object('before',before_value,'after',before_value+delta));
+  INSERT INTO "PayrollReconciliation" ("company","appointmentId","amount","transactionId") VALUES(co,item->>'appointmentId',delta,tx);
+  PERFORM xs_payroll_audit(co,actor,'credit-reconciliation',item->>'appointmentId',reason,item,jsonb_build_object('refund',delta,'transactionId',tx));count:=count+1;total:=total+delta;
+ END LOOP;
+ RETURN jsonb_build_object('committed',count,'refundTotal',total);
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_writing_mapping(co text,actor text,aid text,category text,evidence text,expected timestamptz,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE c "CommonModel";a "CourseAppointment";label text;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54 FOR UPDATE;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"::text=c."itemId"::text FOR UPDATE;
+ label:=CASE category WHEN 'primary_writing' THEN '小学语法写作课' WHEN 'middle_writing' THEN '中学语法写作课' WHEN 'high_writing' THEN '高中语法写作课' END;
+ IF c."objectId" IS NULL OR c."status"=-2 OR label IS NULL OR c."subtitle" !~ '阅读|写作|语法|作文|强化' OR length(trim(evidence))<2 THEN RAISE EXCEPTION '预约、语法写作分类或学段证据无效';END IF;
+ IF a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请重新核对';END IF;
+ IF COALESCE(a."courseCategoryKey",'')<>'' AND a."courseCategoryKey"<>category AND (a."teacherPaySnapshot" IS NOT NULL OR a."dszt" NOT IN ('0','10')) THEN RAISE EXCEPTION '已保存计薪快照或已完课,请通过差异调整处理';END IF;
+ UPDATE "CourseAppointment" SET "courseCategoryKey"=category,"courseCategoryName"=label,"categoryEvidence"=evidence,"updatedAt"=now() WHERE "objectId"=a."objectId";
+ PERFORM xs_payroll_audit(co,actor,'student-writing-mapping',aid,reason,to_jsonb(a),jsonb_build_object('categoryKey',category,'evidence',evidence));RETURN jsonb_build_object('updated',true);
+END $$;
+COMMIT;

+ 46 - 0
scripts/tests/payroll-integrity.test.mjs

@@ -0,0 +1,46 @@
+import {test,before,after} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite(),source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const q=async(sql,args=[])=>(await db.query(sql,args)).rows,one=async(sql,args=[])=>(await q(sql,args))[0];
+const migration=readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8');
+const context=vm.createContext({Date,Psql:{query:q,one,oneOrNone:one}});
+vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),context);
+context.companyIdOf=()=> 'co';
+const manager={roles:['ops-manager'],current:{id:'teacher'}};
+async function command(op,target,req,id){return(await one('SELECT xs_payroll_command(\'co\',\'teacher\',$1,$2,$3::jsonb,$4) AS result',[op,target,JSON.stringify({reason:'测试工资',...req}),id])).result;}
+async function version(){return(await one('SELECT xs_payroll_source_version(\'co\') AS value')).value;}
+before(async()=>{
+ const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n  });/)[1]+')');
+ for(const [name,fields] of Object.entries(schemas)){const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);}
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,company text,id numeric,plid text,yhid text,xxjlid text,kywrq text,kywsj text,kcid numeric,fxzt text,wcsj text,"updatedAt" timestamptz);
+ CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,company text,"sourceKey" text NOT NULL UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,cdate timestamptz,cname text,type1 text,type2 text,type3 text,clevel numeric,"cadminId" numeric,detail text,content1 text,content2 text);
+ ALTER TABLE "CourseAppointment" ADD COLUMN szyh text,ADD COLUMN kcid text,ADD COLUMN pl text,ADD COLUMN fxpl text,ADD COLUMN dszt text,ADD COLUMN dslx text,ADD COLUMN jffs text,ADD COLUMN jssj text,ADD COLUMN kssj text,ADD COLUMN yysj text,ADD COLUMN bxrq text,ADD COLUMN sdsd text,ADD COLUMN plxm text,ADD COLUMN yykcid text;
+ INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyGroupId","identityType","legacyUserData","realName",username) VALUES ('teacher','co',30,3,'coach','{"ParentUserID":10}','老师','teacher'),('student','co',20,1,'member','{"UserPoint":2,"Purse":2}','学员','student'),('store','co',10,2,'store','{}','门店','store');`);
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,company text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" numeric,score numeric,"scoreBefore" numeric,"hisTime" timestamptz,operator numeric,"scoreType" numeric,detail text,remark text,"payMethod" text)`);
+ await db.exec(readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8'));
+ await db.exec(migration);
+ context.teachingCategoryRows=async()=>[];context.courseTeachingRows=async()=>[];context.payRateRows=async()=>context.defaultPayRates();
+});after(()=>db.close());
+const line={kind:'lesson',standardLessonKey:'legacy:123',legacyGeneralId:123,coachId:30,coachName:'老师',amount:40,rateAmount:40,durationHours:1,originalMonth:'2026-09',lessonAt:'2026-09-01T00:00:00Z',exception:''};let batch;
+test('北京时间月界,工资快照含零元不被规则覆盖',()=>{assert.equal(context.payrollBusinessDate('2026-09-01 07:30:00'),'2026-09-01');assert.equal(context.payrollDateIso(new Date('2026-09-01T00:00:00Z')),'2026-09-01T00:00:00.000Z');for(const pay of [0,40]){const r=context.payrollCalculatedLine({...line,classType:2,courseCategoryKey:'word',teacherPaySnapshot:pay},[],'2026-09',[],new Map());assert.equal(r.amount,pay);assert.equal(r.exception,'');}});
+test('未知运营角色不能获得经理权限',()=>assert.equal(context.roleOf({roles:['ops-unknown']}),'denied'));
+test('创建、课次跨批次防重、失败不留半张工资单',async()=>{batch=await command('create','',{month:'2026-09',lines:[line],sourceVersion:await version()},'create-001');assert.equal(batch.payableAmount,40);const retry=await command('create','',{month:'2026-09',lines:[],sourceVersion:'changed'},'create-001');assert.equal(retry.objectId,batch.objectId);await assert.rejects(command('create','',{month:'2026-10',lines:[line],sourceVersion:await version()},'duplicate-01'),/unique/);assert.equal((await one(`SELECT count(*)::int AS n FROM "PayrollBatch"`)).n,1);});
+test('调整幂等、门店和月份完整,过期版本拒绝',async()=>{const req={version:batch.version,coachId:30,amount:10};batch=await command('adjustment',batch.objectId,req,'adjustment-001');assert.equal(batch.payableAmount,50);const again=await command('adjustment',batch.objectId,req,'adjustment-001');assert.equal(again.payableAmount,50);const l=await one(`SELECT * FROM "PayrollLine" WHERE kind='adjustment'`);assert.equal(l.storeId,10);assert.equal(l.settlementMonth,'2026-09');await assert.rejects(command('adjustment',batch.objectId,req,'adjustment-002'),/已更新/);});
+test('重算已有明细保留调整,异常禁止审核,审计失败全部回滚',async()=>{batch=await command('refresh',batch.objectId,{version:batch.version,month:'2026-09',lines:[{...line,amount:45}],sourceVersion:await version()},'refresh-001');assert.equal(batch.payableAmount,55);await assert.rejects(command('review',batch.objectId,{version:batch.version,month:'2026-09',lines:[{...line,amount:0,exception:'来源异常'}],sourceVersion:await version()},'review-bad'),/异常/);assert.equal((await one(`SELECT "payableAmount" FROM "PayrollBatch"`)).payableAmount,55);await db.exec(`CREATE FUNCTION fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'audit failed';END $$; CREATE TRIGGER fail_audit BEFORE INSERT ON "SysLog" FOR EACH ROW EXECUTE FUNCTION fail_audit();`);await assert.rejects(command('adjustment',batch.objectId,{version:batch.version,coachId:30,amount:9},'audit-failure'),/audit failed/);await db.exec('DROP TRIGGER fail_audit ON "SysLog"');assert.equal((await one(`SELECT "payableAmount" FROM "PayrollBatch"`)).payableAmount,55);});
+test('审核永久锁定、登记发放校验凭据',async()=>{batch=await command('review',batch.objectId,{version:batch.version,month:'2026-09',lines:[line],sourceVersion:await version()},'review-good');await assert.rejects(command('adjustment',batch.objectId,{version:batch.version,coachId:30,amount:1},'locked-adjust'),/草稿/);await assert.rejects(command('mark-paid',batch.objectId,{version:batch.version},'paid-missing'),/凭证/);batch=await command('mark-paid',batch.objectId,{version:batch.version,paymentMethod:'转账',paymentReference:'test-001'},'paid-good');assert.equal(batch.status,'paid');});
+test('新完课入工资、事实不覆盖快照、预览和结算来源一致',async()=>{await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,kcid,pl,dszt,"teacherPaySnapshot","courseCategoryKey","durationMinutes",jffs) VALUES ('appointment','co',5,'20','100','30','11',40,'word',60,'线上'); INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",title,subtitle,status) VALUES ('ac','co',54,5,5,'学员','单词',99),('lc','co',59,6,6,'学员','单词',99); INSERT INTO "LessonRecord" ("objectId",company,id,jsmz,xymz,yyds,kcid,kclx,"lessonAt","teacherPaySnapshot") VALUES ('lesson','co',6,'30','20','5','100','2','2026-09-01 07:30+08',40); INSERT INTO "LessonRecordFact" ("objectId",company,"legacyGeneralId","teacherPaySnapshot") VALUES ('f','co',6,99);` .replace(',"teacherPaySnapshot") VALUES (\'f\',\'co\',6,99)',') VALUES (\'f\',\'co\',6)'));
+ const snapshot=await context.payrollCanonicalSnapshot(manager,{});assert.equal(snapshot.items.length,1);assert.equal(snapshot.items[0].teacherPaySnapshot,40);const preview=await context.payrollPreviewData(manager,{},'2026-09',true);assert.equal(preview.baseAmount,40);});
+test('规则同日修订保留旧版本,禁止直接覆盖',async()=>{const req={categoryKey:'word',durationMinutes:60,deliveryMode:'online',teacherPay:40,effectiveFrom:'2026-09-01',creditCosts:[{account:'SilverCoin',amount:1}],active:true,supersedesId:'',reason:'调整规则'};const a=(await one('SELECT xs_payroll_rule(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const b=(await one('SELECT xs_payroll_rule(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,teacherPay:99,supersedesId:a.objectId})])).r;assert.equal(b.revision,2);await assert.rejects(q('UPDATE "TeachingCompensationRule" SET "teacherPay"=1 WHERE "objectId"=$1',[a.objectId]),/不可改写/);const rows=await context.teachingRuleRows(manager,{});assert.equal(rows.filter(r=>r.categoryKey==='word'&&r.durationMinutes===60&&r.effectiveFrom==='2026-09-01'&&r.deliveryMode==='online'&&r.active).length,1);});
+test('按门店和老师导出,排除记录单列,汇总明细一致',async()=>{const ctx=vm.createContext({Date,Psql:{query:q,one,oneOrNone:one}});vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.payrollDetailData=async()=>({month:'2026-09',lines:[{...line,storeId:10},{...line,coachId:31,storeId:11},{...line,storeId:10,excluded:true,exclusionReason:'作废'}]});const data=await ctx.payrollExportData({}, {targetId:'batch',storeId:10,coachId:30});assert.equal(data.lines.length,1);assert.equal(data.excluded.length,1);assert.equal(data.summary.payableAmount,40);});
+test('课时差额通过账户事务退回一次,预览扣除已退金额',async()=>{await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,"courseCategoryKey","durationMinutes",yysj) VALUES ('review-appointment','co',7,'20','11','review',30,'2026-09-02 09:00');INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES ('review-common','co',54,7,7,99);INSERT INTO "UserUserPoint" ("objectId",company,"sourceKey",score) VALUES ('charge','co','cloud:e_order_update:7:period',-1);`);const before=Number((await one(`SELECT "legacyUserData"->>'UserPoint' b FROM "_User" WHERE "objectId"='student'`)).b);const run=async()=>(await one('SELECT xs_payroll_refunds(\'co\',\'teacher\',\'["review-common"]\',\'核对差额\') r')).r;assert.equal((await run()).refundTotal,.5);assert.equal((await run()).refundTotal,0);assert.equal(Number((await one(`SELECT "legacyUserData"->>'UserPoint' b FROM "_User" WHERE "objectId"='student'`)).b),before+.5);const rows=await context.creditReconciliationPreview(manager,{});assert.equal(rows.items[0].adjustment,0);assert.equal(rows.items[0].status,'已处理');});
+test('历史预留同事务、重复提交不重复预留,异常时整体回滚',async()=>{await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,kcid,yysj,"updatedAt") VALUES ('future','co',8,'20','0','100','2099-01-01 10:00',now());INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES ('future-common','co',54,8,8,99);`);const a=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='future'`);const i={appointmentId:'future-common',expectedUpdatedAt:a.updatedAt,status:'ready',reason:'',categoryKey:'word',categoryName:'单词课',durationMinutes:30,deliveryMode:'online',teachingRuleId:'saved',teacherPay:15,creditCosts:[{account:'Purse',amount:1}]};const run=async(items)=>(await one('SELECT xs_payroll_reservations(\'co\',\'teacher\',$1,\'补建预留\',$2) r',[JSON.stringify(items),await version()])).r;await assert.rejects(run([i,{...i,appointmentId:'missing'}]),/不存在/);assert.equal((await one(`SELECT count(*)::int n FROM "CourseCreditReservation"`)).n,0);assert.equal((await run([i])).reserved,1);const updated=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='future'`);assert.equal((await run([{...i,expectedUpdatedAt:updated.updatedAt}])).reserved,0);});
+test('阅读写作只修改指定预约,必须有证据并保留其他学生分类',async()=>{await db.exec(`UPDATE "CommonModel" SET subtitle='阅读写作' WHERE "objectId"='future-common';`);const a=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='future'`);await assert.rejects(q('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'future-common\',\'primary_writing\',\'小学五年级\',$1,\'核实年级\')',[a.updatedAt]),/快照/);await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,kcid,yysj,"updatedAt") VALUES ('writing','co',9,'20','0','398','2099-01-02 10:00',now());INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status,subtitle) VALUES ('writing-common','co',54,9,9,99,'阅读写作');`);const w=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='writing'`);await q('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'writing-common\',\'primary_writing\',\'用户核实小学\',$1,\'核实年级\')',[w.updatedAt]);assert.equal((await one(`SELECT "courseCategoryKey" k FROM "CourseAppointment" WHERE "objectId"='writing'`)).k,'primary_writing');});
+test('奖励导出快照及游标不受分页期间新增记录影响',async()=>{for(let i=0;i<3;i++)await q('INSERT INTO "ReviewReward" ("objectId","company","taskId","status","amount") VALUES ($1,\'co\',$1,\'historical\',0)',['receipt-'+i]);const page=await context.reviewRewardPage('co',{exportMode:true,pageSize:2});await q(`INSERT INTO "ReviewReward" ("objectId",company,"taskId",status,amount) VALUES ('receipt-new','co','receipt-new','historical',0)`);const next=await context.reviewRewardPage('co',{exportMode:true,pageSize:2,snapshot:page.snapshot,afterId:page.nextCursor});assert.equal(page.total,3);assert.equal(next.total,3);assert.equal(next.items.length,1);assert.notEqual(next.items[0].objectId,'receipt-new');});
+test('工资表禁止直接绕过事务写入,迁移重跑不改变已发放金额',async()=>{await assert.rejects(q(`INSERT INTO "PayrollBatch" ("objectId",company,month,status) VALUES ('bypass','co','2099-01','paid')`),/业务事务/);const before=await one(`SELECT "payableAmount",status FROM "PayrollBatch" WHERE "objectId"=$1`,[batch.objectId]);await db.exec(migration);assert.deepEqual(await one(`SELECT "payableAmount",status FROM "PayrollBatch" WHERE "objectId"=$1`,[batch.objectId]),before);});
+test('历史课次门店名称跟随保存的门店编号而不是教师现门店',async()=>{const ctx=vm.createContext({Date,Psql:{one:async()=>({rows:[{objectId:'history',jsmz:30,szmdid:11}],version:'v'}),query:async()=>[{objectId:'teacher',legacyUserId:30,legacyUserData:{ParentUserID:10},realName:'老师'},{objectId:'old-store',legacyUserId:11,realName:'历史门店'},{objectId:'new-store',legacyUserId:10,realName:'现门店'}]}});vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';const result=await ctx.payrollCanonicalSnapshot(manager,{});assert.equal(result.items[0].storeId,11);assert.equal(result.items[0].storeName,'历史门店');});
+test('旧单价接口也保留修订记录且审计在同一事务中',async()=>{const req={classType:2,amount:40,durationHours:1,effectiveFrom:'2026-08-01',active:true,reason:'历史工资规则修订'};const first=(await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const v=await version();await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,amount:99})]);assert.notEqual(await version(),v);assert.equal((await one(`SELECT amount FROM "CoachPayRate" WHERE "objectId"=$1`,[first.objectId])).amount,40);await assert.rejects(q(`UPDATE "CoachPayRate" SET amount=1 WHERE "objectId"=$1`,[first.objectId]),/不可改写/);assert.equal((await one(`SELECT count(*)::int n FROM "SysLog" WHERE type2='save-legacy-rate'`)).n,2);});

Някои файлове не бяха показани, защото твърде много файлове са промени