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

feat: align teacher completion, daily review pay and income experience

彭峰 1 час назад
Родитель
Сommit
503a870496
57 измененных файлов с 1190 добавлено и 118 удалено
  1. 2 0
      docs/features/lesson-workflow-2026-10-11.md
  2. 1 1
      docs/features/payroll-integrity.md
  3. 12 4
      docs/features/review-rewards.md
  4. 87 0
      docs/features/teacher-earnings-2026-10-15.md
  5. 5 1
      package.json
  6. 359 0
      perf/teacher-review-2026-10-15.json
  7. 2 2
      projects/xiaoshu-admin/src/app/admin.models.ts
  8. 4 0
      projects/xiaoshu-admin/src/app/operations.service.ts
  9. 9 2
      projects/xiaoshu-admin/src/app/pages/review-rewards.component.html
  10. 11 1
      projects/xiaoshu-admin/src/app/pages/review-rewards.component.spec.ts
  11. 22 2
      projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts
  12. 1 0
      projects/xiaoshu-mobile/src/app/app.routes.ts
  13. 1 0
      projects/xiaoshu-mobile/src/app/core/all-routes.integration.spec.ts
  14. 4 2
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  15. 30 6
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  16. 1 1
      projects/xiaoshu-mobile/src/app/core/review-page.ts
  17. 5 4
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  18. 1 1
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss
  19. 14 0
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  20. 2 1
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  21. 9 0
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss
  22. 1 1
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.html
  23. 4 0
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.scss
  24. 3 0
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts
  25. 17 6
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.html
  26. 2 0
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss
  27. 20 7
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts
  28. 13 0
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html
  29. 2 0
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss
  30. 42 22
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  31. 2 2
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html
  32. 1 1
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts
  33. 2 0
      projects/xiaoshu-mobile/src/app/layout/app-shell.component.scss
  34. 2 1
      projects/xiaoshu-mobile/src/app/layout/app-shell.component.ts
  35. 42 0
      scripts/audit-teacher-earnings.mjs
  36. 29 0
      scripts/benchmark-teacher-review.mjs
  37. 49 0
      scripts/check-teacher-earnings-browser.mjs
  38. 2 2
      scripts/cloud/lesson-workflow.js
  39. 21 8
      scripts/cloud/mobile-teaching.js
  40. 6 1
      scripts/cloud/payroll-integrity.js
  41. 8 7
      scripts/cloud/review-rewards.js
  42. 33 0
      scripts/cloud/teacher-earnings.js
  43. 8 6
      scripts/deploy-admin-functions.mjs
  44. 1 1
      scripts/migrate-lesson-workflow.mjs
  45. 29 0
      scripts/migrate-teacher-earnings.mjs
  46. 10 0
      scripts/sql/lesson-records.sql
  47. 14 7
      scripts/sql/lesson-workflow.sql
  48. 42 2
      scripts/sql/payroll-integrity.sql
  49. 51 9
      scripts/sql/review-rewards.sql
  50. 5 0
      scripts/teacher-earnings-migration.mjs
  51. 9 0
      scripts/teacher-earnings-transform.mjs
  52. 9 0
      scripts/tests/fixtures/teacher-review-schema.sql
  53. 2 2
      scripts/tests/lesson-records.test.mjs
  54. 30 0
      scripts/tests/lesson-workflow.test.mjs
  55. 39 0
      scripts/tests/payroll-integrity.test.mjs
  56. 25 5
      scripts/tests/review-rewards.test.mjs
  57. 33 0
      scripts/tests/teacher-earnings.test.mjs

+ 2 - 0
docs/features/lesson-workflow-2026-10-11.md

@@ -2,6 +2,8 @@
 
 2026-10-11。源代码实现完成;用户最后要求只在本地运行,不部署线上。本文件不代表三端真实账号验收完成。
 
+2026-10-15 增补:结课与反馈已分离。`app_lesson_complete` 可不带 feedback,报告标记 pending,正常校验、课时事务与工资快照不变。结课后由 `app_lesson_feedback_update` 首次补交或修改;未填写不显示为已提交。当前交付与验收见 [老师收入优化](teacher-earnings-2026-10-15.md)。
+
 ## 业务与实现
 
 - 流程改为后台排课 → 老师上课 → 老师一次结课(0→10→30)→ 学生随后评价。评价不再阻塞报告、完课记录或课酬快照。

+ 1 - 1
docs/features/payroll-integrity.md

@@ -17,7 +17,7 @@
 
 - 当月收入与批次结算应发分别展示,结算卡显示人工调整及补结金额。老师收入明细遵循当月收入口径,打开批次后展示该批次结算明细,导出沿用当前口径。
 - 导出统一门店、搜索、老师筛选范围,计薪、异常、已排除记录分表并附金额核对。排除原因可查看;金额不含已排除明细。
-- 抗遗忘导出使用固定创建时间边界和 objectId 游标,客户端验证总数及去重。首次完成 1 元、历史任务不自动补发等规则保持。
+- 抗遗忘导出使用固定创建时间边界和 objectId 游标,客户端验证总数及去重。2026-10-15 起仅计划当天首次完成 1 元,逾期零元;历史任务不自动补发。纠正后的有效金额读取 ReviewRewardIncome,已确认错计不会再次进入结算。
 - 工资批次完整分页;查询及明细响应有顺序保护;重置保留月份,清空门店、搜索、老师与抗遗忘记录类型。部分接口失败时显示具体区域,并说明保留旧结果。
 - 数据来源显示“新数据库”及北京时间,不再把新库结果标为缓存。历史课次的门店名称按保存的门店编号解析。
 - 历史预约预览为纯查询;补建使用账户事务锁并再次核对预约版本、规则与余额,预约、预留、审计原子写入。

+ 12 - 4
docs/features/review-rewards.md

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

+ 87 - 0
docs/features/teacher-earnings-2026-10-15.md

@@ -0,0 +1,87 @@
+# 老师结课、复习和收入优化交付记录
+
+代码基线:`7bb38260e49e21dec0f466e4de713f868aa7b365`。目标网关版本:`2026-10-15-teacher-earnings-v12`。本轮完成源码与本地验收,尚未部署生产,也未修改任何线上历史工资。
+
+## 已实现
+
+- 老师可直接结束课程,反馈后补。事务仍校验身份、课程状态、学习批次、课时凭据,保存完课工资快照、扣课时并生成适用复习计划;后补反馈不重做结课。独立 feedbackStatus 区分待补和已提交。
+- 销课记录和结课结果显示实际课酬,合法零元与工资待核对分开。无统一固定单价、无测试账号工资特例。
+- 复习首次完成按数据库生成的北京时间完成时间计薪。当天 1 元、逾期 0 元、未来拒绝,重试返回原凭据;状态与收入同事务。行锁后取实际服务器时间,避免请求排队跨午夜仍使用请求开始时间。旧 content_update 入口同样受规则约束。
+- 首页服务端先筛选今天再分页,4 条预览、今日总数/完成/待完成、单独逾期入口;未来计划仍在完整日历。没有任务、全部完成与加载失败分别呈现。
+- 首页、我的、销课和复习工资使用后台同一计算规则与净收入视图。老师收入是累计已记账收入,与工资实际发放分开;不再按课次数乘 20/40 元估算。缺失工资依据计入待核对数量。
+- 老师浏览日历、销课、工资、课程详情等页面保留现有底部导航。练习中未提交离开有提示,提交中禁止离开;完成后有继续今日、返回原列表及首页,原列表日期、学生、页码与滚动位置可恢复。缓存隔离账号、日期、筛选和业务数据版本,跨页/标签写入失效;首页跨午夜与重新可见刷新。
+- 历史错计提供只读核对工具、后台筛选和逐条纠正入口。原凭据不可改写;未结算来源净额冲销,草稿行移除,已审核/已发放原单保留,后续草稿记唯一关联调整。理由、原金额、调整金额和工资单可导出。没有运行历史纠正。
+
+## 验证证据
+
+| 检查 | 结果及限制 |
+| --- | --- |
+| Node / 数据库回归 | 211 项通过;真实 PostgreSQL DDL、触发器、约束与事务在 PGlite 执行 |
+| 移动端 ChromeHeadless | 220 项通过,含未反馈结课及服务器确认前防重复提交 |
+| 管理端 ChromeHeadless | 271 项通过,含纠正权限、理由和承接草稿版本检查 |
+| 双端生产构建 | 通过;存在原有 SCSS budget 与 CommonJS 警告 |
+| cloud:validate | 11 个函数校验通过 |
+| 本地动作覆盖检查 | 122 个使用动作检查,missing 为空 |
+| 浏览器页面 | 本地生产构建+隔离接口数据:当天/逾期完成、金额、首页刷新、错误态、导航通过;360px/390px 截图已人工查看,姓名和工资不互相挤压 |
+| 组合迁移 | 一笔事务安装,连续执行两次通过,原完课和反馈记录保留 |
+
+复现:`npm run test:server`、`npm run test:mobile:ci`、`npm run test:admin:ci`、`npm run build`、`npm run cloud:validate`、`npm run app:actions:audit:local`。浏览器验收:`npm run teacher-earnings:browser`,截图/结果写入忽略目录 `tmp/teacher-earnings-qa/`,不会连接线上账号。
+
+PGlite 单连接的并发调用会排队,不能证明生产 PostgreSQL 多连接并发性能。浏览器模拟数据不能证明线上账号、手机浏览器或微信 WebView 已通过。
+
+## 性能实测及边界
+
+固定 10,000 条任务,40 条今天任务,原始与修改后 appReviewPage 使用同一 PGlite 数据库和现有索引。先各预热 4 次,再按 AB/BA 交替各测 20 次,不注入网络延迟。原始首页请求 pending/20 条,修改后请求 today/4 条并省略日历和学员元数据。
+
+| 测量 | 原版 | 修改后 |
+| --- | ---: | ---: |
+| p50 | 120.66 ms | 34.90 ms |
+| p75 | 124.43 ms | 35.90 ms |
+| p95 | 134.27 ms | 36.39 ms |
+| 页面数据 SQL 数 | 6 | 3 |
+| 序列化响应 | 28,665 bytes | 2,046 bytes |
+| 首页结果中的今日任务 | 0 | 4 |
+| 错误率 | 0/20 | 0/20 |
+
+p75 查询阶段下降约 71%。这是本地 SQL 和序列化测量,排除了真实网络、版本查询和浏览器渲染,**不等于五条真实路径已经达成 50% 提速**。最初缺少索引的探测中,新查询反而更慢,因此保留索引核验作为发布检查;没有据此盲目添加生产索引。原始采样见 `perf/teacher-review-2026-10-15.json`。
+
+复现:准备基线提交的 `scripts/cloud/mobile-teaching.js`,执行 `npm run teacher-earnings:benchmark -- --baseline=/绝对路径/mobile-teaching.js`。线上须核查现有 CommonModel/company/model/item、CommonModel/company/model/general、DailyStudyRecord/company/id、MemoryPracticeRecord/company/user/time 与 coach/status 索引和实际执行计划。
+
+以下仍待同一隔离账号、设备、网络下交替各测至少 10 次,报告 p50/p75/p95、请求数、响应体积及错误率:
+
+1. 老师首页:从导航到今日任务和收入均可使用。
+2. 今日复习:从入口到任务列表可操作。
+3. 复习内容:从任务点击到单词可操作。
+4. 完成复习:从点击提交到服务端确认且页面显示结果。
+5. 工资明细:从入口到真实金额明细可核对。
+
+仍需关注:累计课酬读取规范化工资来源及历史规则,特定学员复习仍可能等待旧数据同步;本轮未将旧同步改成无监控的后台任务,也未宣称这两条路径已提速。
+
+## 线上只读核对
+
+2026-10-15 11:43 北京时间只读检查公开 migration_status:HTTP 200,现网仍为 `2026-10-14-member-credit-v11`,尚无 app_review_complete。只检查版本,不触碰账号或业务数据。
+
+本轮环境检查:未提供 `XIAOSHU_SESSION_TOKEN` 或 `XIAOSHU_MASTER_KEY`。因此没有核对录音中的钱老师账号、10 月 13 日课程与真实错计名单,不能把测试数据当作业务清单。
+
+在已授权环境设置现有管理员会话(不要把凭据写进文档或终端参数),使用明确老师编号和年份范围:
+
+```sh
+npm run teacher-earnings:audit -- --check
+npm run teacher-earnings:audit -- --coach-id=实际编号 --from=YYYY-MM-DD --to=YYYY-MM-DD --output=/私有路径/核对目录
+```
+
+工具只读查询,输出课程/学习批次/保存计划/既定周期/任务归属/错计凭据及工资单状态。按实际结课日核对 `1、2、3、5、7、9、11、14、17、21` 天;缺少完成日或索引时列出限制。无适用学习内容的课不要求生成单词复习任务。历史手工转交必须结合转交审计解释,不能直接按当前预约老师覆盖。错计接口要求 `beijing-day-v1` 标记,旧网关不会被误当作完整清单。
+
+先核对唯一老师身份和年份,再运行;输出私有 JSON,不进入 Git。清单完成后逐笔确认,不自动批量冲销、不自动发放。
+
+## 发布与回滚
+
+发布需确认本次提交版本,按计划另行执行。本轮没有运行以下写入步骤。
+
+1. 确认现网已具备 member-enrollment、mobile-teaching、lesson-study-batches、payroll-integrity 前置安装及 Parse 类。读取线上网关版本和发布文件哈希,确认同步任务空闲、既有索引和关键表结构。
+2. 在可信环境设置主密钥和现有经理会话。`npm run teacher-earnings:migrate` 只输出 SQL 哈希,不执行;审核哈希后使用 `--apply --backup-dir=/私有绝对路径`。脚本验证身份和活动同步任务,先保存一致性表数据、函数及列定义,再将四份兼容 SQL 作为单事务执行;失败回滚,成功回读函数和字段。临时函数限定操作者、15 分钟过期且 finally 清理。不要复用同一个备份目录覆盖上次备份。
+3. 核对 readback 三项均为 true、备份可读取。发布 `xiaoshu.app.gateway`、`xiaoshu.ops.gateway`、`xiaoshu.ops.gateway.v3`,再发布本次移动端与管理端构建;确认 migration_status 为目标版本。保留线上原始文件备份。
+4. 用隔离账号执行真实闭环和金额回读:不填反馈结课→只扣一次→待补反馈→补交不重复记薪;当天/昨天任务分别 1/0;旧客户端重试、新客户端并发、跨日重试;老师、明细、后台、导出金额一致。再核对录音账号只读清单。
+5. 分别完成手机浏览器、真实微信 WebView 和上述五条路径性能验收,再开放发布范围。具体历史工资调整只执行已核对的单条记录。
+
+回滚优先退回前端文件,必要时回退后端展示逻辑;数据库新增账本、调整凭据、防重约束、当天资格规则和已发生的真实账务必须保留。不得删除凭据、改回金额或恢复可绕过资格判断的完成函数。历史纠正不通过删行撤回;如纠正依据有误,另行审核财务调整。新旧客户端仍使用同一数据库守卫。

+ 5 - 1
package.json

@@ -63,7 +63,11 @@
     "test:direct-sync": "node --test scripts/tests/direct-nightly-sync.test.mjs scripts/tests/review-rewards.test.mjs",
     "test:server": "node --test --test-concurrency=2 scripts/tests/*.test.mjs",
     "app:actions:audit:local": "node scripts/audit-app-cloud-actions.mjs --local",
-    "verify:local": "npm run test:ci && npm run test:server && npm run build && npm run cloud:validate && npm run app:actions:audit:local"
+    "verify:local": "npm run test:ci && npm run test:server && npm run build && npm run cloud:validate && npm run app:actions:audit:local",
+    "teacher-earnings:audit": "node scripts/audit-teacher-earnings.mjs",
+    "teacher-earnings:migrate": "node scripts/migrate-teacher-earnings.mjs",
+    "teacher-earnings:browser": "node scripts/check-teacher-earnings-browser.mjs",
+    "teacher-earnings:benchmark": "node scripts/benchmark-teacher-review.mjs"
   },
   "private": true,
   "dependencies": {

+ 359 - 0
perf/teacher-review-2026-10-15.json

@@ -0,0 +1,359 @@
+{
+  "scope": "local PGlite appReviewPage SQL + serialization, 10000 tasks; excludes real network, live-version queries and browser rendering",
+  "generatedAt": "2026-10-15T03:34:46.995Z",
+  "baseline": "scripts/cloud/mobile-teaching.js at baselineCommit",
+  "alternating": true,
+  "warmupPerVersion": 4,
+  "summary": {
+    "before": {
+      "runs": 20,
+      "p50": 120.65830399999959,
+      "p75": 124.42511000000013,
+      "p95": 134.27062100000012,
+      "queries": 6,
+      "bytes": 28665,
+      "items": 20,
+      "todayItems": 0,
+      "errors": 0
+    },
+    "after": {
+      "runs": 20,
+      "p50": 34.9012859999998,
+      "p75": 35.896255999999994,
+      "p95": 36.38741200000004,
+      "queries": 3,
+      "bytes": 2046,
+      "items": 4,
+      "todayItems": 4,
+      "errors": 0
+    }
+  },
+  "samples": {
+    "before": [
+      {
+        "ms": 121.27073599999994,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 125.13146000000006,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 124.42511000000013,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 124.99080499999991,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 124.07241199999999,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 137.175299,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 134.27062100000012,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 120.27743499999997,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 118.99599400000079,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 119.77650300000005,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 120.903096,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 126.14597599999979,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 120.14247000000069,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 118.71395400000074,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 120.65830399999959,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 121.37778900000012,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 119.61216200000035,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 118.45848599999954,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 118.5008029999999,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      },
+      {
+        "ms": 120.34927000000062,
+        "queries": 6,
+        "bytes": 28665,
+        "items": 20,
+        "todayItems": 0,
+        "metadataDates": 367
+      }
+    ],
+    "after": [
+      {
+        "ms": 36.814246999999796,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 36.279597000000194,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 35.330079999999725,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 36.38741200000004,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 36.29133499999989,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 35.95505600000024,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.9012859999998,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.86000299999978,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 35.11431799999991,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.517165000000205,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 35.896255999999994,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 35.25351200000023,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 35.67925800000012,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.57966600000054,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.08334300000024,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.7807870000006,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.337986999999885,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.33303999999953,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 34.16145299999971,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      },
+      {
+        "ms": 33.894013000000086,
+        "queries": 3,
+        "bytes": 2046,
+        "items": 4,
+        "todayItems": 4,
+        "metadataDates": 0
+      }
+    ]
+  },
+  "baselineCommit": "7bb38260e49e21dec0f466e4de713f868aa7b365",
+  "indexes": "Existing project index definitions, applied identically to both variants"
+}

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

@@ -977,9 +977,9 @@ export interface MediaUploadTicket {
 export interface ReviewReward {
   objectId: string; taskId: string; coachId: number; coachName: string; studentName: string;
   courseName: string; storeId: number; storeName: string; plannedDate: string; completedAt: string;
-  incomeMonth: string; amount: number; status: 'earned' | 'historical' | 'exception';
+  incomeMonth: string; amount: number; status: 'earned' | 'historical' | 'exception' | 'ineligible';
   statusLabel: string; exception: string; batchId?: string; settlementMonth?: string;
-  canConfirm?: boolean;
+  canConfirm?: boolean; canCorrect?: boolean; batchStatus?: string; originalAmount?: number; correctionAmount?: number; correctionReason?: string; correctionId?: string; adjustmentBatchId?: string;
 }
 export interface ReviewRewardPage {
   snapshot?: string; nextCursor?: string;

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

@@ -148,6 +148,10 @@ export class OperationsService {
     return this.functions.operations('ops/payroll/review-rewards', { ...filters, page, pageSize, status });
   }
 
+  correctReviewReward(rewardId:string,reason:string,batchId='',version?:number):Promise<{unchanged?:boolean}> {
+    return this.mutate('ops/payroll/review-rewards/correct',rewardId,reason,{batchId,version});
+  }
+
   confirmLegacyReviewRewards(rewardIds: string[], reason: string): Promise<{ confirmed: number; alreadyConfirmed: number }> {
     return this.mutate('ops/payroll/review-rewards/confirm', '', reason, { rewardIds });
   }

+ 9 - 2
projects/xiaoshu-admin/src/app/pages/review-rewards.component.html

@@ -1,12 +1,19 @@
+@if(correction();as row){<section class="content-card reward-confirm" aria-label="核对逾期工资">
+  <h3>核对 {{row.coachName}} 的逾期复习工资</h3><p>计划 {{row.plannedDate}},完成 {{row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'}},原工资 ¥{{row.amount}},纠正后净收入 ¥0。</p>
+  <label>核对依据<input [ngModel]="correctionReason()" (ngModelChange)="correctionReason.set($event)" /></label>
+  @if(row.batchStatus==='reviewed'||row.batchStatus==='paid'){<label>承接扣减的草稿工资单<select [ngModel]="correctionBatch()" (ngModelChange)="correctionBatch.set($event)"><option value="">请选择</option>@for(batch of correctionBatches();track batch.objectId){<option [value]="batch.objectId">{{batch.month}}</option>}</select></label><p>原工资单保持不变,在选中草稿记录 -¥{{row.amount}} 调整。</p>}
+  @else{<p>冲销未结算的错误收入;已有草稿同步移除该笔工资。</p>}
+  <button type="button" class="ui-button primary" [disabled]="confirming()" (click)="saveCorrection()">确认本条纠正</button><button type="button" class="ui-button secondary" [disabled]="confirming()" (click)="correction.set(null)">取消</button>
+</section>}
 @if(error()){<p class="notice error" role="alert">{{error()}} <button class="ui-button secondary compact" (click)="load()">重新加载</button></p>}
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 <section class="content-card reward-card" [attr.aria-busy]="loading()">
-  <header><div><strong>抗遗忘工资</strong><span>每条抗遗忘复习计划任务首次完成计薪 1 元,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="earned">有效计薪</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出工资明细'}}</button></div></header>
+  <header><div><strong>抗遗忘工资</strong><span>每条抗遗忘任务仅计划当天首次完成计薪 1 元,逾期补做不计薪,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="incorrect">逾期错计待核对</option><option value="ineligible">逾期不计薪</option><option value="earned">有效计薪</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出工资明细'}}</button></div></header>
   @if(data();as result){
     <div class="reward-summary"><span>有效复习 <strong>{{result.summary.reviewCount}}</strong> 次</span><span>工资合计 <strong>¥{{result.summary.reviewAmount|number:'1.2-2'}}</strong></span><span>历史待核对 {{result.summary.historicalCount}} 条</span><span>异常待核对 {{result.summary.exceptionCount}} 条</span></div>
     @if(canManage&&hasConfirmable()){<div class="reward-confirm"><label>人工核对依据<input [ngModel]="confirmReason()" (ngModelChange)="confirmReason.set($event)" placeholder="例如:已核对旧系统完成与历史发放记录,确认未发放" /></label><button class="ui-button primary" type="button" (click)="confirmSelected()" [disabled]="confirming()||!selectedIds().length">{{confirming()?'核对中…':'确认选中 '+selectedIds().length+' 条,每条 ¥1'}}</button><small>仅可勾选 2026-09-01 起的旧系统完成记录;服务端再次校验来源、身份、时间和重复计薪。</small></div>}
     <div class="table-scroll"><table class="data-table"><thead><tr><th>核对</th><th>老师 / 门店</th><th>学生 / 课程</th><th>计划日期</th><th>首次完成时间</th><th>金额</th><th>结算状态</th><th>说明 / 任务</th></tr></thead><tbody>
-      @for(row of result.items;track row.objectId){<tr><td>@if(canManage&&row.canConfirm){<input type="checkbox" [attr.aria-label]="'核对复习凭据 '+row.taskId" [checked]="selectedIds().includes(row.objectId)" (change)="toggleSelection(row.objectId,$any($event.target).checked)" />}@else{—}</td><td><strong>{{row.coachName}}</strong><small>{{row.storeName}}</small></td><td>{{row.studentName}}<small>{{row.courseName||'未记录课程'}}</small></td><td>{{row.plannedDate||'未记录'}}</td><td>{{row.completedAt?(row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'):'待核对'}}</td><td class="money">¥{{row.amount|number:'1.2-2'}}</td><td><span class="badge" [class.warn]="row.status!=='earned'">{{row.statusLabel}}</span><small>{{row.settlementMonth}}</small></td><td>{{row.exception|| (row.status==='historical'?'需核对历史发放情况':'首次完成,仅计薪一次')}}<small>{{row.taskId}}</small></td></tr>}
+      @for(row of result.items;track row.objectId){<tr><td>@if(canManage&&row.canConfirm){<input type="checkbox" [attr.aria-label]="'核对复习凭据 '+row.taskId" [checked]="selectedIds().includes(row.objectId)" (change)="toggleSelection(row.objectId,$any($event.target).checked)" />}@else{—}@if(canManage&&row.canCorrect){<button class="ui-button secondary compact" type="button" (click)="openCorrection(row)">核对纠正</button>}</td><td><strong>{{row.coachName}}</strong><small>{{row.storeName}}</small></td><td>{{row.studentName}}<small>{{row.courseName||'未记录课程'}}</small></td><td>{{row.plannedDate||'未记录'}}</td><td>{{row.completedAt?(row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'):'待核对'}}</td><td class="money">¥{{row.amount|number:'1.2-2'}}</td><td><span class="badge" [class.warn]="row.status!=='earned'">{{row.statusLabel}}</span><small>{{row.settlementMonth}}</small></td><td>{{row.exception|| (row.status==='ineligible'?'逾期补做,不计薪':row.status==='historical'?'需核对历史发放情况':'当天首次完成,仅计薪一次')}}<small>{{row.correctionReason}}</small>@if(row.correctionId){<small>原金额 ¥{{row.originalAmount}} · 调整 ¥{{row.correctionAmount}}</small>}<small>{{row.taskId}}</small></td></tr>}
       @empty{<tr><td colspan="8"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无抗遗忘工资记录'}}</div></td></tr>}
     </tbody></table></div>
     <footer class="reward-pagination"><span>共 {{result.total}} 条 · 第 {{result.page}} 页</span><button class="ui-button secondary compact" (click)="load(result.page-1)" [disabled]="loading()||result.page<=1">上一页</button><button class="ui-button secondary compact" (click)="load(result.page+1)" [disabled]="loading()||result.page*result.pageSize>=result.total">下一页</button></footer>

+ 11 - 1
projects/xiaoshu-admin/src/app/pages/review-rewards.component.spec.ts

@@ -7,7 +7,7 @@ describe('ReviewRewardsComponent',()=>{
   const empty:ReviewRewardPage={items:[],total:0,page:1,pageSize:20,summary:{reviewCount:0,reviewAmount:0,historicalCount:0,exceptionCount:0}};
   let service:jasmine.SpyObj<OperationsService>;
   beforeEach(async()=>{
-    service=jasmine.createSpyObj('OperationsService',['reviewRewards','confirmLegacyReviewRewards']);service.reviewRewards.and.resolveTo(empty);service.confirmLegacyReviewRewards.and.resolveTo({confirmed:1,alreadyConfirmed:0});
+    service=jasmine.createSpyObj('OperationsService',['reviewRewards','confirmLegacyReviewRewards','correctReviewReward','payrolls']);service.reviewRewards.and.resolveTo(empty);service.confirmLegacyReviewRewards.and.resolveTo({confirmed:1,alreadyConfirmed:0});
     await TestBed.configureTestingModule({imports:[ReviewRewardsComponent],providers:[{provide:OperationsService,useValue:service}]}).compileComponents();
   });
   it('passes coach, store, month and status filters, resetting pagination when filters change',async()=>{
@@ -35,4 +35,14 @@ describe('ReviewRewardsComponent',()=>{
     component.canManage=true;await component.confirmSelected();expect(service.confirmLegacyReviewRewards).toHaveBeenCalledWith(['reward-1'],'已核对未发放');expect(component.selectedIds()).toEqual([]);
   });
 
+  it('requires a reviewed correction reason and a versioned draft for paid evidence',async()=>{
+    const component=TestBed.createComponent(ReviewRewardsComponent).componentInstance;component.filters={month:'2026-10',coachId:0,storeId:0,search:''};
+    const row:any={objectId:'reward-1',canCorrect:true,batchStatus:'paid',amount:1};
+    await component.openCorrection(row);expect(component.correction()).toBeNull();
+    component.canManage=true;service.payrolls.and.resolveTo({items:[{objectId:'draft',month:'2026-11',status:'draft',version:4}],total:1} as any);
+    await component.openCorrection(row);component.correctionReason.set('核对真实完成时间');await component.saveCorrection();expect(service.correctReviewReward).not.toHaveBeenCalled();
+    component.correctionBatch.set('draft');service.correctReviewReward.and.resolveTo({});await component.saveCorrection();
+    expect(service.correctReviewReward).toHaveBeenCalledWith('reward-1','核对真实完成时间','draft',4);expect(component.correction()).toBeNull();
+  });
+
 });

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

@@ -2,7 +2,7 @@ import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, Input, OnChanges, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { ReviewRewardFilters, ReviewRewardPage } from '../admin.models';
+import { ReviewReward, ReviewRewardFilters, ReviewRewardPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
 @Component({selector:'app-review-rewards',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
@@ -30,13 +30,33 @@ export class ReviewRewardsComponent implements OnChanges {
     catch(error){this.error.set(error instanceof Error?error.message:'历史复习工资核对失败');}
     finally{this.confirming.set(false);}
   }
+  readonly correction=signal<ReviewReward|null>(null);
+  readonly correctionReason=signal('');
+  readonly correctionBatch=signal('');
+  readonly correctionBatches=signal<Array<{objectId:string;month:string;version?:number}>>([]);
+  async openCorrection(row:ReviewReward):Promise<void>{
+    if(!this.canManage||!row.canCorrect||this.confirming())return;
+    this.correction.set(row);this.correctionReason.set('');this.correctionBatch.set('');this.correctionBatches.set([]);
+    if(['reviewed','paid'].includes(row.batchStatus||'')){
+      try{const all=[];let page=1;while(true){const data=await this.operations.payrolls(page,100);all.push(...data.items.filter(b=>b.status==='draft'));if(page*100>=data.total)break;page++;}if(this.correction()?.objectId===row.objectId)this.correctionBatches.set(all);}
+      catch(e){this.error.set(e instanceof Error?e.message:'草稿工资单加载失败');}
+    }
+  }
+  async saveCorrection():Promise<void>{
+    const row=this.correction(),reason=this.correctionReason().trim();if(!this.canManage||!row||this.confirming())return;
+    const settled=['reviewed','paid'].includes(row.batchStatus||''),batch=this.correctionBatches().find(b=>b.objectId===this.correctionBatch());
+    if(reason.length<2||settled&&!batch){this.error.set('请填写核对依据;已审核或已发放记录还须选择承接调整的草稿工资单');return;}
+    this.confirming.set(true);this.error.set('');
+    try{await this.operations.correctReviewReward(row.objectId,reason,settled?batch!.objectId:'',batch?.version);this.correction.set(null);this.message.set('已记录工资纠正,原凭据和已审核工资单保留。');await this.load(this.data()?.page||1);}
+    catch(e){this.error.set(e instanceof Error?e.message:'工资纠正失败');}finally{this.confirming.set(false);}
+  }
   async export():Promise<void> {
     if(this.exporting())return;this.exporting.set(true);this.error.set('');
     const filters={...this.filters,exportMode:true,snapshot:'',afterId:''},status=this.status();
     try {
       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})));
+        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.originalAmount??row.amount,'调整金额':row.correctionAmount??0,'工资金额':row.amount,'纠正依据':row.correctionReason||'','调整工资单':row.adjustmentBatchId||'','结算状态':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 {utils,writeFile}=await import('xlsx');const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'抗遗忘工资');writeFile(book,`抗遗忘工资_${filters.month}.xlsx`);

+ 1 - 0
projects/xiaoshu-mobile/src/app/app.routes.ts

@@ -41,6 +41,7 @@ const components: Record<PageKind, ComponentLoader> = {
 const migratedRoutes: Routes = PAGE_CATALOG.map((meta): Route => ({
   path: meta.path,
   loadComponent: meta.path === 'pages/member/acc_destory' ? () => import('./features/auth/deletion-page.component').then(m=>m.DeletionPageComponent) : meta.path === 'pages/member/passwd' ? () => import('./features/auth/password-page.component').then(m=>m.PasswordPageComponent) : unavailableReason(meta) ? () => import('./features/resource/unavailable-page.component').then(m=>m.UnavailablePageComponent) : meta.path === 'pages/shop/baobiao' ? () => import('./features/appointment/lesson-report.component').then(m=>m.LessonReportComponent) : components[meta.kind],
+  canDeactivate: meta.path === 'pages/stu/record' ? [canLeaveReadingExercise] : undefined,
   canActivate: meta.auth ? [authGuard] : undefined,
   data: { meta },
 }));

+ 1 - 0
projects/xiaoshu-mobile/src/app/core/all-routes.integration.spec.ts

@@ -53,6 +53,7 @@ describe('all migrated routes', () => {
   };
 
   beforeEach(() => {
+    for (const storage of [localStorage, sessionStorage]) for (const key of Object.keys(storage)) if (key.startsWith('xiaoshu.learning.')) storage.removeItem(key);
     TestBed.configureTestingModule({
       providers: [
         provideRouter(routes),

+ 4 - 2
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -18,6 +18,7 @@ export class ApiService {
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(SessionService);
   private readonly router = inject(Router, { optional: true });
+  dataRevision = 0;
   readonly dataChanges$ = new Subject<{ source: 'local' | 'broadcast'; changedAt: string }>();
   private readonly overviewRequests = new Map<string, Observable<ApiEnvelope<unknown>>>();
   private readonly cachedOverviewActions = new Set(['app_learning_overview', 'app_companion_overview', 'app_account_overview']);
@@ -36,7 +37,7 @@ export class ApiService {
   }
 
   callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
-    const teachingWrite=['app_lesson_feedback_update','app_lesson_complete','app_lesson_review','e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
+    const teachingWrite=['app_review_complete','app_lesson_feedback_update','app_lesson_complete','app_lesson_review','e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
     const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
     if(teachingWrite && !params['requestKey']) {
       const key=this.pendingWrites.get(requestIdentity)||crypto.randomUUID();
@@ -140,11 +141,12 @@ export class ApiService {
   private createDataChannel(): BroadcastChannel | null {
     if (typeof globalThis.BroadcastChannel !== 'function') return null;
     const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
-    channel.addEventListener('message', () => this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }));
+    channel.addEventListener('message', () => { this.dataRevision++; this.invalidateOverviews(); this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }); });
     return channel;
   }
 
   private announceDataChange(): void {
+    this.dataRevision++;
     const event = { source: 'local' as const, changedAt: new Date().toISOString() };
     this.dataChanges$.next(event);
     this.dataChannel?.postMessage(event);

+ 30 - 6
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -300,6 +300,7 @@ describe('home learning modules', () => {
   };
 
   beforeEach(() => {
+    spyOn(window, 'confirm').and.returnValue(true);
     // Route tests do not depend on remote audio availability or its error-toast timer.
     spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
     localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.skip_spelling_stage');
@@ -1242,11 +1243,11 @@ describe('home learning modules', () => {
     expect(coachAccountTab?.textContent).toContain('我的');
     expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
     const memoryApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:20 }));
+    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:4, scope:'today', includeMetadata:false, includeIncome:false }));
     expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('抗遗忘带练');
     expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('张雅茜');
     expect(harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button')?.textContent).toContain('带学生复习');
-    expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
+    expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toContain('/pages/stu/stu_calendar?date=');
     (harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=900&gid=102');
@@ -1328,7 +1329,7 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     const teacherServices = Array.from(harness.routeNativeElement?.querySelectorAll('.service-grid > a') ?? []).map((link) => link.getAttribute('href'));
     expect(teacherServices).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/feedback']);
-    expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('抗遗忘工资');
+    expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('累计复习工资');
 
     await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
     await harness.fixture.whenStable();
@@ -1451,7 +1452,18 @@ describe('home learning modules', () => {
     }
   });
 
-  it('opens feedback only on ending a lesson and submits once after validation', async () => {
+  it('ends without requiring feedback and displays the server-confirmed lesson pay', async () => {
+    user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});session.groupId.set(3);
+    const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
+    spyOn(TestBed.inject(PlatformService),'confirm').and.resolveTo(true);
+    const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/shop/get_order?id=901');await harness.fixture.whenStable();harness.fixture.detectChanges();
+    const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
+    await component.endLesson();expect(api.completionRequests.length).toBe(1);expect(api.completionRequests[0]['feedback']).toBeUndefined();
+    api.appointmentStatus=30;api.completionResponse.next({retcode:0,result:{status:30,teacherPay:45,payStatus:'earned',feedbackStatus:'pending'}});api.completionResponse.complete();
+    harness.fixture.detectChanges();expect(harness.routeNativeElement?.textContent).toContain('本节带课工资 ¥45.00');expect(harness.routeNativeElement?.textContent).toContain('反馈待补');
+  });
+
+  it('retains the optional feedback-and-complete dialog and submits once after validation', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
@@ -1464,7 +1476,7 @@ describe('home learning modules', () => {
     const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
     const endButton = harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement;
     expect(dialog.open).toBeFalse();
-    endButton.click();
+    component.openCompletionFeedback();
     harness.fixture.detectChanges();
     expect(dialog.open).toBeTrue();
     expect(api.completionRequests.length).toBe(0);
@@ -1481,7 +1493,7 @@ describe('home learning modules', () => {
     (dialog.querySelector('.feedback-actions .secondary-button') as HTMLButtonElement).click();
     expect(dialog.open).toBeFalse();
     expect(api.appointmentStatus).toBe(10);
-    endButton.click();
+    component.openCompletionFeedback();
     harness.fixture.detectChanges();
     expect((dialog.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Unit 3 词汇');
     component.reviewStatus = '完成';
@@ -1757,4 +1769,16 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
     expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
   });
+  it('waits for server completion, blocks duplicate clicks and shows zero for overdue work', async()=>{
+    user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1});
+    const api=TestBed.inject(ApiService),post=api.post.bind(api),reply=new Subject<ApiEnvelope<any>>();
+    const submit=spyOn(api,'post').and.callFake((action,params)=>action==='app_review_complete'?reply:post(action,params));
+    const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/record?id=900&gid=103&plannedDate=2000-01-01');await harness.fixture.whenStable();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    component.endRecord();component.endRecord();harness.fixture.detectChanges();expect(component.reviewCompletion()).toBeNull();expect(component.canDeactivate()).toBeFalse();
+    expect(submit.calls.allArgs().filter(a=>a[0]==='app_review_complete').length).toBe(1);
+    reply.next({retcode:1,result:{amount:0,rewardStatus:'ineligible',completed:true}});reply.complete();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('本次收入 ¥0.00');expect(component.canDeactivate()).toBeTrue();
+  });
+
 });

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/review-page.ts

@@ -1,5 +1,5 @@
 import { buildAntiForgetCalendar, buildAntiForgetView } from './anti-forget';
-export interface ReviewPage { items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}> }
+export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}> }
 export function reviewCalendar(dates:ReviewPage['dates'],month:Date,selected:string){
   const view=buildAntiForgetCalendar([],month,selected);const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
   view.days=view.days.map(day=>{const row=dates.find(d=>d.date===day.date);const total=Number(row?.total)||0,completed=Number(row?.completed)||0,pending=total-completed;return{...day,total,completed,pending,state:!total?'none' as const:!pending?'completed' as const:day.date<=today?'due' as const:'scheduled' as const};});

+ 5 - 4
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -1,4 +1,5 @@
 <section class="appointment-page">
+  @if(completionNotice()){<p class="saved-notice" role="status">{{completionNotice()}}</p>}
   @if (loading()) {
     <div class="empty-state"><p>正在加载预约...</p></div>
   } @else if (error()) {
@@ -146,18 +147,18 @@
     @if (feedback(); as f) {
       <section class="lesson-feedback">
         <header class="feedback-heading"><div><h2>完课反馈</h2><small>课堂内容与下次学习安排</small></div>
-          @if (!isStudentView && can('editFeedback')) { <button type="button" class="edit-report" (click)="openFeedbackEditor()" [disabled]="submitting()">修改报告</button> }
+          @if (!isStudentView && can('editFeedback')) { <button type="button" class="edit-report" (click)="openFeedbackEditor()" [disabled]="submitting()">{{feedback()?.['feedbackStatus']==='pending'?'补充反馈':'修改报告'}}</button> }
         </header>
         @if (feedbackSaved()) { <p class="saved-notice" role="status">报告修改已保存</p> }
-        <app-lesson-feedback [feedback]="f" />
+        @if(f['feedbackStatus']==='pending'){<p role="status">课程已结束,课堂反馈待老师补充。</p>}@else{<app-lesson-feedback [feedback]="f" />}
       </section>
     }
     <div class="appointment-actions">
       @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
       @if (can('continue')) { <button type="button" [class.primary-button]="!can('complete')" [class.secondary-button]="can('complete')" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">继续课程</button> }
-      @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">{{status()===10?'结束课程':'补齐反馈并结课'}}</button> }
+      @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="endLesson()">结束课程</button> }
       @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
-      @if (status()>=30 && !order()['cancelled']) { @if (!isStudentView && can('editFeedback')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openFeedbackEditor()">修改报告</button> } <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
+      @if (status()>=30 && !order()['cancelled']) { @if (!isStudentView && can('editFeedback')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openFeedbackEditor()">{{feedback()?.['feedbackStatus']==='pending'?'补充反馈':'修改报告'}}</button> } <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
     </div>
   }
 </section>

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss

@@ -67,7 +67,7 @@
 .report-panel h2 { display: flex; align-items: center; margin: 0 0 14px; font-size: 17px; gap: 7px; }
 .report-panel div { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--ink-soft); font-size: 12px; }
 .report-panel p { margin: 7px 0 14px; padding: 11px; color: var(--ink); line-height: 1.6; background: #f6f8f7; border-radius: var(--radius-sm); }
-.appointment-actions { position: fixed; z-index: 20; right: 0; bottom: 0; left: 0; display: flex; flex-wrap: wrap; align-items: center; padding: 12px var(--page-gutter) max(12px, env(safe-area-inset-bottom)); background: rgb(255 255 255 / 96%); border-top: 1px solid var(--line); gap: 10px; }
+.appointment-actions { position: fixed; z-index: 20; right: 0; bottom: var(--app-tabbar-offset,0px); left: 0; display: flex; flex-wrap: wrap; align-items: center; padding: 12px var(--page-gutter) max(12px, env(safe-area-inset-bottom)); background: rgb(255 255 255 / 96%); border-top: 1px solid var(--line); gap: 10px; }
 .appointment-actions:empty { display: none; }
 .appointment-actions > span { flex-basis: 100%; color: var(--ink-soft); text-align: center; font-size: 12px; }
 .appointment-actions button { display: flex; min-height: 49px; align-items: center; justify-content: center; flex: 1; gap: 6px; }

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -338,6 +338,20 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     if (!this.submitting()) this.completionDialog()?.nativeElement.close();
   }
 
+  async endLesson(): Promise<void> {
+    if (this.submitting() || !this.can('complete')) return;
+    if (this.classStudyLoading() || this.classStudyError() || !this.classStudy()) { this.actionError.set('请先加载本节学习成果,再结束课程'); return; }
+    if (this.classStudy()?.pending) { this.actionError.set('还有未确认的学习批次,请先处理'); return; }
+    if (!await this.platform.confirm('确认结束本次课程?系统将记录课时和工资,课堂反馈可以稍后补充。')) return;
+    if (this.submitting() || !this.can('complete')) return;
+    this.submitting.set(true);this.actionError.set('');
+    this.api.post<{teacherPay:number|null;payStatus:string}>('app_lesson_complete', {orderId:this.orderId,expectedUpdatedAt:this.order()['updatedAt']}).pipe(finalize(()=>this.submitting.set(false))).subscribe({
+      next:({result})=>{this.completionNotice.set(result.payStatus==='earned'&&result.teacherPay!=null?'已结课,本节带课工资 ¥'+Number(result.teacherPay).toFixed(2)+' · 反馈待补':'已结课 · 工资待核对 · 反馈待补');this.load();},
+      error:(e:ApiError)=>this.actionError.set(e.message),
+    });
+  }
+  readonly completionNotice=signal('');
+
   finishCoachLesson(): void {
     if (this.submitting() || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete') || !this.completionDialog()?.nativeElement.open) return;
     this.feedbackError.set('');

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -110,9 +110,10 @@
           <h3>{{ group.date }}</h3>
           <div class="schedule-list">
             @for (item of group.items; track $index) {
-              <article [attr.data-schedule-id]="scheduleId(item)" (click)="open(item, $event)">
+              <article [class.has-lesson-pay]="isHistory && isCoach && !item['cancelled']" [attr.data-schedule-id]="scheduleId(item)" (click)="open(item, $event)">
                 <time>{{ scheduleTime(item) }}</time>
                 <div class="schedule-copy"><strong>{{ scheduleTitle(item) }}</strong>@if (scheduleSubtitle(item)) { <span>{{ scheduleSubtitle(item) }}</span> }</div>
+                @if (isHistory && isCoach && !item['cancelled']) { <div class="lesson-pay"><strong>{{ item['payStatus'] === 'earned' ? '带课工资 ¥' + ($any(item['teacherPay']) | number:'1.2-2') : '工资待核对' }}</strong><small>{{item['feedbackStatus']==='submitted'?'反馈已提交':'反馈待补'}}</small></div> }
                 <span class="status" [class]="'status ' + statusClass(item)">{{ statusText(item) }}</span>
                 @if (isCoach) { <button type="button" class="report-button" (click)="report(item, $event)" aria-label="学习报表" title="学习报表"><lucide-icon [img]="icons.BarChart3" [size]="18" /></button> }
                 <lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" />

+ 9 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss

@@ -104,3 +104,12 @@ header span { color: var(--ink-soft); font-size: 12px; }
   .calendar-day { min-height: 39px; border-radius: 9px; }
 }
 @media (min-width: 760px) { .center-page { max-width: 760px; margin: 0 auto; border-inline: 1px solid var(--line); } }
+
+.schedule-list article.has-lesson-pay { grid-template-columns: 44px minmax(0, 1fr) auto 28px 14px; gap: 8px; }
+.has-lesson-pay time { grid-area: 1 / 1; }
+.has-lesson-pay .schedule-copy { grid-area: 1 / 2; }
+.has-lesson-pay .status { grid-area: 1 / 3; }
+.has-lesson-pay .report-button { grid-area: 1 / 4; }
+.has-lesson-pay .chevron { grid-area: 1 / 5; }
+.lesson-pay { grid-column: 2 / -1; grid-row: 2; display:flex; align-items:center; flex-wrap:wrap; gap:4px 12px; min-width:0; color:var(--brand-strong); }
+.lesson-pay small { color:var(--ink-soft); font-size:12px; }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.html

@@ -39,7 +39,7 @@
     }
   </section>
   @if (session.groupId() === 3) {
-    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>抗遗忘工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
+    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_xiao"><strong>{{refreshedAt() && coachPanel().lessonIncome != null ? '¥' + (coachPanel().lessonIncome | number:'1.2-2') : '—'}}</strong><span>累计带课工资</span></a><div><strong>{{refreshedAt() && coachPanel().totalIncome != null ? '¥' + (coachPanel().totalIncome | number:'1.2-2') : '—'}}</strong><span>累计总收入</span></div><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>累计复习工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
   } @else {
     <section class="wallet-strip"><a routerLink="/pages/member/purse"><strong>查看</strong><span>课时余额与预留</span></a><a routerLink="/pages/member/point"><strong>{{ refreshedAt()?(session.user()?.['UserPoint']??'—'):'—' }}</strong><span>可查陪练时长</span></a></section>
   }

+ 4 - 0
projects/xiaoshu-mobile/src/app/features/home/account-page.component.scss

@@ -91,3 +91,7 @@ footer { padding: 20px 16px 24px; color: var(--ink-soft); font-size: 11px; text-
   .profile-header, .account-state, .membership-card, .wallet-strip, .role-panel, .service-section, .settings-list { margin-inline: 10px; }
   .memory-overview { grid-template-columns: minmax(116px, 1fr) minmax(70px, .8fr) 36px; padding-inline: 13px; gap: 8px; }
 }
+
+.coach-wallet-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.coach-wallet-strip > div { display:flex; min-width:0; min-height:72px; align-items:center; justify-content:center; flex-direction:column; gap:3px; border-right:1px solid var(--line); }
+.coach-wallet-strip > a:last-child { grid-column:1 / -1; min-height:44px; border-top:1px solid var(--line); flex-direction:row; gap:8px; }

+ 3 - 0
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -15,6 +15,9 @@ interface CoachPanelSummary {
   todaySchedules: number;
   completedLessons: number;
   reviewIncome: number;
+  lessonIncome?: number;
+  totalIncome?: number;
+  pendingPayCount?: number;
   reviewRecords: number;
   totalLessons: number;
   totalHours: number;

+ 17 - 6
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -20,15 +20,21 @@
           <div><strong>{{ statsLoading() ? '—' : (stats()['t_tiyan'] || 0) }}</strong><span>体验课</span></div>
           <div><strong>{{ statsLoading() ? '—' : (stats()['t_total'] || 0) }}</strong><span>上课总次数</span></div>
         </div>
-        <div class="coach-footer"><span>累计时长 {{ statsLoading() ? '—' : (stats()['t_shichang'] || 0) }}</span><span class="commission">佣金 ¥{{ commissionHidden() ? '***' : (stats()['t_yongji'] || 0) }}<button type="button" (click)="commissionHidden.set(!commissionHidden())" [attr.aria-label]="commissionHidden() ? '显示佣金' : '隐藏佣金'"><lucide-icon [img]="commissionHidden() ? icons.EyeOff : icons.Eye" [size]="16" /></button></span></div>
+        <div class="coach-footer"><span>累计时长 {{ statsLoading() ? '—' : (stats()['t_shichang'] || 0) }}</span><span class="commission">累计收入<button type="button" (click)="commissionHidden.set(!commissionHidden())" [attr.aria-label]="commissionHidden() ? '显示累计收入' : '隐藏累计收入'"><lucide-icon [img]="commissionHidden() ? icons.EyeOff : icons.Eye" [size]="20" /></button></span></div>
       }
+      <div class="income-breakdown" aria-label="累计收入">
+        <a routerLink="/pages/member/ke_xiao"><span>累计带课工资</span><strong>{{ commissionHidden() ? '***' : (stats()['lessonIncome'] == null ? '—' : '¥' + (stats()['lessonIncome'] | number:'1.2-2')) }}</strong></a>
+        <a routerLink="/pages/member/ke_fuxi"><span>累计复习工资</span><strong>{{ commissionHidden() ? '***' : (stats()['reviewIncome'] == null ? '—' : '¥' + (stats()['reviewIncome'] | number:'1.2-2')) }}</strong></a>
+        <div><span>累计总收入</span><strong>{{ commissionHidden() ? '***' : (stats()['totalIncome'] == null ? '—' : '¥' + (stats()['totalIncome'] | number:'1.2-2')) }}</strong></div>
+      </div>
+      <small>已记账收入,不代表已发放@if (stats()['pendingPayCount']) { · {{ stats()['pendingPayCount'] }} 节工资待核对 }</small>
     </section>
 
     <section class="coach-memory-board" aria-labelledby="coach-memory-title">
       <header>
         <div class="coach-memory-heading">
           <span class="coach-memory-icon"><lucide-icon [img]="icons.Clock3" [size]="22" /></span>
-          <div><span>关联学员复习情况</span><h2 id="coach-memory-title">抗遗忘带练</h2></div>
+          <div><span>当天完成才计复习工资</span><h2 id="coach-memory-title">今日抗遗忘带练</h2></div>
         </div>
         <button type="button" class="coach-memory-refresh" [disabled]="antiForgetLoading()" (click)="retryAntiForget()" aria-label="刷新抗遗忘任务"><lucide-icon [img]="icons.RefreshCw" [size]="18" [class.spin]="antiForgetLoading()" /></button>
       </header>
@@ -38,9 +44,9 @@
         <button type="button" class="coach-memory-state error" (click)="retryAntiForget()"><lucide-icon [img]="icons.AlertCircle" [size]="21" /><span>{{ antiForgetError() }},点击重试</span></button>
       } @else {
         <div class="coach-memory-summary" aria-label="抗遗忘任务统计">
-          <span><strong>{{ coachReviewSummary().today }}</strong><small>今日任务</small></span>
-          <span><strong>{{ coachReviewSummary().due }}</strong><small>到期待复习</small></span>
-          <span><strong>{{ coachReviewSummary().completed }}</strong><small>已完成</small></span>
+          <span><strong>{{ coachReviewSummary().todayTotal ?? coachReviewSummary().today }}</strong><small>今日任务</small></span>
+          <span><strong>{{ coachReviewSummary().todayPending ?? 0 }}</strong><small>今日待完成</small></span>
+          <span><strong>{{ coachReviewSummary().todayCompleted ?? 0 }}</strong><small>今日已完成</small></span>
         </div>
         <div class="coach-memory-list">
           @for (item of coachReviewItems().slice(0, 4); track $index) {
@@ -51,10 +57,15 @@
               <button type="button" (click)="openCoachReview(item)">{{ coachReviewAction(item) }}</button>
             </article>
           } @empty {
-            <div class="coach-memory-empty"><lucide-icon [img]="icons.Check" [size]="24" /><strong>暂无关联复习任务</strong><span>有学员进入复习周期后会显示在这里</span></div>
+            <div class="coach-memory-empty"><lucide-icon [img]="icons.Check" [size]="24" /><strong>今天没有复习任务</strong><span>可查看全部复习计划了解后续安排</span></div>
           }
         </div>
       }
+      @if (!antiForgetLoading() && !antiForgetError()) {
+        @if ((coachReviewSummary().todayTotal ?? 0) > 0 && coachReviewSummary().todayPending === 0) { <p class="today-complete" role="status">今天的复习任务已全部完成</p> }
+        <a class="coach-memory-footer" routerLink="/pages/stu/stu_calendar" [queryParams]="{date:today()}">查看今日全部任务</a>
+        @if (coachReviewSummary().overduePending) { <a class="coach-memory-footer" routerLink="/pages/stu/stu_calendar" [queryParams]="{scope:'overdue'}">逾期补做 {{coachReviewSummary().overduePending}} 条 · 不计薪</a> }
+      }
       <a class="coach-memory-footer" routerLink="/pages/stu/stu_calendar"><span>查看全部学员复习计划</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></a>
     </section>
   } @else {

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss

@@ -165,3 +165,5 @@
 @media (max-width: 350px) { .brand img { width: 116px; height: 32px; } .brand span { max-width: 86px; font-size: 11px; } .home-main { padding-inline: 10px; } .memory-content { grid-template-columns: 124px minmax(0, 1fr); padding-inline: 10px; } .day-ring { transform: scale(.82); } .today-panel { padding-left: 7px; } .task-row { font-size: 11px; } .quick-arrow { display: none; } .quick-grid a { grid-template-columns: 42px minmax(0, 1fr); } }
 @media (min-width: 620px) { .home-main { padding-inline: 22px; } .memory-content { grid-template-columns: 280px minmax(0, 1fr); } .day-ring { width: 190px; height: 190px; } .memory-dot { transform: rotate(var(--angle)) translateY(-84px); } .today-panel { padding: 8px 30px 0; } .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
 @media (prefers-reduced-motion: reduce) { .state-spinner { animation-duration: 1.6s; } }
+
+.income-breakdown{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:16px 0 8px}.income-breakdown a,.income-breakdown>div{display:flex;flex-direction:column;gap:6px;min-height:48px;color:inherit;text-decoration:none}.income-breakdown span{font-size:12px}.income-breakdown strong{font-size:18px;overflow-wrap:anywhere}.commission button{min-width:44px;min-height:44px}.today-complete{padding:12px;color:var(--brand-strong)}

+ 20 - 7
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -1,6 +1,6 @@
 import { ReviewPage, reviewOverview } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, NgZone, OnDestroy, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { AlertCircle, BarChart3, BookMarked, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Info, LucideAngularModule, RefreshCw, Search, UserRound, Zap } from 'lucide-angular';
 import { finalize } from 'rxjs';
@@ -44,18 +44,28 @@ export class HomePageComponent implements OnInit, OnDestroy {
     const dateDifference = antiForgetDate(left).localeCompare(antiForgetDate(right));
     return stateOrder(left) === 1 ? dateDifference : -dateDifference;
   }));
-  readonly coachReviewSummary=computed(()=>this.reviewData()?.summary||{today:0,due:0,completed:0});
+  readonly coachReviewSummary=computed(()=>this.reviewData()?.summary||{today:0,due:0,completed:0,todayTotal:0,todayCompleted:0,todayPending:0,overduePending:0});
   readonly icons = { AlertCircle, BarChart3, BookMarked, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Info, RefreshCw, Search, UserRound, Zap };
 
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
   private readonly platform = inject(PlatformService);
   private readonly liveData = inject(LiveDataRefreshService);
+  private readonly zone=inject(NgZone);
+  private midnightTimer?: ReturnType<typeof setTimeout>;
+  private readonly resumeToday=()=>{if(document.visibilityState==='visible')this.retryAntiForget();};
+  private scheduleTodayRefresh():void{
+    if(this.midnightTimer)clearTimeout(this.midnightTimer);
+    const now=Date.now(),next=(Math.floor((now+8*3600000)/86400000)+1)*86400000-8*3600000;
+    this.midnightTimer=this.zone.runOutsideAngular(()=>setTimeout(()=>this.zone.run(()=>{this.retryAntiForget();this.retryCoachStats();this.scheduleTodayRefresh();}),next-now+100));
+  }
   private stopLiveRefresh?: () => void;
 
   ngOnInit(): void {
     const uid = this.session.user()?.userId;
     if (uid) {
+      this.scheduleTodayRefresh();
+      document.addEventListener('visibilitychange',this.resumeToday);
       this.loadStats(uid);
       if(!this.isCoach())this.loadBalances(uid);
       this.loadAntiForget(uid);
@@ -75,6 +85,8 @@ export class HomePageComponent implements OnInit, OnDestroy {
 
   ngOnDestroy(): void {
     this.stopLiveRefresh?.();
+    if(this.midnightTimer)clearTimeout(this.midnightTimer);
+    document.removeEventListener('visibilitychange',this.resumeToday);
   }
 
   retryCoachStats(): void {
@@ -137,7 +149,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
       groupId: 1,
     });
     void this.router.navigate(['/pages/stu/record'], {
-      queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'] },
+      queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'], plannedDate:antiForgetDate(item), date:antiForgetDate(item) },
     });
   }
 
@@ -161,11 +173,11 @@ export class HomePageComponent implements OnInit, OnDestroy {
       this.antiForgetLoading.set(true);
       this.antiForgetError.set('');
     }
-    this.api.post<ReviewPage>('app_review_page', { uid, page:1,pageSize:20,status:'pending' }).pipe(
+    this.api.post<ReviewPage>('app_review_page', { uid, page:1,pageSize:this.isCoach()?4:20,...(this.isCoach()?{scope:'today',includeMetadata:false,includeIncome:false}:{status:'pending'}) }).pipe(
       finalize(() => { if (!background) this.antiForgetLoading.set(false); }),
     ).subscribe({
-      next: ({ result }) => {this.reviewData.set(result);this.antiForgetItems.set(result.items);},
-      error: () => { if (!background) this.antiForgetError.set('抗遗忘计划暂时无法加载'); },
+      next: ({ result }) => {this.reviewData.set(result);this.antiForgetItems.set(result.items);this.antiForgetError.set('');},
+      error: () => { this.antiForgetError.set('抗遗忘计划暂时无法加载'); },
     });
   }
 
@@ -174,7 +186,8 @@ export class HomePageComponent implements OnInit, OnDestroy {
   stat(key:string):string|number {const value=this.stats()[key];return value!=null&&Number.isFinite(Number(value))?Number(value):'—';}
   balance(...keys:string[]):string|number {const p=this.balances();if(!p)return '—';for(const key of keys)if(p[key]!=null&&p[key]!==''&&Number.isFinite(Number(p[key])))return Number(p[key]);return '—';}
 
-  private today(): string {
+  today(): string {
+    if(this.reviewData()?.serverToday)return this.reviewData()!.serverToday!;
     const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date());
     const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
     return `${value('year')}-${value('month')}-${value('day')}`;

+ 13 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -433,6 +433,7 @@
     </section>
 
     <section class="memory-date-tasks" aria-live="polite">
+      @if(reviewScope()==='overdue'){<h2>逾期补做 · 不计薪</h2>}
       <header><div><span>当日复习</span><strong>{{ memorySelectedLabel() }}</strong></div><b>@if (calendarLoading()) { <span class="memory-loading-label"><lucide-icon class="spin" [img]="icons.RefreshCw" [size]="15" />正在加载</span> } @else { {{ memorySelectedTasks().length }} 项 }</b></header>
       <div class="calendar-list">
         @if (calendarLoading()) {
@@ -451,6 +452,17 @@
       </div>
     </section>
   } @else if (isRecordPage) {
+    @if(reviewCompletion(); as result){
+      <section class="review-complete" role="status"><h2>复习已完成</h2>
+        @if(canStartPointReading){<strong>{{result.unchanged?'已记账金额':'本次收入'}} ¥{{result.amount | number:'1.2-2'}}</strong><p>{{result.rewardStatus==='ineligible'?'逾期补做,不计薪':result.unchanged?'此任务已完成,不重复计薪':result.rewardStatus==='earned'?'已记录当天复习工资':'计薪凭据待核对'}}</p>}
+        <button class="primary-button" type="button" (click)="returnToReview(true)">继续今日任务</button>
+        <button class="secondary-button" type="button" (click)="returnToReview()">返回原任务列表</button>
+        <a class="secondary-button" routerLink="/pages/home/index">返回首页</a>
+      </section>
+    } @else {
+    <button class="secondary-button" type="button" (click)="returnToReview()">返回复习任务</button>
+    @if(canStartPointReading && overdueReview){<p class="record-tip" role="status">这是逾期任务,可以补做,但不计复习工资。</p>}
+
     <section class="record-overview">
       <header><div><span>单词复习</span><strong>先回忆,再核对</strong></div><b>{{ seen().size }} / {{ words().length }}</b></header>
       <div><span><strong>{{ seen().size }}</strong><small>已核对</small></span><span><strong>{{ selected().size }}</strong><small>遗忘词</small></span></div>
@@ -472,6 +484,7 @@
       @if (!isWritingRecord()) { <button type="button" class="secondary-button" (click)="addRecordWords()">加入生词本</button> }
       <button type="button" class="primary-button" [disabled]="submitting()" (click)="endRecord()">结束本次复习</button>
     </div>
+    }
   } @else if (isAntiForgetPage) {
     <div class="anti-heading"><div><strong>今日复习任务</strong><span>已完成 {{ seen().size }} / {{ words().length }}</span></div><span>勾选遗忘词可加入生词本</span></div>
     <div class="anti-grid">

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss

@@ -1375,3 +1375,5 @@ button {
 .batch-actions { grid-template-columns: repeat(2,minmax(0,1fr)); }
 .batch-actions > button:first-child { grid-column: 1 / -1; min-height: 36px; }
 .learning-page:has(.batch-actions) { padding-bottom: 135px; }
+
+.review-complete{display:grid;gap:16px;padding:28px 20px;text-align:center}.review-complete strong{font-size:24px;color:var(--brand-strong)}

+ 42 - 22
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -161,6 +161,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly memoryStudentId = signal('');
   readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12));
   readonly memorySelectedDate = signal('');
+  readonly reviewScope=signal('');
+  readonly reviewCompletion=signal<{amount:number;rewardStatus:string;unchanged?:boolean}|null>(null);
+  private reviewReturn: {date?:string;studentId?:string;page?:number;scope?:string;scroll?:number}={};
   readonly orderInfo = signal<SourceItem>({});
   readonly learnedRecord = signal<SourceItem>({});
   readonly antiHistory = signal<SourceItem>({});
@@ -259,7 +262,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly memoryView = computed(() => buildAntiForgetView(this.memoryFilteredItems()));
   readonly memorySummary = computed(() => this.memoryView().summary);
   readonly memoryCalendar=computed(()=>reviewCalendar(this.reviewPageData()?.dates||[],this.memoryMonth(),this.memorySelectedDate()));
-  readonly memorySelectedTasks = computed(() => antiForgetTasksOnDate(this.memoryFilteredItems(), this.memorySelectedDate()));
+  readonly memorySelectedTasks = computed(() => this.reviewScope() === 'overdue' ? this.memoryFilteredItems() : antiForgetTasksOnDate(this.memoryFilteredItems(), this.memorySelectedDate()));
   readonly memorySelectedLabel = computed(() => {
     const value = this.memorySelectedDate();
     if (!value) return '';
@@ -300,6 +303,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       void this.router.navigateByUrl('/pages/home/cart');
       return;
     }
+    if(this.isCalendarPage){
+      this.reviewScope.set(this.query('scope')==='overdue'?'overdue':'');
+      this.memorySelectedDate.set(this.query('date')||this.dateOnly());
+      this.memoryStudentId.set(this.query('studentId')||'');
+      this.reviewListPage.set(Math.max(1,Number(this.query('page'))||1));
+      const [y,m]=this.memorySelectedDate().split('-').map(Number);this.memoryMonth.set(new Date(y,m-1,1,12));
+    }
+    if(this.isRecordPage)this.reviewReturn=history.state?.reviewReturn||{date:this.query('date')||this.dateOnly()};
     this.load();
   }
 
@@ -944,21 +955,26 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   endRecord(): void {
-    if(this.submitting())return;
-    const now = this.dateTime();
+    if(this.submitting()||this.reviewCompletion())return;
     this.submitting.set(true);
-    this.api.post('content_update', {
-      content: JSON.stringify({ ModelID: 60, nodeId: 388, status: 99, GeneralID: this.query('gid'), UpDateTime: now }),
-      addon: JSON.stringify({ fxzt: 1, wcsj: now }),
-    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => {
-        this.reviewCache().clear();
-        void this.router.navigateByUrl('/pages/stu/stu_calendar');
-      },
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+    this.api.post<{amount:number;rewardStatus:string;unchanged?:boolean}>('app_review_complete',{id:this.query('gid')}).pipe(finalize(()=>this.submitting.set(false))).subscribe({
+      next:({result})=>{this.reviewCache().clear();this.reviewCompletion.set(result);},
+      error:(e:ApiError)=>this.platform.toast(e.message,'error'),
     });
   }
 
+  get overdueReview():boolean{return Boolean(this.query('plannedDate')&&this.query('plannedDate')<this.dateOnly());}
+  canDeactivate():boolean|Promise<boolean>{
+    if(!this.isRecordPage||this.reviewCompletion())return true;
+    if(this.submitting())return false;
+    return this.seen().size||this.selected().size||this.recordRevealed().size?this.platform.confirm('本次复习尚未提交完成,确定离开?'):true;
+  }
+  returnToReview(today=false):void{
+    const r=today?{date:this.dateOnly()}:this.reviewReturn;
+    void this.router.navigate(['/pages/stu/stu_calendar'],{queryParams:{date:r.date,studentId:r.studentId,scope:r.scope,page:r.page},state:{reviewScroll:r.scroll},replaceUrl:true});
+  }
+
+
   calendarTitle(item: SourceItem): string {
     const title = String(item['kc_title'] ?? item['kctitle'] ?? item['kcmc'] ?? '').trim();
     return title ? title.split('@')[0] : '单词复习';
@@ -1036,7 +1052,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   selectMemoryDay(day: AntiForgetCalendarDay): void {
     if (day.date === this.memorySelectedDate() && day.inCurrentMonth) return;
-    this.memorySelectedDate.set(day.date);
+    this.reviewScope.set('');this.memorySelectedDate.set(day.date);
     this.reviewListPage.set(1);this.loadCalendar();
     if (!day.inCurrentMonth) {
       const [year, month] = day.date.split('-').map(Number);
@@ -1058,7 +1074,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const today = this.dateOnly();
     const [year, month] = today.split('-').map(Number);
     this.memoryMonth.set(new Date(year, month - 1, 1, 12));
-    this.memorySelectedDate.set(today);
+    this.reviewScope.set('');this.memorySelectedDate.set(today);
     this.reviewListPage.set(1);this.loadCalendar();
   }
 
@@ -1068,7 +1084,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   memoryTaskStatus(item: SourceItem): string {
     if (this.calendarCompleted(item)) return '已完成';
-    return this.calendarReady(item) ? '待复习' : '未到期';
+    return this.calendarReady(item) ? (this.calendarDate(item)<this.dateOnly()?'逾期补做 · 不计薪':'待复习 · 当天完成计薪') : '未到期';
   }
 
   isWritingRecord(): boolean {
@@ -1091,7 +1107,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       groupId: 1,
     });
     void this.router.navigate(['/pages/stu/record'], {
-      queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'] },
+      queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'], plannedDate:this.calendarDate(item),date:this.memorySelectedDate() },
+      state:{reviewReturn:{date:this.memorySelectedDate(),studentId:this.memoryStudentId(),page:this.reviewListPage(),scope:this.reviewScope(),scroll:window.scrollY}},
     });
   }
 
@@ -1746,7 +1763,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   retryCalendar(): void { this.loadCalendar(false, true); }
 
   private reviewCache(): Map<string, ReviewPage> {
-    const identity = `${this.session.token()}|${this.session.user()?.userId ?? ''}`;
+    const identity = `${this.session.token()}|${this.session.user()?.userId ?? ''}|${this.dateOnly()}|${this.api.dataRevision ?? 0}`;
     let cache = LearningPageComponent.reviewCacheByApi.get(this.api);
     if (!cache || cache.identity !== identity) {
       cache = { identity, pages: new Map() };
@@ -1760,23 +1777,23 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.calendarRequest?.unsubscribe();
     this.calendarError.set('');
     const date = this.memorySelectedDate() || this.dateOnly();
-    const key = `${this.memoryStudentId()}|${date}|${this.reviewListPage()}`;
+    const key = `${this.memoryStudentId()}|${date}|${this.reviewListPage()}|${this.reviewScope()}`;
     const cache = this.reviewCache();
     const cached = force ? undefined : cache.get(key);
     if (cached) {
-      this.reviewPageData.set(cached);this.calendarItems.set(cached.items);
+      this.reviewPageData.set(cached);this.calendarItems.set(cached.items);this.restoreReviewScroll();
       if(!this.memorySelectedDate())this.memorySelectedDate.set(date);
       this.loading.set(false);this.calendarLoading.set(false);
       return;
     }
     if (initial) this.loading.set(true); else this.calendarLoading.set(true);
-    this.calendarRequest = this.api.post<ReviewPage>('app_review_page',{uid:this.session.user()?.userId,studentId:this.memoryStudentId()||undefined,date,page:this.reviewListPage(),pageSize:20}).pipe(finalize(()=>{
+    this.calendarRequest = this.api.post<ReviewPage>('app_review_page',{uid:this.session.user()?.userId,studentId:this.memoryStudentId()||undefined,date,scope:this.reviewScope()||undefined,page:this.reviewListPage(),pageSize:20}).pipe(finalize(()=>{
       if(requestId!==this.calendarRequestId)return;
       if(initial)this.loading.set(false);else this.calendarLoading.set(false);
     })).subscribe({next:({result})=>{
       if(requestId!==this.calendarRequestId)return;
       cache.set(key,result);
-      this.reviewPageData.set(result);this.calendarItems.set(result.items);
+      this.reviewPageData.set(result);this.calendarItems.set(result.items);this.restoreReviewScroll();
       if(!this.memorySelectedDate())this.memorySelectedDate.set(this.dateOnly());
     },error:(e:ApiError)=>{
       if(requestId!==this.calendarRequestId)return;
@@ -1784,6 +1801,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     }});
   }
   private calendarRequestId=0;
+  private restoredReviewScroll=false;
+  private restoreReviewScroll():void{if(this.restoredReviewScroll)return;this.restoredReviewScroll=true;const top=Number(history.state?.reviewScroll);if(Number.isFinite(top)&&top>0)setTimeout(()=>window.scrollTo({top,behavior:'instant'}));}
 
   private loadAntiForget(): void {
     const user = this.session.user();
@@ -2083,7 +2102,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.seen.set(new Set());
     this.recordRevealed.set(new Set());
     this.calendarItems.set([]);
-    this.memoryStudentId.set('');
+    if(!this.isCalendarPage)this.memoryStudentId.set('');
+    this.reviewCompletion.set(null);
     this.examStarted.set(false);
     this.examSubmitted.set(false);
   }

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

@@ -37,7 +37,7 @@
     }
   }
   @if (meta.path === 'pages/member/myorder_appeal') { <div class="record-tabs"><button type="button" [class.active]="activeTab() === 0" (click)="selectTab(0)">我的退货</button><button type="button" [class.active]="activeTab() === 1" (click)="selectTab(1)">我的换货</button></div> }
-  @if (meta.path === 'pages/member/ke_fuxi') { <div class="income-summary"><span>抗遗忘工资合计</span><strong>¥{{ totalIncome() | number:'1.2-2' }}</strong><small>按首次完成的计薪凭据统计</small></div> }
+  @if (meta.path === 'pages/member/ke_fuxi') { <div class="income-summary"><span>抗遗忘工资合计</span><strong>{{ loading() || error() || totalIncome() == null ? '—' : '¥' + (totalIncome() | number:'1.2-2') }}</strong><small>仅计划当天首次完成计薪,逾期补做不计薪</small></div> }
   @if (meta.path === 'pages/member/myorder' && items().length) { <section class="mastery"><h2>当前已掌握</h2><div><span>{{ items()[0]['learned'] || 0 }}<small>学习单词</small></span><span>{{ items()[0]['ygg'] || 0 }}<small>抗遗忘单词</small></span><span>{{ items()[0]['djq'] || 0 }}<small>待抗遗忘</small></span></div><footer><a routerLink="/pages/stu/index">继续学习</a><a routerLink="/pages/stu/stu_calendar">科学抗遗忘</a></footer></section> }
 
   @if (meta.path === 'pages/his/his' && session.groupId() === 3 && !studentId) {
@@ -91,7 +91,7 @@
         } @else if (meta.path === 'pages/member/myke') {
           <article class="progress-item" (click)="open(item)"><lucide-icon [img]="icons.RotateCcw" [size]="21" /><div><strong>{{ title(item) }}</strong><p>点击查看分册最新学习进度</p></div><span>复习</span></article>
         } @else if (meta.path === 'pages/member/ke_fuxi') {
-          <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} {{ item['kywsj'] }}</p>@if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计薪</small>}@else if(item['rewardStatus']==='exception'){<small>计薪异常待核对</small>}</div><b>¥{{ item['money'] || 0 }}</b></article>
+          <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} 计划 {{ item['kywrq'] || item['kywsj'] }} · 完成 {{ item['completedAt'] ? ($any(item['completedAt']) | date:'yyyy-MM-dd HH:mm':'+0800') : '待核对' }}</p>@if(item['correctionReason']){<small>原金额 ¥{{item['originalAmount']}} · 调整 ¥{{item['correctionAmount']}} · {{item['correctionReason']}}</small>}@else if(item['rewardStatus']==='ineligible'){<small>逾期补做,不计薪</small>}@else if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计薪</small>}@else if(item['rewardStatus']==='exception'){<small>计薪异常待核对</small>}</div><b>¥{{ item['money'] || 0 }}</b></article>
         } @else {
           <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="(completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解') + (readingArticleCount(item) ? ',已生成' + readingArticleCount(item) + '篇' : '')" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}@if (readingArticleCount(item)) { <span class="reading-count">{{ readingArticleCount(item) }}</span> }</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
         }

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

@@ -63,7 +63,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   readonly refreshedAt = signal('');
   readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
   readonly error = signal('');
-  readonly totalIncome = signal(0);
+  readonly totalIncome = signal<number|null>(null);
   readonly activeTab = signal(0);
   readonly datePickerOpen = signal(false);
   readonly datePickerMonth = signal(this.firstOfMonth(new Date()));

+ 2 - 0
projects/xiaoshu-mobile/src/app/layout/app-shell.component.scss

@@ -153,3 +153,5 @@
 @media (min-width: 800px) {
   .tabbar { right: auto; left: 50%; width: min(100%, 760px); transform: translateX(-50%); }
 }
+
+.app-frame{--app-tabbar-offset:calc(72px + env(safe-area-inset-bottom))}.app-frame.no-tabbar{--app-tabbar-offset:0px}

+ 2 - 1
projects/xiaoshu-mobile/src/app/layout/app-shell.component.ts

@@ -72,7 +72,8 @@ export class AppShellComponent {
 
   showTabbar(): boolean {
     const meta = this.meta();
-    return Boolean(meta && !meta.hideTabbar && PRIMARY_TAB_PATHS.has(meta.path));
+    const teacherBrowse = Number(this.session.groupId()) === 3 && ['pages/stu/stu_calendar','pages/member/ke_xiao','pages/member/ke_fuxi','pages/member/ghis','pages/shop/get_order','pages/his/his'].includes(meta?.path || '');
+    return Boolean(meta && (teacherBrowse || !meta.hideTabbar && PRIMARY_TAB_PATHS.has(meta.path)));
   }
 
   goBack(): void {

+ 42 - 0
scripts/audit-teacher-earnings.mjs

@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+// Read-only: no login, session creation, synchronization, completion or payroll mutation.
+import {mkdir,writeFile} from 'node:fs/promises';
+import {resolve} from 'node:path';
+const args=Object.fromEntries(process.argv.slice(2).map(a=>{const i=a.indexOf('=');return i<0?[a,true]:[a.slice(0,i),a.slice(i+1)];}));
+const token=process.env.XIAOSHU_SESSION_TOKEN,master=process.env.XIAOSHU_MASTER_KEY;
+if(args['--check']){console.log(JSON.stringify({readOnly:true,sessionAvailable:!!token,masterAvailable:!!master,requiredArguments:['--coach-id=ID','--from=YYYY-MM-DD','--to=YYYY-MM-DD'],privateOutputDefault:'tmp/teacher-earnings-audit'}));process.exit(0);}
+const coach=Number(args['--coach-id']),from=args['--from'],to=args['--to'];
+if(!token||!Number.isSafeInteger(coach)||coach<=0||!/^\d{4}-\d{2}-\d{2}$/.test(from||'')||!/^\d{4}-\d{2}-\d{2}$/.test(to||'')||from>to)throw Error('需要有效管理会话、明确的老师编号和核对日期范围;不会猜测同名老师身份');
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Session-Token':token,...(master?{'X-Parse-Master-Key':master}:{})};
+async function get(path){const r=await fetch(base+path,{headers,signal:AbortSignal.timeout(30000)}),d=await r.json();if(!r.ok||d.error)throw Error('只读查询失败:'+(d.error||r.status));return d;}
+async function all(name,where,keys){const rows=[];let after='';for(;;){const filter=after?{$and:[where,{objectId:{$gt:after}}]}:where;const d=await get('/classes/'+name+'?'+new URLSearchParams({where:JSON.stringify(filter),keys,limit:'500',order:'objectId'}));rows.push(...d.results);if(d.results.length<500)break;after=d.results.at(-1).objectId;}return rows;}
+const current=await get('/users/me');if(!['super-admin','ops-manager','ops-staff','ops-auditor'].includes(current.adminRoleKey))throw Error('需要管理端只读访问身份');
+const company=current.company;if(!company?.objectId)throw Error('管理会话缺少帐套');
+const report={generatedAt:new Date().toISOString(),coachId:coach,from,to,readOnly:true,issues:[],lessons:[],incorrectRewards:[],limitations:[]};
+const teachers=await all('_User',{company,legacyUserId:coach},'objectId,realName,nickname,legacyUserId');if(teachers.length!==1)throw Error('老师身份不存在或不唯一');report.teacher=teachers[0];
+const appointments=await all('CourseAppointment',{company,pl:String(coach),bxrq:{$gte:from,$lte:to+' 23:59:59'}},'id,szyh,pl,fxpl,dszt,bxrq,yysj,jssj,teacherPaySnapshot');
+const ids=appointments.map(a=>a.id);const common=ids.length?await all('CommonModel',{company,modelId:54,itemId:{$in:ids}},'itemId,generalId,status'):[];
+for(const a of appointments){const c=common.find(c=>Number(c.itemId)===Number(a.id));if(!c){report.issues.push({appointmentId:a.objectId,reason:'预约索引缺失'});continue;}
+ const gid=String(c.generalId);const [lessons,studies,reviews]=await Promise.all([all('LessonRecord',{company,yyds:gid},'objectId,teacherPaySnapshot,lessonAt'),all('DailyStudyRecord',{company,dsid:gid},'objectId,id,learned,xxqs,fxrl,dqrq'),all('MemoryPracticeRecord',{company,orderId:Number(gid)},'objectId,xxjlid,plid,kywrq,fxzt,wcsj')]);
+ const studyIds=studies.map(s=>s.id),studyIndexes=studyIds.length?await all('CommonModel',{company,modelId:56,itemId:{$in:studyIds}},'itemId,generalId'):[];
+ const completionValue=String(a.jssj||lessons[0]?.lessonAt?.iso||lessons[0]?.lessonAt||'');
+ const completionDay=/(Z|[+-]\d{2}:?\d{2})$/.test(completionValue)?new Date(completionValue).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):completionValue.slice(0,10);
+ const cycleDates=/^\d{4}-\d{2}-\d{2}$/.test(completionDay)?[1,2,3,5,7,9,11,14,17,21].map(n=>new Date(Date.parse(completionDay+'T00:00:00+08:00')+n*86400000).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'})):[];
+ const taskChecks=studies.filter(s=>Number(s.learned)>0||(()=>{try{return JSON.parse(s.xxqs||'[]').length>0;}catch{return false;}})()).map(study=>{
+  const studyIndex=studyIndexes.find(c=>Number(c.itemId)===Number(study.id));
+  const savedDates=String(study.fxrl||'').split(',').filter(d=>/^\d{8}$/.test(d)).map(d=>d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8));
+  const expectedDates=cycleDates.length?cycleDates:savedDates;
+  const tasks=reviews.filter(r=>studyIndex&&String(r.xxjlid)===String(studyIndex.generalId));
+  return {studyId:study.objectId,studyGeneralId:studyIndex?.generalId,expectedDates,savedDates,planMismatch:cycleDates.length>0&&JSON.stringify([...savedDates].sort())!==JSON.stringify([...cycleDates].sort()),missingDates:expectedDates.filter(d=>!tasks.some(r=>r.kywrq===d)),duplicateDates:expectedDates.filter(d=>tasks.filter(r=>r.kywrq===d).length>1),wrongCoachTasks:tasks.filter(r=>String(r.plid)!==String(a.fxpl||a.pl)).map(r=>r.objectId),needsPlanReview:!cycleDates.length||!studyIndex};
+ });
+ const item={appointmentId:a.objectId,generalId:gid,status:a.dszt,lessonCount:lessons.length,studyCount:studies.length,reviewCount:reviews.length,taskChecks,assignedReviewCoach:a.fxpl||a.pl,teacherPay:lessons[0]?.teacherPaySnapshot??a.teacherPaySnapshot??null};report.lessons.push(item);
+ if(Number(a.dszt)>=30&&(lessons.length!==1||taskChecks.some(c=>c.missingDates.length||c.duplicateDates.length||c.wrongCoachTasks.length||c.needsPlanReview||c.planMismatch)))report.issues.push({...item,reason:lessons.length!==1?'完课凭据缺失或重复':'逐学习批次检查计划、日期或复习老师归属(人工转交需另行核对)'});
+}
+for(let page=1;;page++){
+ const r=await fetch(base.replace(/\/parse$/,'/api/functions/xiaoshu/ops/gateway-v3'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/payroll/review-rewards',coachId:coach,status:'incorrect',page,pageSize:100}}),signal:AbortSignal.timeout(30000)});const d=await r.json();if(!r.ok||!d.success)throw Error('错计凭据查询失败(需先发布兼容只读接口)');
+ if(d.data.ruleVersion!=='beijing-day-v1')throw Error('当前网关尚不支持当天计薪核对,禁止输出可能不完整的错计清单');
+ report.incorrectRewards.push(...d.data.items);if(page*100>=d.data.total)break;
+}
+report.limitations.push('计划日期同时核对实际结课日后的既定周期和已保存计划;实际结课日缺失时只能核对保存计划;任务归属不自动更改,未执行工资纠正或补发。');
+const out=resolve(String(args['--output']||'tmp/teacher-earnings-audit'));await mkdir(out,{recursive:true,mode:0o700});await writeFile(out+'/audit.json',JSON.stringify(report,null,2),{mode:0o600});console.log(JSON.stringify({output:out+'/audit.json',lessons:report.lessons.length,issues:report.issues.length,incorrectRewards:report.incorrectRewards.length,mutations:0}));

+ 29 - 0
scripts/benchmark-teacher-review.mjs

@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+// Local SQL benchmark only; never connects to production or models artificial network delay.
+import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
+import {PGlite} from '@electric-sql/pglite';
+import vm from 'node:vm';
+const baseline=process.argv.find(a=>a.startsWith('--baseline='))?.slice(11);
+if(!baseline)throw Error('需要 --baseline=/path/to/original/mobile-teaching.js');
+const db=new PGlite();
+try{
+ await db.exec(readFileSync(new URL('./tests/fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+ await db.exec(`TRUNCATE "MemoryPracticeRecord","CommonModel";
+ INSERT INTO "MemoryPracticeRecord" SELECT 'r'||i,'co',i,30,20+i%100,100+i%100,1,CASE WHEN i%4=0 THEN '1' ELSE '0' END,CASE WHEN i<=40 THEN '2026-10-14' WHEN i<=80 THEN '2026-10-13' ELSE to_char(date '2026-10-20'+(i%365),'YYYY-MM-DD') END,to_char(date '2026-10-20'+(i%365),'YYYY-MM-DD')||' 10:00:00' FROM generate_series(1,10000) i;
+ INSERT INTO "CommonModel" SELECT company,60,id+1000,id FROM "MemoryPracticeRecord";
+ CREATE VIEW "ReviewReward" AS SELECT * FROM "ReviewRewardIncome";
+ CREATE INDEX ON "CommonModel" (company,"modelId","itemId");
+ CREATE INDEX ON "CommonModel" (company,"modelId","generalId");
+ CREATE INDEX ON "DailyStudyRecord" (company,id);
+ CREATE INDEX ON "MemoryPracticeRecord" (company,yhid,kywsj);
+ CREATE INDEX ON "MemoryPracticeRecord" (company,plid,fxzt);
+ CREATE INDEX ON "MemoryPracticeRecord" (company,id); ANALYZE;`);
+ const make=code=>{const queries=[];const query=async(sql,args)=>{const t=performance.now();const rows=(await db.query(sql,args)).rows;queries.push({ms:performance.now()-t,sql});return rows;};const c=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),reviewIncomeTotal:async()=>Number((await query('SELECT COALESCE(sum(amount),0) AS n FROM "ReviewRewardIncome" WHERE company=$1',[ 'co']))[0].n)});c.Psql={query,one:async(s,a)=>(await query(s,a))[0]};vm.runInContext(code,c);return{c,queries};};
+ const variants={before:make(readFileSync(baseline,'utf8')),after:make(readFileSync(new URL('./cloud/mobile-teaching.js',import.meta.url),'utf8'))};
+ const samples={before:[],after:[]};
+ for(let i=0;i<24;i++)for(const key of i%2?['after','before']:['before','after']){const v=variants[key];v.queries.length=0;const t=performance.now();const r=await v.c.appReviewPage(key==='before'?{status:'pending',pageSize:20}:{scope:'today',pageSize:4,includeMetadata:false,includeIncome:false},{get:()=>3});const sample={ms:performance.now()-t,queries:v.queries.length,bytes:Buffer.byteLength(JSON.stringify(r)),items:r.items.length,todayItems:r.items.filter(i=>i.kywrq==='2026-10-14').length,metadataDates:r.dates.length};if(i>=4)samples[key].push(sample);}
+ const quantile=(a,p)=>[...a].sort((x,y)=>x-y)[Math.ceil(a.length*p)-1];
+ const summary=Object.fromEntries(Object.entries(samples).map(([k,rows])=>[k,{runs:rows.length,p50:quantile(rows.map(r=>r.ms),.5),p75:quantile(rows.map(r=>r.ms),.75),p95:quantile(rows.map(r=>r.ms),.95),queries:rows[0].queries,bytes:rows[0].bytes,items:rows[0].items,todayItems:rows[0].todayItems,errors:0}]));
+ const result={scope:'local PGlite appReviewPage SQL + serialization, 10000 tasks; excludes real network, live-version queries and browser rendering',generatedAt:new Date().toISOString(),baseline,alternating:true,warmupPerVersion:4,summary,samples};
+ mkdirSync('tmp/teacher-earnings-qa',{recursive:true});writeFileSync('tmp/teacher-earnings-qa/sql-benchmark.json',JSON.stringify(result,null,2));console.log(JSON.stringify(summary,null,2));
+}finally{await db.close();}

+ 49 - 0
scripts/check-teacher-earnings-browser.mjs

@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+// Isolated local fixture browser acceptance. Does not read or write online accounts.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+const root=resolve('dist/xiaoshu-mobile/browser'),out=resolve('tmp/teacher-earnings-qa');mkdirSync(out,{recursive:true});
+const server=createServer((req,res)=>{let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));if(!file.startsWith(root+'/'))file=root+'/index.html';if(!existsSync(file)||!extname(file))file=root+'/index.html';try{res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));}catch{res.statusCode=404;res.end();}});
+await new Promise(r=>server.listen(0,'127.0.0.1',r));const origin='http://127.0.0.1:'+server.address().port;
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const checks=[],errors=[],calls=[];
+const day=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),yesterday=new Date(Date.now()-86400000).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
+const tasks=[{GeneralID:102,xxjlid:900,kcid:801,yhid:100,honeyName:'测试学员',kc_title:'高中单词课',kywrq:day,kywsj:day+' 09:00',fxzt:0},{GeneralID:103,xxjlid:900,kcid:801,yhid:100,honeyName:'测试学员',kc_title:'高中单词课',kywrq:yesterday,kywsj:yesterday+' 09:00',fxzt:0}];
+let completed=false,failReview=false;
+try{
+ const context=await browser.newContext({viewport:{width:390,height:844}});
+ await context.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-fixture-only',user:{userId:300,groupId:3,honeyName:'测试老师'},student:{userId:100,groupId:1,honeyName:'测试学员'}})));
+ await context.route('**/*',async route=>{
+  const u=new URL(route.request().url());if(u.origin!==origin)return route.abort();
+  if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();
+  const {params:p}=route.request().postDataJSON();calls.push(p);let result={};
+  if(p.action==='e_order_tongji')result={t30:1,t60:2,t_total:3,t_shichang:2.5,lessonIncome:120,reviewIncome:completed?3:2,totalIncome:completed?123:122,pendingPayCount:1};
+  else if(p.action==='app_live_versions')result={version:String(completed),scopeVersions:{},refreshedAt:new Date().toISOString(),syncStatus:{state:'current'}};
+  else if(p.action==='app_review_page'){
+   if(failReview)return route.fulfill({status:500,json:{retcode:-1,retmsg:'模拟读取失败'}});
+   const items=p.scope==='today'?tasks.slice(0,1):p.scope==='overdue'?tasks.slice(1):p.status==='completed'?tasks.filter(t=>t.fxzt===1):tasks.filter(t=>!p.date||t.kywrq===p.date);
+   result={serverToday:day,items,page:{itemCount:items.length,pageCount:1},summary:{total:2,completed:completed?1:0,due:completed?1:2,today:1,todayTotal:1,todayCompleted:completed?1:0,todayPending:completed?0:1,overduePending:1,income:completed?3:2},dates:[{date:day,total:1,completed:completed?1:0},{date:yesterday,total:1,completed:0}],students:[{id:100,name:'测试学员',taskCount:2}]};
+  }else if(p.action==='e_record_detail')result=[{GeneralID:1001,Title:'apple',sy:'苹果'}];
+  else if(p.action==='app_review_complete'){const task=tasks.find(t=>t.GeneralID===Number(p.id));const unchanged=task.fxzt===1;task.fxzt=1;task.money=task.kywrq===day?1:0;task.rewardStatus=task.money?'earned':'ineligible';task.ineligibleReason=task.money?'':'overdue';if(task.money)completed=true;result={completed:true,amount:task.money,rewardStatus:task.rewardStatus,unchanged};}
+  else if(p.action==='app_companion_overview')result={courses:[],schedules:[45,0,null].map((pay,i)=>({GeneralID:901+i,Title:'测试学员(单词课)',bxrq:day,sdsd:'10:00',dszt:30,teacherPay:pay,payStatus:pay===null?'pending':'earned',feedbackStatus:i?'submitted':'pending'})),dates:[{date:day,finished:3,planned:0}],page:{itemCount:3,pageCount:1},studentOptions:[],refreshedAt:new Date().toISOString()};
+  else if(p.action==='app_account_overview')result={profile:{},coachPanel:{lessonIncome:120,reviewIncome:3,totalIncome:123,totalLessons:3,totalHours:2.5},refreshedAt:new Date().toISOString()};
+  else if(p.action==='user_list')result=[];
+  await route.fulfill({json:{retcode:1,result}});
+ });
+ const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
+ await page.goto(origin+'/pages/home/index');await page.locator('.coach-memory-list article').waitFor();
+ assert.match(await page.locator('.income-breakdown').innerText(),/\*\*\*/);await page.getByRole('button',{name:'显示累计收入'}).click();await page.waitForFunction(()=>document.querySelector('.income-breakdown')?.textContent.includes('¥122.00'));assert.match(await page.locator('.income-breakdown').innerText(),/¥120\.00[\s\S]*¥2\.00[\s\S]*¥122\.00/);
+ assert.ok(calls.some(p=>p.action==='app_review_page'&&p.scope==='today'&&p.pageSize===4&&p.includeMetadata===false));checks.push('首页当天范围、三项累计收入同显同隐');
+ await page.screenshot({path:out+'/home-390.png',fullPage:true});
+ await page.getByRole('button',{name:'带学生复习',exact:true}).click();await page.locator('.record-list').waitFor();await page.getByRole('button',{name:'结束本次复习',exact:true}).click();await page.locator('.review-complete').waitFor();assert.match(await page.locator('.review-complete').innerText(),/本次收入 ¥1\.00/);await page.screenshot({path:out+'/review-complete-390.png',fullPage:true});
+ await page.getByRole('link',{name:'返回首页',exact:true}).click();await page.getByText('今天的复习任务已全部完成').waitFor();checks.push('完成结果显示服务器金额、返回首页更新今日计数');
+ await page.getByText('逾期补做 1 条 · 不计薪').click();await page.locator('.memory-date-tasks').waitFor();assert.ok(await page.locator('.tabbar').isVisible());await page.getByRole('button',{name:'带学生复习',exact:false}).first().click();await page.getByText('这是逾期任务,可以补做,但不计复习工资。').waitFor();await page.getByRole('button',{name:'结束本次复习',exact:true}).click();await page.locator('.review-complete').waitFor();assert.match(await page.locator('.review-complete').innerText(),/本次收入 ¥0\.00/);checks.push('逾期入口、补做前说明、完成零元显示');
+ await page.goto(origin+'/pages/member/ke_xiao');await page.locator('.lesson-pay').first().waitFor();assert.match(await page.locator('body').innerText(),/带课工资 ¥45\.00/);assert.match(await page.locator('body').innerText(),/带课工资 ¥0\.00/);assert.match(await page.locator('body').innerText(),/工资待核对/);assert.ok(await page.locator('.tabbar').isVisible());
+ for(const width of [390,360]){await page.setViewportSize({width,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));assert.ok(await page.locator('.schedule-copy').first().evaluate(e=>e.getBoundingClientRect().width>=80));await page.screenshot({path:out+'/history-'+width+'.png',fullPage:true});}
+ checks.push('销课45元、合法零元、待核对分开显示;360/390宽度无横向溢出且固定导航可见');
+ failReview=true;await page.goto(origin+'/pages/home/index');await page.getByText('抗遗忘计划暂时无法加载,点击重试').waitFor();assert.equal(await page.getByText('今天没有复习任务').count(),0);checks.push('接口失败不会显示没有任务');
+ assert.deepEqual(errors,[]);writeFileSync(out+'/browser-result.json',JSON.stringify({passed:true,environment:'local build + isolated fixture API, not live backend or WeChat',checks,requests:calls.length,errors},null,2));console.log(JSON.stringify({passed:true,checks},null,2));
+}finally{await browser.close();await new Promise(r=>server.close(r));}

+ 2 - 2
scripts/cloud/lesson-workflow.js

@@ -23,10 +23,10 @@ async function appLessonComplete(input, current) {
   assertLessonTenant(current);
   const id = String(input.orderId || '').trim(), key = String(input.requestKey || '');
   if (!id || key.length < 10 || key.length > 160 || !input.expectedUpdatedAt || Number.isNaN(Date.parse(input.expectedUpdatedAt))) fail(400, '缺少预约编号、请求编号或详情版本,请刷新后重试');
-  const feedback = lessonFeedbackInput(input);
+  const feedback = input.feedback == null ? null : lessonFeedbackInput(input);
   try {
     return (await Psql.one('SELECT xs_lesson_complete($1,$2,$3,$4,$5::timestamptz,$6,$7::jsonb) AS result',
-      [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),input.expectedUpdatedAt,key,JSON.stringify(feedback)])).result;
+      [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),input.expectedUpdatedAt,key,feedback === null ? null : JSON.stringify(feedback)])).result;
   } catch (error) {
     const message = String(error.message || error);
     if (message.includes('历史凭据')) await Psql.none('INSERT INTO "LessonWorkflowIssue" (company,"appointmentGeneralId",message) VALUES($1,$2,$3) ON CONFLICT(company,"appointmentGeneralId") DO UPDATE SET message=EXCLUDED.message,"updatedAt"=now()', [DEFAULT_COMPANY_ID,id,message]);

+ 21 - 8
scripts/cloud/mobile-teaching.js

@@ -21,26 +21,39 @@ async function appLessonReport(input,current) {
   return {order:await lessonOrderCapabilities(order,current),feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()};
 }
 async function appReviewPage(input,current) {
-  const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
+  const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input),includeMetadata=input.includeMetadata!==false,today=appOverviewToday();
   const reviewTarget=coach&&number(input.studentId)?await appReviewTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined;
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
   // A legacy writing lesson can have a ten-round plan but no saved words. Do not list it as a word-review task.
   clauses.push('NOT EXISTS (SELECT 1 FROM "CommonModel" sc JOIN "DailyStudyRecord" d ON d."company"=sc."company" AND d."id"=sc."itemId" WHERE sc."company"=m."company" AND sc."modelId"=56 AND sc."generalId"::text=m."xxjlid"::text AND COALESCE(d."learned",0)=0 AND COALESCE(NULLIF(BTRIM(d."xxqs"),\'\'),\'[]\')=\'[]\')');
   const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
-  const students=coach?await Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',args):[];
+  const studentsPromise=coach&&includeMetadata? Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',[...args]):Promise.resolve([]);
   if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId));
   const baseWhere=clauses.join(' AND '),baseArgs=[...args];
   const dateSql='LEFT(COALESCE(NULLIF(m."kywrq",\'\'),m."kywsj"),10)';
-  const summary=await Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed,COUNT(*) FILTER(WHERE COALESCE(m."fxzt",\'0\')<>\'1\' AND '+dateSql+'<=$'+(args.length+1)+')::int due,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+')::int today FROM "MemoryPracticeRecord" m WHERE '+baseWhere,[...args,appOverviewToday()]);
-  const dates=await Psql.query('SELECT '+dateSql+' AS date,COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" m WHERE '+baseWhere+' GROUP BY '+dateSql,baseArgs);
-  if(input.date)add(dateSql+'=?',String(input.date));
+  const summaryPromise= Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed,COUNT(*) FILTER(WHERE COALESCE(m."fxzt",\'0\')<>\'1\' AND '+dateSql+'<=$'+(args.length+1)+')::int due,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+')::int today,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+' AND m."fxzt"=\'1\')::int \"todayCompleted\",COUNT(*) FILTER(WHERE '+dateSql+'<$'+(args.length+1)+' AND COALESCE(m."fxzt",\'0\')<>\'1\')::int \"overduePending\" FROM "MemoryPracticeRecord" m WHERE '+baseWhere,[...args,today]);
+  const datesPromise=includeMetadata? Psql.query('SELECT '+dateSql+' AS date,COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" m WHERE '+baseWhere+' GROUP BY '+dateSql,baseArgs):Promise.resolve([]);
+  if(input.scope==='today')add(dateSql+'=?',today);
+  else if(input.scope==='overdue') {add(dateSql+'<?',today);clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');}
+  else if(input.date)add(dateSql+'=?',String(input.date));
   if(input.month)add('LEFT('+dateSql+',7)=?',String(input.month));
   if(input.status==='completed')clauses.push('m."fxzt"=\'1\'');
   if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');
-  const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
-  const rows=await Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",COALESCE(u."realName",u."nickname",u."username") AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewReward" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+' ORDER BY m."kywsj" DESC,m."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
+  const where=clauses.join(' AND '),countPromise=Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
+  const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",COALESCE(u."realName",u."nickname",u."username") AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
-  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,income:coach?await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0},dates,students,legacyReviewSync,...mobileSource(),...await appLiveVersionSnapshot(versionTarget,['review','account'],current)};
+  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
+  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};
+}
+
+async function appReviewComplete(input,current) {
+  assertLessonTenant(current);
+  const gid=String(input.id||'');
+  const task=await Psql.oneOrNone('SELECT m."objectId" FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId" WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"::text=$2',[DEFAULT_COMPANY_ID,gid]);
+  if(!task)fail(404,'复习任务不存在');
+  // Completion and receipt readback share one statement/transaction. Old clients use the same SQL command.
+  try { const result=await Psql.one('SELECT xs_review_complete_result($1,$2,$3) AS result',[DEFAULT_COMPANY_ID,task.objectId,current.id]);return result.result; }
+  catch(error){fail(409,String(error.message||error));}
 }
 
 function mobileCommandKey(input) {const key=String(input.requestKey||'');return /^[A-Za-z0-9:_-]{10,160}$/.test(key)?key:null;}

+ 6 - 1
scripts/cloud/payroll-integrity.js

@@ -53,7 +53,7 @@ async function payrollPreviewData(context,input,month,includeUsed=false,requireL
  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)]);
+ const rewards=await Psql.query('SELECT r.* FROM "ReviewRewardIncome" 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};
@@ -168,3 +168,8 @@ async function operationalSyncIndicator(context,input){
  const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret){const receipt=await directSyncReceipt(context,input).catch(()=>null);if(receipt){const lastSuccessAt=payrollDateIso(receipt.completedAt);return{state:'delayed',label:'旧接口批次已同步,保留差异待核对',lastSuccessAt,lagSeconds:Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000))};}return{state:'not_configured',label:'尚未启用持续同步',lastSuccessAt:'',lagSeconds:null};}
  const rows=await newClassObjects('LegacySyncCheckpoint',context,input,1000),critical=rows.filter(row=>PAYROLL_SYNC_DATASETS.includes(text(row.get('dataset')))),lastSuccessAt=critical.map(row=>dateIso(row.get('lastSuccessAt'))).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=critical.length===3&&critical.every(row=>text(row.get('state'))==='healthy')&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'工资相关数据刚刚同步':'数据同步延迟,正在追平',lastSuccessAt,lagSeconds};
 }
+
+async function correctReviewReward(context,input,payload){
+ requireRole(context,['ops-manager']);
+ return (await payrollSql('SELECT xs_review_reward_correct($1,$2,$3,$4,$5,$6::int) AS result',[payrollTenant(context,input),context.current.id,text(input.targetId),reasonOf(input),text(payload.batchId),payload.version??null])).result;
+}

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

@@ -1,6 +1,6 @@
 // Included verbatim in both gateway bundles. Read-only queries share the immutable ledger.
 async function reviewIncomeTotal(companyId, coachId) {
-  const row = await Psql.one('SELECT COALESCE(SUM("amount"),0) AS total FROM "ReviewReward" WHERE "company"=$1 AND "coachId"=$2 AND "status"=\'earned\'', [companyId, Number(coachId)]);
+  const row = await Psql.one('SELECT COALESCE(SUM("amount"),0) AS total FROM "ReviewRewardIncome" WHERE "company"=$1 AND "coachId"=$2 AND "status"=\'earned\'', [companyId, Number(coachId)]);
   return Number(row.total);
 }
 function rewardPayrollLine(row, month) {
@@ -11,12 +11,12 @@ function rewardPayrollLine(row, month) {
     legacyGeneralId: 0, lessonId: '', lessonAt: iso, originalMonth: row.incomeMonth, settlementMonth: month,
     classType: 0, classTypeLabel: '抗遗忘工资', courseCategoryName: '抗遗忘工资', courseCategoryKey: '',
     durationMinutes: 0, durationHours: 0, deliveryMode: '', deliveryLabel: '', rateAmount: Number(row.amount),
-    amount: Number(row.amount), score: 0, exception: '', description: '计划复习首次完成 · ' + row.plannedDate };
+    amount: Number(row.amount), score: 0, exception: '', description: '计划当天首次完成 · ' + row.plannedDate };
 }
 async function reviewRewardPayrollLines(companyId, month, includeUsed) {
   if (!companyId) throw new Error('抗遗忘工资必须指定帐套');
   if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
-  const rows = await Psql.query('SELECT r.* FROM "ReviewReward" r WHERE r."company"=$1 AND r."status"=\'earned\' AND ' +
+  const rows = await Psql.query('SELECT r.* FROM "ReviewRewardIncome" r WHERE r."company"=$1 AND r."status"=\'earned\' AND ' +
     (includeUsed ? 'r."incomeMonth"=$2' : 'r."incomeMonth"<=$2 AND NOT EXISTS (SELECT 1 FROM "PayrollLine" l WHERE l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND l."kind"=\'review_reward\' AND COALESCE(l."excluded",false)=false)') +
     ' ORDER BY r."completedAt",r."objectId"', [companyId, month]);
   return rows.map((row) => rewardPayrollLine(row, month));
@@ -30,13 +30,14 @@ async function reviewRewardPage(companyId, input) {
   if (Number(input.storeId)) add('r."storeId"=?', Number(input.storeId));
   if (Number(input.coachId)) add('r."coachId"=?', Number(input.coachId));
   if (input.search) add('(r."coachName" ILIKE ? OR r."coachId"::text ILIKE ?)', '%' + String(input.search).trim() + '%');
-  if (['earned','historical','exception'].includes(input.status)) add('r."status"=?', input.status);
+  if(input.status==='incorrect')clauses.push("r.status='earned' AND xiaoshu_review_eligibility(r.\"plannedDate\",r.\"completedAt\")='overdue'");
+  if (['earned','historical','exception','ineligible'].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 '+summaryWhere, summaryValues);
-  const rows = await Psql.query('SELECT r.*,m."sourceKey" AS "taskSourceKey",m."fxzt" AS "taskStatus",m."rewardDuplicate" AS "taskDuplicate",l."batchId",b."month" AS "settlementMonth",b."status" AS "batchStatus" FROM "ReviewReward" r LEFT JOIN "MemoryPracticeRecord" m ON m."company"=r."company" AND m."objectId"=r."taskId" 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)=>{const completedAt=row.completedAt?new Date(row.completedAt).toISOString():'',businessDay=completedAt?new Date(completedAt).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'',canConfirm=row.status==='historical'&&!row.exception&&businessDay>='2026-09-01'&&row.taskStatus==='1'&&row.taskDuplicate!==true&&(/^(legacy:model:60:|legacy-sync:memory-records:addon:)/.test(row.taskSourceKey||''));const {taskSourceKey,taskStatus,taskDuplicate,...visible}=row;return {...visible,amount:Number(row.amount),completedAt,canConfirm,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 "ReviewRewardIncome" r WHERE '+summaryWhere, summaryValues);
+  const rows = await Psql.query('SELECT r.*,m."sourceKey" AS "taskSourceKey",m."fxzt" AS "taskStatus",m."rewardDuplicate" AS "taskDuplicate",l."batchId",b."month" AS "settlementMonth",b."status" AS "batchStatus" FROM "ReviewRewardIncome" r LEFT JOIN "MemoryPracticeRecord" m ON m."company"=r."company" AND m."objectId"=r."taskId" 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 { ruleVersion:'beijing-day-v1',snapshot,nextCursor:input.exportMode&&rows.length===pageSize?rows[rows.length-1].objectId:'',items: rows.map((row)=>{const completedAt=row.completedAt?new Date(row.completedAt).toISOString():'',businessDay=completedAt?new Date(completedAt).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'',canConfirm=row.status==='historical'&&!row.exception&&businessDay>='2026-09-01'&&businessDay===row.plannedDate&&row.taskStatus==='1'&&row.taskDuplicate!==true&&(/^(legacy:model:60:|legacy-sync:memory-records:addon:)/.test(row.taskSourceKey||''));const {taskSourceKey,taskStatus,taskDuplicate,...visible}=row;return {...visible,amount:Number(row.amount),completedAt,canConfirm,canCorrect:row.status==='earned'&&businessDay>row.plannedDate&&Boolean(row.plannedDate)&&!row.correctionId,statusLabel:row.correctionId?'已纠正,净收入 ¥0':row.status==='ineligible'?'逾期补做,不计薪':row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')};}), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
 }

+ 33 - 0
scripts/cloud/teacher-earnings.js

@@ -0,0 +1,33 @@
+// Read-only teacher view of the SAME canonical sources/calculator as operations payroll.
+async function teacherLessonEarnings(companyId,coachId,appointmentIds=null) {
+  const calc=teacherEarningsCalculator();
+  const [sources,storedRates,storedRules,mappings]=await Promise.all([
+    Psql.query('SELECT s FROM xs_payroll_sources($1) s WHERE COALESCE(s->>\'coachId\',s->>\'jsmz\')=$2 AND ($3::text[] IS NULL OR COALESCE(s->>\'appointmentGeneralId\',s->>\'yyds\')=ANY($3))',[companyId,String(coachId),appointmentIds]),
+    Psql.query('SELECT * FROM "CoachPayRate" WHERE company=$1 ORDER BY "createdAt" DESC,"objectId" DESC',[companyId]),
+    Psql.query('SELECT * FROM "TeachingCompensationRule" WHERE company=$1 ORDER BY revision DESC,"createdAt" DESC,"objectId"',[companyId]),
+    Psql.query('WITH RECURSIVE ancestors AS (SELECT n."nodeId" AS course_id,n."nodeId",n."parentId",n."nodeName",0 AS depth FROM "Node" n WHERE company=$1 UNION ALL SELECT a.course_id,n."nodeId",n."parentId",n."nodeName",a.depth+1 FROM ancestors a JOIN "Node" n ON n.company=$1 AND n."nodeId"=a."parentId" WHERE a.depth<12) SELECT p.*,n."nodeName" AS "courseName",array_agg(a."nodeName" ORDER BY a.depth) AS ancestor_names FROM "CourseTeachingProfile" p LEFT JOIN "Node" n ON n.company=p.company AND n."nodeId"=p."courseId" LEFT JOIN ancestors a ON a.course_id=p."courseId" WHERE p.company=$1 GROUP BY p."objectId",n."nodeName"',[companyId])
+  ]);
+  const rateKeys=new Set(),rates=[];
+  for(const row of storedRates){const key=row.classType+':'+row.effectiveFrom;if(rateKeys.has(key))continue;rateKeys.add(key);rates.push({...row,classType:Number(row.classType),amount:Number(row.amount),durationHours:Number(row.durationHours),active:row.active!==false,isDefault:false});}
+  rates.push(...calc.defaultRates().filter(r=>!rateKeys.has(r.classType+':'+r.effectiveFrom)));
+  const ruleKeys=new Set(),rules=[];
+  for(const row of storedRules){const key=[row.categoryKey,row.durationMinutes,row.deliveryMode,row.effectiveFrom].join(':');const superseded=ruleKeys.has(key);ruleKeys.add(key);rules.push({...calc.rule(row),active:row.active!==false&&!superseded});}
+  rules.push(...calc.defaultRules().filter(r=>!ruleKeys.has([r.categoryKey,r.durationMinutes,r.deliveryMode,r.effectiveFrom].join(':'))));
+  const mappingMap=new Map(mappings.filter(r=>calc.category(r)).map(r=>[Number(r.courseId),r]));
+  const unique=new Map();
+  for(const {s:raw} of sources){const row={...calc.project(raw,'cache'),standardLessonKey:raw.standardLessonKey,commonStatus:Number(raw.commonStatus??99),teacherPaySnapshot:raw.teacherPaySnapshot==null?null:Number(raw.teacherPaySnapshot)};if(row.commonStatus===-2)continue;if(unique.has(row.standardLessonKey))unique.get(row.standardLessonKey).duplicate=true;else unique.set(row.standardLessonKey,row);}
+  const lines=[...unique.values()].map(r=>calc.calculate(r,rates,'',rules,mappingMap));
+  return {lines,lessonIncome:lines.reduce((sum,l)=>sum+Math.round(l.amount*100),0)/100,pendingPayCount:lines.filter(l=>l.exception).length};
+}
+async function teacherIncomeSummary(companyId,coachId) {
+  const [lessons,reviewIncome]=await Promise.all([teacherLessonEarnings(companyId,coachId),reviewIncomeTotal(companyId,coachId)]);
+  return {lessonIncome:lessons.lessonIncome,reviewIncome,totalIncome:(Math.round(lessons.lessonIncome*100)+Math.round(reviewIncome*100))/100,pendingPayCount:lessons.pendingPayCount};
+}
+async function teacherSchedulePay(rows,coachId) {
+  const ids=rows.filter(r=>Number(r.dszt)>=30&&Number(r.commonStatus)!==-2).map(r=>String(r.generalId));
+  if(!ids.length)return rows;
+  const [earnings,reports]=await Promise.all([teacherLessonEarnings(DEFAULT_COMPANY_ID,coachId,ids),Psql.query('SELECT "appointmentGeneralId"::text AS id,"feedbackStatus" FROM "LessonCompletionReport" WHERE company=$1 AND "coachId"=$2 AND "appointmentGeneralId"::text=ANY($3::text[])',[DEFAULT_COMPANY_ID,Number(coachId),ids])]);
+  return rows.map(row=>{const matches=earnings.lines.filter(l=>String(l.appointmentGeneralId)===String(row.generalId)),line=matches[0],valid=matches.length===1&&!line.exception;return {...row,teacherPay:valid?line.amount:null,payStatus:valid?'earned':'pending',payReason:line?.exception||'工资待核对',feedbackStatus:reports.find(r=>r.id===String(row.generalId))?.feedbackStatus||'pending'};});
+}
+
+async function teacherIncomeLegacySummary(companyId,coachId){const result=await teacherIncomeSummary(companyId,coachId);return {...result,t_yongji:String(result.totalIncome)};}

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


+ 1 - 1
scripts/migrate-lesson-workflow.mjs

@@ -28,7 +28,7 @@ function invokeMigration(url,token) {
 // Register additive Parse fields before SQL, so schema metadata and physical columns agree.
 for(const [name,fields] of Object.entries({
  CommonModel:{teachingManaged:{type:'Boolean'}},CourseAppointment:{teachingManaged:{type:'Boolean'}},
- LessonRecord:{teachingManaged:{type:'Boolean'}},LessonCompletionReport:{coachComment:{type:'String'}},
+ LessonRecord:{teachingManaged:{type:'Boolean'}},LessonCompletionReport:{feedbackStatus:{type:'String'},coachComment:{type:'String'}},
 })){
  const schema=await request('/schemas/'+name),missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields?.[key]));
  if(Object.keys(missing).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields:missing})});

+ 29 - 0
scripts/migrate-teacher-earnings.mjs

@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+import {teacherEarningsMigration} from './teacher-earnings-migration.mjs';
+import {mkdir,writeFile} from 'node:fs/promises';
+import {resolve} from 'node:path';
+import {createHash,randomUUID} from 'node:crypto';
+const sql=teacherEarningsMigration(),sha=createHash('sha256').update(sql).digest('hex');
+if(!process.argv.includes('--apply')){console.log(JSON.stringify({ready:true,applied:false,sha256:sha,transactionCount:1,prerequisites:['member-enrollment','mobile-teaching','lesson-study-batches','payroll-integrity'],message:'使用 --apply --backup-dir=私有绝对路径 执行;不自动纠正历史工资。'}));process.exit(0);}
+const backupArg=process.argv.find(a=>a.startsWith('--backup-dir='))?.slice(13),master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SESSION_TOKEN;
+if(!backupArg?.startsWith('/')||!master||!token)throw Error('需要私有绝对备份路径、主密钥及现有管理员会话,不自动创建账号');
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+async function parse(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master,'X-Parse-Session-Token':token},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'迁移访问失败');return d;}
+const user=await parse('/users/me');if(!['super-admin','ops-manager'].includes(user.adminRoleKey)||user.isDisabled||user.isDeleted||!user.company?.objectId)throw Error('有效管理员身份缺失');
+const name='xiaoshu/system/teacher-earnings-'+randomUUID(),expires=Date.now()+15*60000;let functionId;
+const backup=resolve(backupArg);await mkdir(backup,{recursive:true,mode:0o700});
+const tables=['CourseAppointment','LessonRecord','LessonCompletionReport','MemoryPracticeRecord','ReviewReward','ReviewRewardCorrection','PayrollBatch','PayrollLine','PayrollCommand'];
+const code=`async function handler(request,response){try{
+ const current=request.user;if(!current||current.id!==${JSON.stringify(user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});await current.fetch({useMasterKey:true});if(current.get('isDisabled')||current.get('isDeleted')||!['super-admin','ops-manager'].includes(current.get('adminRoleKey')))return response.status(403).json({success:false});
+ const active=await Psql.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[${JSON.stringify(user.company.objectId)},'running']);if(active.n)throw Error('夜间同步正在运行,暂缓迁移');
+ const mode=request.body.params.mode;
+ if(mode==='backup'){const snapshot=await Psql.tx(async t=>{await t.none('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');const out={};for(const table of ${JSON.stringify(tables)}){const exists=await t.one('SELECT to_regclass($1) present',['"'+table+'"']);if(exists.present)out[table]=await t.query('SELECT to_jsonb(x) AS row FROM "'+table+'" x');}out.functions=await t.query("SELECT proname,pg_get_functiondef(oid) AS definition FROM pg_proc WHERE pronamespace='public'::regnamespace AND (proname LIKE 'xs_%' OR proname LIKE 'xiaoshu_%')");out.columns=await t.query('SELECT * FROM information_schema.columns WHERE table_schema=$1',['public']);return out;});return response.json({success:true,snapshot});}
+ if(mode!=='apply'||request.body.params.sha256!==${JSON.stringify(sha)})throw Error('迁移版本不匹配');await Psql.none(${JSON.stringify(sql)});
+ const readback=await Psql.one('SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name=$1 AND column_name=$2) AS "feedbackStatus",to_regprocedure($3) IS NOT NULL AS "reviewCompletion",to_regprocedure($4) IS NOT NULL AS correction',['LessonCompletionReport','feedbackStatus','xs_review_complete_result(text,text,text)','xs_review_reward_correct(text,text,text,text,text,integer)']);return response.json({success:true,readback,sha256:${JSON.stringify(sha)}});
+ }catch(e){return response.status(500).json({success:false,message:String(e.message||e)});}}`;
+async function call(mode){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+name,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{mode,sha256:sha}})});const d=await r.json();if(!r.ok||!d.success)throw Error(d.message||'迁移失败');return d;}
+try{
+ functionId=(await parse('/classes/Function','POST',{name,path:name,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ const data=await call('backup');await writeFile(backup+'/before.json',JSON.stringify(data.snapshot),{mode:0o600,flag:'wx'});await writeFile(backup+'/migration.sql',sql,{mode:0o600,flag:'wx'});
+ const result=await call('apply');await writeFile(backup+'/result.json',JSON.stringify(result,null,2),{mode:0o600,flag:'wx'});if(!Object.values(result.readback).every(Boolean))throw Error('迁移回读不完整,禁止发布网关');console.log(JSON.stringify({applied:true,backup,sha256:sha,readback:result.readback}));
+}finally{if(functionId)await parse('/classes/Function/'+functionId,'DELETE');}

+ 10 - 0
scripts/sql/lesson-records.sql

@@ -100,6 +100,16 @@ BEGIN
  IF uid IS NULL OR (uid<>task."plid"::text AND uid<>task."yhid"::text) THEN RAISE EXCEPTION '复习已转交或不属于当前账号,请刷新列表'; END IF;
  PERFORM xiaoshu_complete_review(co,task_id);
 END $$;
+CREATE OR REPLACE FUNCTION xs_review_complete_result(co text, task_id text, actor text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE receipt "ReviewReward"; already_done boolean;
+BEGIN
+ SELECT fxzt::text='1' INTO already_done FROM "MemoryPracticeRecord" WHERE company=co AND "objectId"=task_id FOR UPDATE;
+ PERFORM xs_complete_assigned_review(co,task_id,actor);
+ SELECT (jsonb_populate_record(NULL::"ReviewReward",to_jsonb(r))).* INTO receipt FROM "ReviewRewardIncome" r WHERE r.company=co AND r."taskId"=task_id;
+ IF NOT FOUND THEN RAISE EXCEPTION '复习计薪凭据缺失,请联系管理员核对'; END IF;
+ RETURN jsonb_build_object('taskId',task_id,'completed',true,'unchanged',COALESCE(already_done,false),'amount',receipt.amount,'rewardStatus',receipt.status,'ineligibleReason',receipt."ineligibleReason",'plannedDate',receipt."plannedDate",'completedAt',receipt."completedAt");
+END $$;
+REVOKE ALL ON FUNCTION xs_review_complete_result(text,text,text) FROM PUBLIC;
 CREATE INDEX IF NOT EXISTS xs_review_student_pending ON "MemoryPracticeRecord" ("company","yhid","fxzt");
 CREATE INDEX IF NOT EXISTS xs_review_transfer_task ON "ReviewTransferAudit" ("company","taskId");
 COMMIT;

+ 14 - 7
scripts/sql/lesson-workflow.sql

@@ -18,6 +18,13 @@ ALTER TABLE "CommonModel" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
 ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
 ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "coachComment" text;
 
+-- Existing reports were submitted; new feedback-free completions explicitly use pending.
+ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "feedbackStatus" text NOT NULL DEFAULT 'submitted';
+UPDATE "LessonCompletionReport" SET "feedbackStatus"='submitted' WHERE "feedbackStatus" IS NULL;
+ALTER TABLE "LessonCompletionReport" ALTER COLUMN "feedbackStatus" SET DEFAULT 'submitted';
+ALTER TABLE "LessonCompletionReport" ALTER COLUMN "feedbackStatus" SET NOT NULL;
+ALTER TABLE "LessonCompletionReport" DROP CONSTRAINT IF EXISTS lesson_feedback_status;
+ALTER TABLE "LessonCompletionReport" ADD CONSTRAINT lesson_feedback_status CHECK ("feedbackStatus" IN ('pending','submitted'));
 CREATE OR REPLACE FUNCTION xs_lesson_complete(co text,gid text,actor text,actor_uid numeric,expected timestamptz,request_key text,feedback jsonb)
 RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "LessonCompletionReport";
@@ -27,12 +34,12 @@ DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "Lesson
 BEGIN
  IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
  IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
- IF NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR length(feedback->>'contentSummary')>1000
+ IF feedback IS NOT NULL AND (NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR length(feedback->>'contentSummary')>1000
  OR length(COALESCE(feedback->>'reviewStatus',''))>200
  OR length(COALESCE(feedback->>'trainingStatus',''))>200
  OR NULLIF(trim(feedback->>'trainingSummary'),'') IS NULL OR length(feedback->>'trainingSummary')>1000
  OR (NOT COALESCE((feedback->>'noHomework')::boolean,false) AND NULLIF(trim(feedback->>'homework'),'') IS NULL)
- THEN RAISE EXCEPTION '请完整填写学习内容、进度说明和作业';END IF;
+ ) THEN RAISE EXCEPTION '请完整填写学习内容、进度说明和作业';END IF;
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "generalId"::text=gid;
  SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId";
@@ -48,7 +55,7 @@ BEGIN
  END IF;
  IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)>1 THEN RAISE EXCEPTION '历史凭据存在重复报告,请由后台核对';END IF;
  SELECT * INTO report FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid ORDER BY "submittedAt" DESC LIMIT 1;
- IF a.dszt='30' AND report."objectId" IS NOT NULL THEN RETURN jsonb_build_object('GeneralID',gid,'status',30,'unchanged',true,'feedbackId',report."objectId");END IF;
+ IF a.dszt='30' AND report."objectId" IS NOT NULL THEN RETURN jsonb_build_object('GeneralID',gid,'status',30,'unchanged',true,'feedbackId',report."objectId",'feedbackStatus',report."feedbackStatus",'teacherPay',(SELECT COALESCE(lr."teacherPaySnapshot",a."teacherPaySnapshot") FROM "LessonRecord" lr WHERE lr.company=co AND lr."objectId"=report."lessonId"),'payStatus',CASE WHEN (SELECT COALESCE(lr."teacherPaySnapshot",a."teacherPaySnapshot") FROM "LessonRecord" lr WHERE lr.company=co AND lr."objectId"=report."lessonId") IS NULL THEN 'pending' ELSE 'earned' END);END IF;
  IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
  IF a.dszt NOT IN ('10','11','20') THEN RAISE EXCEPTION '请先开始课程';END IF;
  -- Versioned batches and completion serialize on the same xs-credit lock.
@@ -123,15 +130,15 @@ BEGIN
  report_id=COALESCE(report."objectId",xs_object_id());
  -- Existing partial reports are replaced in the same transaction, preserving their ID.
  DELETE FROM "LessonCompletionReport" WHERE "objectId"=report_id AND company=co;
- INSERT INTO "LessonCompletionReport" SELECT * FROM jsonb_populate_record(NULL::"LessonCompletionReport",feedback||jsonb_build_object(
+ INSERT INTO "LessonCompletionReport" SELECT * FROM jsonb_populate_record(NULL::"LessonCompletionReport",COALESCE(feedback,'{}'::jsonb)||jsonb_build_object(
   'objectId',report_id,'company',co,'createdAt',COALESCE(report."createdAt",now()),'updatedAt',now(),'sourceKey','cloud:lesson-feedback:'||co||':'||gid,
   'appointmentId',a."objectId",'appointmentGeneralId',gid,'lessonId',l."objectId",'studentId',a.szyh,'coachId',a.pl,
   'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
   'actualStartAt',NULLIF(a.kssj,'')::timestamptz,'actualEndAt',COALESCE(NULLIF(a.jssj,'')::timestamptz,now()),'creditCosts',a."creditCostsSnapshot",
   'vocabularyCount',jsonb_array_length(words),'antiForgetSummary',jsonb_build_object('total',total_reviews,'completed',completed_reviews,'pending',total_reviews-completed_reviews),
-  'homework',CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,'submittedBy',actor,'submittedAt',now()));
+  'homework',CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,'feedbackStatus',CASE WHEN feedback IS NULL THEN 'pending' ELSE 'submitted' END,'submittedBy',CASE WHEN feedback IS NULL THEN NULL ELSE actor END,'submittedAt',CASE WHEN feedback IS NULL THEN NULL ELSE now() END));
  DELETE FROM "LessonWorkflowIssue" WHERE company=co AND "appointmentGeneralId"=gid;
- result=jsonb_build_object('GeneralID',gid,'status',30,'lessonId',l."objectId",'feedbackId',report_id,'periodDeducted',was_charged);
+ result=jsonb_build_object('GeneralID',gid,'status',30,'lessonId',l."objectId",'feedbackId',report_id,'periodDeducted',was_charged,'feedbackStatus',CASE WHEN feedback IS NULL THEN 'pending' ELSE 'submitted' END,'teacherPay',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),'payStatus',CASE WHEN COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot") IS NULL THEN 'pending' ELSE 'earned' END);
  INSERT INTO "LessonWorkflowCommand" VALUES(co,actor,request_key,jsonb_build_object('id',gid,'feedback',feedback),result,now());
  RETURN result;
 END $$;
@@ -228,7 +235,7 @@ BEGIN
  "contentSummary"=feedback->>'contentSummary',"reviewStatus"=feedback->>'reviewStatus',"reviewSummary"=feedback->>'reviewSummary',
  "trainingStatus"=feedback->>'trainingStatus',"trainingSummary"=feedback->>'trainingSummary',
  homework=CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,
- "noHomework"=COALESCE((feedback->>'noHomework')::boolean,false),"coachComment"=feedback->>'coachComment',notes=feedback->>'notes',"updatedAt"=clock_timestamp()
+ "feedbackStatus"='submitted',"submittedBy"=actor,"submittedAt"=COALESCE(report."submittedAt",clock_timestamp()),"noHomework"=COALESCE((feedback->>'noHomework')::boolean,false),"coachComment"=feedback->>'coachComment',notes=feedback->>'notes',"updatedAt"=clock_timestamp()
  WHERE "objectId"=report."objectId";
  UPDATE "LessonRecord" SET pldp=COALESCE(feedback->>'coachComment',''),plpjsj=now()::text,"updatedAt"=now()
  WHERE company=co AND "objectId"=report."lessonId" AND yyds::text=gid;

+ 42 - 2
scripts/sql/payroll-integrity.sql

@@ -63,7 +63,7 @@ CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGU
  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),'')||
- COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "ReviewReward" r WHERE r."company"=co AND r."status"='earned'),''))
+ COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "ReviewRewardIncome" r WHERE r."company"=co AND r."status"='earned'),''))
 $$;
 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
@@ -85,7 +85,7 @@ BEGIN
   IF reward."status"<>'historical' OR reward."amount"<>0 OR COALESCE(reward."exception",'')<>'' THEN RAISE EXCEPTION '凭据不是可核对的历史完成记录:%',item; END IF;
   SELECT * INTO task FROM "MemoryPracticeRecord" WHERE "company"=co AND "objectId"=reward."taskId" FOR UPDATE;
   finished:=xiaoshu_safe_shanghai_timestamp(task."wcsj"::text);
-  IF task."objectId" IS NULL OR task."fxzt"::text<>'1' OR (COALESCE(task."sourceKey",'') NOT LIKE 'legacy:model:60:%' AND COALESCE(task."sourceKey",'') NOT LIKE 'legacy-sync:memory-records:addon:%') OR task."rewardDuplicate" IS TRUE OR reward."taskKey" IS NULL OR reward."taskKey"<>xiaoshu_review_key(task."yhid"::text,task."xxjlid"::text,left(task."kywrq"::text,10)) OR finished IS NULL OR finished AT TIME ZONE 'Asia/Shanghai'<'2026-09-01'::timestamp OR reward."completedAt" IS DISTINCT FROM finished OR reward."incomeMonth"<>to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM') OR reward."coachId"::text<>task."plid"::text OR reward."studentId"::text<>task."yhid"::text THEN RAISE EXCEPTION '旧系统复习记录未通过来源、身份或完成时间核对:%',item; END IF;
+  IF task."objectId" IS NULL OR task."fxzt"::text<>'1' OR (COALESCE(task."sourceKey",'') NOT LIKE 'legacy:model:60:%' AND COALESCE(task."sourceKey",'') NOT LIKE 'legacy-sync:memory-records:addon:%') OR task."rewardDuplicate" IS TRUE OR reward."taskKey" IS NULL OR reward."taskKey"<>xiaoshu_review_key(task."yhid"::text,task."xxjlid"::text,left(task."kywrq"::text,10)) OR finished IS NULL OR xiaoshu_review_eligibility(reward."plannedDate",finished)<>'eligible' OR finished AT TIME ZONE 'Asia/Shanghai'<'2026-09-01'::timestamp OR reward."completedAt" IS DISTINCT FROM finished OR reward."incomeMonth"<>to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM') OR reward."coachId"::text<>task."plid"::text OR reward."studentId"::text<>task."yhid"::text THEN RAISE EXCEPTION '旧系统复习记录未通过来源、身份或完成时间核对:%',item; END IF;
   IF NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE "company"=co AND "modelId"=56 AND "generalId"::text=task."xxjlid"::text) OR NOT EXISTS(SELECT 1 FROM "_User" WHERE "company"=co AND "legacyUserId"::text=task."plid"::text) OR NOT EXISTS(SELECT 1 FROM "_User" WHERE "company"=co AND "legacyUserId"::text=task."yhid"::text) OR EXISTS(SELECT 1 FROM "ReviewReward" WHERE "company"=co AND "taskKey"=reward."taskKey" AND "status"='earned') THEN RAISE EXCEPTION '旧系统复习记录缺少有效关联或存在重复计薪:%',item; END IF;
   PERFORM set_config('xiaoshu.review_reward_confirm','yes',true);
   UPDATE "ReviewReward" SET "status"='earned',"amount"=1 WHERE "company"=co AND "objectId"=item;
@@ -108,6 +108,7 @@ BEGIN
  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 IN ('UPDATE','DELETE') AND EXISTS(SELECT 1 FROM "ReviewRewardCorrection" c WHERE c.company=OLD.company AND OLD."sourceKey"='adjustment:'||c."adjustmentRequestId") 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;
@@ -115,6 +116,7 @@ BEGIN
  NEW."settlementMonth":=b."month";
  IF NEW."kind"='review_reward' THEN
   SELECT * INTO r FROM "ReviewReward" WHERE "company"=b."company" AND "objectId"=NEW."reviewRewardId";
+  IF EXISTS(SELECT 1 FROM "ReviewRewardCorrection" WHERE company=b.company AND "rewardId"=r."objectId") THEN RAISE EXCEPTION '复习工资已纠正,不可重新计薪'; END IF;
   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";
@@ -316,4 +318,42 @@ BEGIN
  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 $$;
+-- Explicit human-reviewed correction. Never rewrite an earned receipt or a locked batch.
+CREATE OR REPLACE FUNCTION xs_review_reward_correct(co text,actor text,reward_id text,reason text,target_batch text,expected integer) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "ReviewReward"; correction "ReviewRewardCorrection"; source_line "PayrollLine"; source_batch "PayrollBatch"; target_doc "PayrollBatch"; result jsonb; correction_id text:=xs_object_id(); request_id text;
+BEGIN
+ IF length(trim(COALESCE(reason,'')))<2 THEN RAISE EXCEPTION '请填写工资纠正依据'; END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."objectId"=actor AND NOT COALESCE((to_jsonb(u)->>'isDisabled')::boolean,false) AND NOT COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false) AND (to_jsonb(u)->>'adminRoleKey' IN ('super-admin','ops-manager') OR to_jsonb(u)->'roles' ? 'ops-manager')) THEN RAISE EXCEPTION '仅管理人员可以纠正工资'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ SELECT * INTO correction FROM "ReviewRewardCorrection" WHERE company=co AND "rewardId"=reward_id;
+ IF FOUND THEN
+  IF correction.reason IS DISTINCT FROM reason OR COALESCE(correction."adjustmentBatchId",'') IS DISTINCT FROM COALESCE(target_batch,'') THEN RAISE EXCEPTION '该工资已纠正,请刷新核对原调整'; END IF;
+  RETURN to_jsonb(correction)||jsonb_build_object('unchanged',true);
+ END IF;
+ SELECT * INTO r FROM "ReviewReward" WHERE company=co AND "objectId"=reward_id FOR UPDATE;
+ IF NOT FOUND OR r.status<>'earned' OR r.amount<>1 OR xiaoshu_review_eligibility(r."plannedDate",r."completedAt")<>'overdue' THEN RAISE EXCEPTION '仅已核实的逾期错计凭据可纠正'; END IF;
+ SELECT * INTO source_line FROM "PayrollLine" WHERE company=co AND "reviewRewardId"=reward_id AND kind='review_reward' AND NOT COALESCE(excluded,false) FOR UPDATE;
+ IF FOUND THEN SELECT * INTO source_batch FROM "PayrollBatch" WHERE company=co AND "objectId"=source_line."batchId" FOR UPDATE; END IF;
+ IF source_batch.status IN ('reviewed','paid') THEN
+  SELECT * INTO target_doc FROM "PayrollBatch" WHERE company=co AND "objectId"=target_batch FOR UPDATE;
+  IF NOT FOUND OR target_doc.status<>'draft' OR target_doc.version IS DISTINCT FROM expected THEN RAISE EXCEPTION '请选择有效草稿工资单并刷新版本'; END IF;
+  request_id:='review-correction:'||reward_id;
+  result:=xs_payroll_command(co,actor,'adjustment',target_batch,jsonb_build_object('version',expected,'coachId',r."coachId",'amount',-r.amount,'reason','复习工资纠正 ['||reward_id||']:'||reason),request_id);
+ ELSE
+  IF NULLIF(target_batch,'') IS NOT NULL THEN RAISE EXCEPTION '尚未审核的工资只冲销来源,不应重复扣减结算'; END IF;
+  IF source_batch.status='draft' THEN
+   PERFORM set_config('xiaoshu.payroll_write','yes',true);
+   DELETE FROM "PayrollLine" WHERE company=co AND "objectId"=source_line."objectId";
+   UPDATE "PayrollBatch" SET "updatedAt"=now() WHERE company=co AND "objectId"=source_batch."objectId";
+  ELSIF source_line."objectId" IS NOT NULL THEN RAISE EXCEPTION '原工资单状态异常,请先核对'; END IF;
+ END IF;
+ PERFORM set_config('xiaoshu.review_correction','yes',true);
+ INSERT INTO "ReviewRewardCorrection"("objectId",company,"rewardId",amount,reason,actor,"sourceBatchId","adjustmentBatchId","adjustmentRequestId")
+ VALUES(correction_id,co,reward_id,-r.amount,reason,actor,source_batch."objectId",NULLIF(target_batch,''),request_id) RETURNING * INTO correction;
+ PERFORM set_config('xiaoshu.review_correction','no',true);
+ PERFORM xs_payroll_audit(co,actor,'correct-review-reward',reward_id,reason,to_jsonb(r),to_jsonb(correction));
+ RETURN to_jsonb(correction);
+END $$;
+REVOKE ALL ON FUNCTION xs_review_reward_correct(text,text,text,text,text,integer) FROM PUBLIC;
+
 COMMIT;

+ 51 - 9
scripts/sql/review-rewards.sql

@@ -16,9 +16,43 @@ CREATE TABLE IF NOT EXISTS "ReviewReward" (
   "plannedDate" text NOT NULL DEFAULT '', "completedAt" timestamptz, "incomeMonth" text NOT NULL DEFAULT '',
   "amount" numeric(12,2) NOT NULL DEFAULT 0, "status" text NOT NULL,
   "exception" text NOT NULL DEFAULT '', "createdAt" timestamptz NOT NULL DEFAULT now(),
-  UNIQUE ("company","taskId"), CHECK ("status" IN ('earned','historical','exception')),
+  UNIQUE ("company","taskId"), CHECK ("status" IN ('earned','historical','exception','ineligible')),
   CHECK (("status"='earned' AND "amount"=1 AND "completedAt" IS NOT NULL AND "coachId">0) OR ("status"<>'earned' AND "amount"=0))
 );
+-- Upgrade the original auto-named constraint without rewriting immutable receipts.
+ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "ineligibleReason" text NOT NULL DEFAULT '';
+ALTER TABLE "ReviewReward" DROP CONSTRAINT IF EXISTS "ReviewReward_status_check";
+ALTER TABLE "ReviewReward" ADD CONSTRAINT "ReviewReward_status_check" CHECK (status IN ('earned','historical','exception','ineligible'));
+CREATE OR REPLACE FUNCTION xiaoshu_review_eligibility(planned text, finished timestamptz) RETURNS text
+LANGUAGE plpgsql IMMUTABLE AS $$
+DECLARE day date; actual date := (finished AT TIME ZONE 'Asia/Shanghai')::date;
+BEGIN
+ IF planned IS NULL OR planned !~ '^\d{4}-\d{2}-\d{2}$' OR finished IS NULL THEN RETURN 'invalid_date'; END IF;
+ BEGIN day := planned::date; EXCEPTION WHEN OTHERS THEN RETURN 'invalid_date'; END;
+ RETURN CASE WHEN day=actual THEN 'eligible' WHEN day<actual THEN 'overdue' ELSE 'future' END;
+END $$;
+-- Append-only correction evidence. Receipts and locked payroll documents remain intact.
+CREATE TABLE IF NOT EXISTS "ReviewRewardCorrection" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, "rewardId" text NOT NULL,
+ "amount" numeric(12,2) NOT NULL CHECK(amount=-1), reason text NOT NULL CHECK(length(trim(reason))>=2),
+ actor text NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(),
+ "sourceBatchId" text, "adjustmentBatchId" text, "adjustmentRequestId" text,
+ UNIQUE(company,"rewardId"), FOREIGN KEY("rewardId") REFERENCES "ReviewReward"("objectId")
+);
+CREATE OR REPLACE VIEW "ReviewRewardIncome" AS
+ SELECT projected.*,r.amount AS "originalAmount",c."objectId" AS "correctionId",c.reason AS "correctionReason",c.amount AS "correctionAmount",c."adjustmentBatchId"
+ FROM "ReviewReward" r LEFT JOIN "ReviewRewardCorrection" c ON c.company=r.company AND c."rewardId"=r."objectId"
+ CROSS JOIN LATERAL jsonb_populate_record(NULL::"ReviewReward",to_jsonb(r)||jsonb_build_object(
+ 'amount',r.amount+COALESCE(c.amount,0),'status',CASE WHEN c."objectId" IS NULL THEN r.status ELSE 'ineligible' END,
+ 'ineligibleReason',CASE WHEN c."objectId" IS NULL THEN r."ineligibleReason" ELSE 'corrected_overdue' END)) projected;
+CREATE OR REPLACE FUNCTION xs_review_correction_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP<>'INSERT' OR current_setting('xiaoshu.review_correction',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资纠正凭据只能由核对事务新增,不能改写或删除'; END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS review_correction_immutable ON "ReviewRewardCorrection";
+CREATE TRIGGER review_correction_immutable BEFORE INSERT OR UPDATE OR DELETE ON "ReviewRewardCorrection" FOR EACH ROW EXECUTE FUNCTION xs_review_correction_guard();
+REVOKE ALL ON "ReviewRewardCorrection" FROM PUBLIC;
 CREATE UNIQUE INDEX IF NOT EXISTS review_reward_once_per_plan ON "ReviewReward" ("company","taskKey") WHERE "status"='earned';
 CREATE INDEX IF NOT EXISTS review_reward_coach_month ON "ReviewReward" ("company","coachId","incomeMonth");
 CREATE UNIQUE INDEX IF NOT EXISTS payroll_company_month_once ON "PayrollBatch" ("company","month");
@@ -43,7 +77,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS memory_review_plan_once ON "MemoryPracticeReco
 
 CREATE OR REPLACE FUNCTION xiaoshu_record_review_reward(task "MemoryPracticeRecord", historical boolean) RETURNS void
 LANGUAGE plpgsql AS $$
-DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text;
+DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text; eligibility text;
 BEGIN
  IF task."company" IS NULL THEN RAISE EXCEPTION '复习任务缺少帐套'; END IF;
  IF EXISTS (SELECT 1 FROM "ReviewReward" WHERE "company"=task."company" AND "taskId"=task."objectId") THEN RETURN; END IF;
@@ -52,15 +86,18 @@ BEGIN
  SELECT * INTO store FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=coach."legacyUserData"->>'ParentUserID' LIMIT 1;
  SELECT "nodeName" INTO course FROM "Node" WHERE "company"=task."company" AND "nodeId"::text=task."kcid"::text LIMIT 1;
  BEGIN
-   finished := CASE WHEN historical THEN NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai' ELSE statement_timestamp() END;
+   -- The BEFORE trigger overwrites client time on every first live completion.
+   finished := NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai';
  EXCEPTION WHEN OTHERS THEN finished := NULL; END;
  IF task."rewardDuplicate" THEN problem := '来源复习任务重复';
  ELSIF task."rewardTaskKey" IS NULL OR NOT EXISTS (SELECT 1 FROM "CommonModel" WHERE "company"=task."company" AND "modelId"::text='56' AND "generalId"::text=task."xxjlid"::text) THEN problem := '缺少有效来源学习记录或计划日期';
  ELSIF coach."objectId" IS NULL OR COALESCE(task."plid"::text,'0') !~ '^[1-9][0-9]*$' THEN problem := '缺少有效复习老师';
  ELSIF student."objectId" IS NULL THEN problem := '缺少有效学员';
  ELSIF finished IS NULL THEN problem := '缺少有效完成时间'; END IF;
- reward_status := CASE WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' ELSE 'earned' END;
- INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception")
+ eligibility := xiaoshu_review_eligibility(left(task."kywrq"::text,10),finished);
+ IF problem='' AND eligibility='invalid_date' THEN problem := '缺少有效计划日期'; END IF;
+ reward_status := CASE WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' WHEN eligibility<>'eligible' THEN 'ineligible' ELSE 'earned' END;
+ INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception","ineligibleReason")
  VALUES (md5(task."company"||':review:'||task."objectId"),task."company",task."objectId",task."rewardTaskKey",
  CASE WHEN task."plid"::text ~ '^[1-9][0-9]*$' THEN task."plid"::double precision ELSE 0 END,
  COALESCE(NULLIF(coach."realName",''),NULLIF(coach."nickname",''),coach."legacyUserData"->>'HoneyName',coach."username",'未识别老师'),
@@ -68,7 +105,7 @@ BEGIN
  COALESCE(NULLIF(student."realName",''),NULLIF(student."nickname",''),student."legacyUserData"->>'HoneyName',student."username",'未识别学员'),COALESCE(course,''),
  CASE WHEN coach."legacyUserData"->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach."legacyUserData"->>'ParentUserID')::double precision ELSE 0 END,
  COALESCE(NULLIF(store."realName",''),NULLIF(store."nickname",''),store."legacyUserData"->>'HoneyName',store."username",'未绑定门店'),
- COALESCE(left(task."kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),CASE WHEN reward_status='earned' THEN 1 ELSE 0 END,reward_status,problem)
+ COALESCE(left(task."kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),CASE WHEN reward_status='earned' THEN 1 ELSE 0 END,reward_status,problem,CASE WHEN reward_status='ineligible' THEN eligibility ELSE '' END)
  ON CONFLICT ("company","taskId") DO NOTHING;
 END $$;
 -- Backfill creates zero-value, non-payable evidence; rerunning migration cannot award it.
@@ -145,7 +182,7 @@ BEGIN
      RETURN NEW;
    END IF;
    IF NEW."fxzt"::text='1' AND (current_setting('xiaoshu.review_completion',true)=NEW."company"||':'||NEW."objectId" OR NOT ((COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%') AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL)) THEN
-     NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
+     NEW."wcsj" := to_char(clock_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
    END IF;
  ELSE
    NEW."rewardTaskKey" := xiaoshu_review_key(NEW."yhid"::text,NEW."xxjlid"::text,left(NEW."kywrq"::text,10)); NEW."rewardDuplicate" := false;
@@ -167,11 +204,16 @@ CREATE TRIGGER review_reward_after_write AFTER INSERT OR UPDATE ON "MemoryPracti
 
 -- The compatibility endpoint calls this, so an unapplied migration fails closed.
 CREATE OR REPLACE FUNCTION xiaoshu_complete_review(tenant text, task_id text) RETURNS void LANGUAGE plpgsql AS $$
-DECLARE previous text:=COALESCE(current_setting('xiaoshu.review_completion',true),'');
+DECLARE previous text:=COALESCE(current_setting('xiaoshu.review_completion',true),''); task record; eligibility text;
 BEGIN
+ SELECT * INTO task FROM "MemoryPracticeRecord" WHERE company=tenant AND "objectId"=task_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
+ IF task.fxzt::text='1' THEN RETURN; END IF;
+ eligibility := xiaoshu_review_eligibility(left(task.kywrq::text,10),clock_timestamp());
+ IF eligibility='future' THEN RAISE EXCEPTION '复习任务未到期,不能提前完成'; END IF;
  -- Only this authenticated completion path earns a reward for an imported pending task.
  PERFORM set_config('xiaoshu.review_completion',tenant||':'||task_id,true);
- UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=statement_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
+ UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=clock_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
  IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
  PERFORM set_config('xiaoshu.review_completion',previous,true);
 EXCEPTION WHEN OTHERS THEN

+ 5 - 0
scripts/teacher-earnings-migration.mjs

@@ -0,0 +1,5 @@
+import {readFileSync} from 'node:fs';
+export function teacherEarningsMigration(){
+ const files=['review-rewards','lesson-workflow','lesson-records','payroll-integrity'];
+ return 'BEGIN;\n'+files.map(name=>readFileSync(new URL('./sql/'+name+'.sql',import.meta.url),'utf8').replace(/^BEGIN;\s*$/gm,'').replace(/^COMMIT;\s*$/gm,'')).join('\n')+'\nCOMMIT;';
+}

+ 9 - 0
scripts/teacher-earnings-transform.mjs

@@ -0,0 +1,9 @@
+import ts from 'typescript';
+// Copy pure calculator declarations from the authoritative operations bundle at build time.
+// Both gateways therefore execute the same historical rules, precedence and rounding.
+export function teacherEarningsSupport(operationsCode, integrityCode) {
+  const pick=(code,names)=>{const file=ts.createSourceFile('gateway.js',code,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);return names.map(name=>{const node=file.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text===name);if(!node)throw Error('缺少工资共享函数 '+name);return node.getText(file);}).join('\n');};
+  const start=operationsCode.indexOf('const TEACHING_STAGE_DEFAULTS='),end=operationsCode.indexOf('async function teachingCategoryRows(',start);
+  if(start<0||end<start)throw Error('缺少工资默认规则');
+  return '\nfunction teacherEarningsCalculator(){\nfunction text(v){return String(v==null?"":v).trim();}\nfunction number(v,f=0){const n=Number(v);return Number.isFinite(n)?n:f;}\n'+operationsCode.slice(start,end)+'\n'+pick(operationsCode,['classType','durationMinutes','durationHours','defaultPayRates','rateFor','teachingRuleFor','payrollLessonAt','payrollSourceProjection','dateIso','teachingRuleProjection','teachingStageFromDatabase','courseStageFromDatabase','categoryMatchesStage','isWritingCourse'])+'\n'+pick(integrityCode,['payrollDateIso','payrollBusinessDate','payrollIdentity','payrollCalculatedLine'])+'\nreturn {project:payrollSourceProjection,calculate:payrollCalculatedLine,defaultRules:defaultTeachingRules,defaultRates:defaultPayRates,rule: r=>teachingRuleProjection(r,TEACHING_CATEGORY_LABELS),category: (r)=>{const stage=courseStageFromDatabase(r.ancestor_names);return r.confirmed===true&&TEACHING_CATEGORY_LABELS.has(r.categoryKey)&&categoryMatchesStage(r.categoryKey,stage.key)&&(!isWritingCourse(r.courseName)||Boolean(stage.key&&stage.name));}};\n}\n';
+}

+ 9 - 0
scripts/tests/fixtures/teacher-review-schema.sql

@@ -0,0 +1,9 @@
+CREATE TABLE "MemoryPracticeRecord"("objectId" text,company text,id numeric,plid numeric,yhid numeric,xxjlid numeric,kcid numeric,fxzt text,kywrq text,kywsj text);
+  CREATE TABLE "CommonModel"(company text,"modelId" numeric,"generalId" numeric,"itemId" numeric);
+  CREATE TABLE "DailyStudyRecord"(company text,id numeric,learned numeric,xxqs text,dqrq text,"createdAt" timestamptz);
+  CREATE TABLE "Node"(company text,"nodeId" numeric,"nodeName" text);
+  CREATE TABLE "_User"(company text,"legacyUserId" numeric,"realName" text,nickname text,username text,"legacyUserData" jsonb);
+  CREATE TABLE "ReviewRewardIncome"(company text,"taskId" text,amount numeric,status text,"ineligibleReason" text,"completedAt" timestamptz,"originalAmount" numeric,"correctionAmount" numeric,"correctionReason" text);
+  INSERT INTO "MemoryPracticeRecord" SELECT 'r'||i,'co',i,30,20,100,1,CASE WHEN i=2 THEN '1' ELSE '0' END,CASE WHEN i<3 THEN '2026-10-14' WHEN i=3 THEN '2026-10-13' ELSE '2026-10-20' END,'2026-10-20 10:00:00' FROM generate_series(1,50) i;
+  INSERT INTO "MemoryPracticeRecord" VALUES ('other','co',100,31,21,101,1,'0','2026-10-14','2026-10-14');
+  INSERT INTO "CommonModel" SELECT company,60,id+1000,id FROM "MemoryPracticeRecord";

+ 2 - 2
scripts/tests/lesson-records.test.mjs

@@ -23,7 +23,7 @@ before(async()=>{
  for(let i=1;i<=55;i++){await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,kcid,kcmc,yyds,kclx,pldp,pjnr,plpjsj,"updatedAt") VALUES($1,'c',$2,'20','10','30','七年级单词',$3,'2','老师点评','学生评价',$4,now())`,['lesson'+i,i,String(1000+i-1),'2026-10-'+String(Math.ceil(i/3)).padStart(2,'0')+' 10:00:00']);}
  await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,yyds,plpjsj) VALUES('duplicate','c',56,'20','10','1000','2026-10-01 10:00:00')`);
  await query(`INSERT INTO "LessonCompletionReport"("objectId",company,"lessonId","appointmentGeneralId","contentSummary","coachComment") VALUES('report','c','lesson2',1001,'学习17词','新式完整点评')`);
- await task('pending1','2026-01-01');await task('pending2','2099-01-01');await task('done3','2026-01-03',{status:'1'});
+ await task('pending1','2026-01-01');await task('pending2',new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));await task('done3','2026-01-03',{status:'1'});
 });
 after(()=>db.close());
 test('历史查询按实际日期倒序、分页计数去重;姓名ID门店课程日期组合筛选',async()=>{
@@ -55,7 +55,7 @@ test('预览包含逾期与未来,已完成及工资排除;转交幂等、
 test('原老师旧页面不能提交,新老师完成按新归属计薪,重复完成无重复工资',async()=>{
  await assert.rejects(query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','old']),/复习已转交/);
  await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','new']);await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','new']);
- const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'pending1\'');assert.equal(r.coachId,11);assert.equal(Number(r.amount),1);
+ const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'pending1\'');assert.equal(r.coachId,11);assert.equal(Number(r.amount),0);assert.equal(r.status,'ineligible');
 });
 test('旧同步不能回退手工归属或代新老师完成;冲突留痕',async()=>{
  await query(`UPDATE "MemoryPracticeRecord" SET plid=10,fxzt='1',wcsj='2026-10-01' WHERE "objectId"='pending2'`);

+ 30 - 0
scripts/tests/lesson-workflow.test.mjs

@@ -227,3 +227,33 @@ test('修改报告只更新文字;重试幂等、旧版本和越权拒绝,
  const audit=await one('SELECT result FROM "LessonWorkflowCommand" WHERE "requestKey"=$1',['edit-feedback-request']);assert.equal(audit.result.previousFeedback.contentSummary,feedback.contentSummary);
  await db.query('UPDATE "CommonModel" SET status=-2 WHERE "objectId"=$1',[a.common]);await assert.rejects(edit(updated.updatedAt,'edit-cancel-request'),/已取消/);
 });
+
+test('未填反馈可结课并冻结课酬,稍后补交不重复扣课时或生成学习任务',async()=>{
+ const a=await appointment(),before=await balance();
+ const done=(await one('SELECT xs_lesson_complete($1,$2,$3,$4,$5,$6,NULL) result',['co',a.gid,'teacher',30,a.updatedAt,'feedback-free-001'])).result;
+ assert.equal(done.status,30);assert.equal(done.teacherPay,20);assert.equal(done.payStatus,'earned');assert.equal(done.feedbackStatus,'pending');assert.equal(await balance(),before-1);
+ const report=await one('SELECT * FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);assert.equal(report.feedbackStatus,'pending');assert.equal(report.submittedAt,null);assert.equal(report.contentSummary,null);
+ const retry=(await one('SELECT xs_lesson_complete($1,$2,$3,$4,$5,$6,NULL) result',['co',a.gid,'teacher',30,a.updatedAt,'feedback-free-002'])).result;assert.equal(retry.unchanged,true);
+ await review(a.gid,4,'先评价再补反馈');
+ const updated=await one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,report.updatedAt,'feedback-later-001',JSON.stringify(feedback)]);
+ assert.equal(updated.result.saved,true);assert.equal(await balance(),before-1);
+ const after=await one('SELECT * FROM "LessonCompletionReport" WHERE "objectId"=$1',[report.objectId]);assert.equal(after.feedbackStatus,'submitted');assert.ok(after.submittedAt);assert.equal(after.contentSummary,feedback.contentSummary);
+ assert.equal((await one('SELECT count(*)::int n FROM "LessonRecord" WHERE yyds=$1',[a.gid])).n,1);
+});
+
+test('组合迁移在既有安装上一笔事务执行,可重跑且保留完课和反馈记录',async()=>{
+ const {teacherEarningsMigration}=await import('../teacher-earnings-migration.mjs');
+ const vm=await import('node:vm');
+ const src=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const schemas=vm.runInNewContext('('+src.slice(src.indexOf('async function ensurePayrollSchemas()')).match(/const schemas = (\{[\s\S]*?\n  });/)[1]+')');
+ for(const [name,fields] of Object.entries(schemas)){
+  await db.exec(`CREATE TABLE IF NOT EXISTS "${name}" ("objectId" text PRIMARY KEY,"createdAt" timestamptz,"updatedAt" timestamptz)`);
+  for(const [key,v] of Object.entries({objectId:{type:'String'},createdAt:{type:'Date'},updatedAt:{type:'Date'},...fields}))await db.exec(`ALTER TABLE "${name}" ADD COLUMN IF NOT EXISTS "${key}" ${{Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'}[v.type]||'text'}`);
+ }
+ for(const [key,type] of Object.entries({realName:'text',nickname:'text',username:'text',isDeleted:'boolean'}))await db.exec(`ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "${key}" ${type}`);
+ await db.exec('ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS wcsj text');
+ const before=await one('SELECT count(*)::int AS n FROM "LessonCompletionReport"');
+ await db.exec(teacherEarningsMigration());await db.exec(teacherEarningsMigration());
+ assert.deepEqual(await one('SELECT count(*)::int AS n FROM "LessonCompletionReport"'),before);
+ assert.ok((await one("SELECT to_regprocedure('xs_review_complete_result(text,text,text)') AS f")).f);
+});

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

@@ -94,3 +94,42 @@ test('学段核对页预填一致证据,旧档案空值回退有效,冲突
   assert.equal(row.canAutoConfirm,false);assert.equal(row.categoryKey,'');assert.match(row.assessmentReason,/冲突/);
  }finally{context.businessDate=previous;}
 });
+
+test('历史逾期错计:未结算、草稿、已审核分别纠正且收入与结算只扣一次',async()=>{
+ await db.exec(`ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "adminRoleKey" text;UPDATE "_User" SET "adminRoleKey"='ops-manager' WHERE "objectId"='teacher';`);
+ const make=async(id)=>{await q(`INSERT INTO "ReviewReward"("objectId",company,"taskId","taskKey","coachId","studentId","plannedDate","completedAt","incomeMonth",amount,status) VALUES($1,'co',$1,$1,30,20,'2026-10-13','2026-10-14T10:00:00+08:00','2026-10',1,'earned')`,[id]);return(await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[id]));};
+ const correct=async(id,batch='',v=null,actor='teacher')=>(await one('SELECT xs_review_reward_correct($1,$2,$3,$4,$5,$6) result',['co',actor,id,'已核对计划和完成日期',batch,v])).result;
+ await make('wrong-unpaid');const versionBefore=await version();await assert.rejects(correct('wrong-unpaid','',null,'student'),/仅管理/);
+ const original=await one(`SELECT * FROM "ReviewReward" WHERE "objectId"='wrong-unpaid'`);await correct('wrong-unpaid');assert.equal((await correct('wrong-unpaid')).unchanged,true);
+ assert.deepEqual(await one(`SELECT * FROM "ReviewReward" WHERE "objectId"='wrong-unpaid'`),original);assert.notEqual(await version(),versionBefore);
+ assert.equal(Number((await one(`SELECT amount FROM "ReviewRewardIncome" WHERE "objectId"='wrong-unpaid'`)).amount),0);
+ const pending=await make('wrong-draft');let draft=await command('create','',{month:'2090-01',lines:[context.rewardPayrollLine(pending,'2090-01')],sourceVersion:await version()},'wrong-draft-create');
+ await correct('wrong-draft');assert.equal((await one('SELECT "payableAmount" FROM "PayrollBatch" WHERE "objectId"=$1',[draft.objectId])).payableAmount,0);
+ await assert.rejects(command('refresh',draft.objectId,{version:(await one('SELECT version FROM "PayrollBatch" WHERE "objectId"=$1',[draft.objectId])).version,lines:[context.rewardPayrollLine(pending,'2090-01')],sourceVersion:await version()},'reintroduce-wrong'),/已纠正/);
+ const lockedReward=await make('wrong-locked');let locked=await command('create','',{month:'2090-02',lines:[context.rewardPayrollLine(lockedReward,'2090-02')],sourceVersion:await version()},'wrong-locked-create');
+ locked=await command('review',locked.objectId,{version:locked.version,lines:[context.rewardPayrollLine(lockedReward,'2090-02')],sourceVersion:await version()},'wrong-locked-review');
+ const lockedBefore=await one('SELECT * FROM "PayrollBatch" WHERE "objectId"=$1',[locked.objectId]);
+ await assert.rejects(correct('wrong-locked'),/草稿工资单/);assert.equal((await one(`SELECT count(*)::int n FROM "ReviewRewardCorrection" WHERE "rewardId"='wrong-locked'`)).n,0);
+ const target=await command('create','',{month:'2090-03',lines:[],sourceVersion:await version()},'correction-target-create');
+ await assert.rejects(correct('wrong-locked',target.objectId,target.version+1),/版本/);
+ await correct('wrong-locked',target.objectId,target.version);await correct('wrong-locked',target.objectId,target.version);
+ assert.deepEqual(await one('SELECT * FROM "PayrollBatch" WHERE "objectId"=$1',[locked.objectId]),lockedBefore);
+ assert.equal((await one('SELECT "payableAmount" FROM "PayrollBatch" WHERE "objectId"=$1',[target.objectId])).payableAmount,-1);
+ assert.equal((await one('SELECT count(*)::int n FROM "PayrollLine" WHERE "batchId"=$1',[target.objectId])).n,1);
+ await assert.rejects(q('DELETE FROM "PayrollLine" WHERE "batchId"=$1',[target.objectId]),/业务事务|不可改写/);
+ await assert.rejects(q('DELETE FROM "ReviewRewardCorrection" WHERE "rewardId"=$1',['wrong-locked']),/不能改写/);
+ const page=await context.reviewRewardPage('co',{coachId:30,pageSize:100});assert.equal(Number(page.items.find(r=>r.objectId==='wrong-locked').amount),0);assert.equal(page.items.find(r=>r.objectId==='wrong-locked').originalAmount,'1.00');
+});
+
+test('移动端真实数据库查询与后台同源:课酬合计、历史规则及已纠正收入一致',async()=>{
+ const {teacherEarningsSupport}=await import('../teacher-earnings-transform.mjs');
+ const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],integrity=readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8');
+ const mobile=vm.createContext({Date,Promise,Psql:{query:q,one,oneOrNone:one},DEFAULT_COMPANY_ID:'co'});
+ vm.runInContext(teacherEarningsSupport(ops,integrity)+readFileSync(new URL('../cloud/teacher-earnings.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),mobile);
+ const summary=await mobile.teacherIncomeSummary('co',30);assert.ok(Number.isFinite(summary.lessonIncome));assert.equal(summary.reviewIncome,await context.reviewIncomeTotal('co',30));assert.equal(summary.totalIncome,summary.lessonIncome+summary.reviewIncome);
+ await db.exec(`INSERT INTO "LessonCompletionReport" ("objectId",company,"appointmentGeneralId","coachId","feedbackStatus") VALUES('report5','co',5,30,'pending')`);
+ const history=await mobile.teacherSchedulePay([{generalId:5,dszt:30},{generalId:999,dszt:30}],30);
+ assert.equal(history[0].teacherPay,40);assert.equal(history[0].payStatus,'earned');assert.equal(history[0].feedbackStatus,'pending');assert.equal(history[1].teacherPay,null);assert.equal(history[1].payStatus,'pending');
+ const lines=await mobile.teacherLessonEarnings('co',30);const [rates,rules]=await Promise.all([context.payRateRows(manager,{}),context.teachingRuleRows(manager,{})]);
+ for(const line of lines.lines){const expected=context.payrollCalculatedLine(line,rates,'',rules,new Map());assert.equal(expected.amount,line.amount);assert.equal(expected.exception,line.exception);}
+});

+ 25 - 5
scripts/tests/review-rewards.test.mjs

@@ -7,6 +7,7 @@ const db = new PGlite();
 const migration = readFileSync(new URL('../sql/review-rewards.sql', import.meta.url),'utf8');
 const query = async (sql, params=[]) => (await db.query(sql,params)).rows;
 const one = async (sql,params=[]) => (await query(sql,params))[0];
+const today=()=>new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
 const complete = (id) => query('SELECT xiaoshu_complete_review($1,$2)',['c',id]);
 async function task(id, day, extra={}) {
  const {coach='10',status='0',study='100',student='20',sourceKey=''}=extra;
@@ -32,7 +33,7 @@ test('历史完成零金额,重复完成和重复迁移不补发', async()=>{
  assert.equal((await one('SELECT "wcsj" FROM "MemoryPracticeRecord" WHERE "objectId"=\'history\'')).wcsj,'2026-08-01 10:00:00');
 });
 test('首次完成一元,快照老师和北京时间月份;并发重试不改变完成时间',async()=>{
- await task('first','2026-09-01');await Promise.all([complete('first'),complete('first'),complete('first')]);
+ await task('first',today());await Promise.all([complete('first'),complete('first'),complete('first')]);
  const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');assert.equal(Number(r.amount),1);assert.equal(r.coachId,10);assert.equal(r.incomeMonth,new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}).slice(0,7));
  await query('UPDATE "MemoryPracticeRecord" SET "fxzt"=\'0\',"wcsj"=\'2099-01-01\',"plid"=\'999\' WHERE "objectId"=\'first\'');
  await complete('first');const again=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'first\'');assert.deepEqual(again,r);
@@ -40,8 +41,8 @@ test('首次完成一元,快照老师和北京时间月份;并发重试不
  await assert.rejects(query('UPDATE "ReviewReward" SET "amount"=2 WHERE "taskId"=\'first\''),/不可改写/);
 });
 test('独立计划分别奖励;新任务生成重复被数据库拒绝',async()=>{
- await task('next','2026-09-03');await complete('next');
- await assert.rejects(task('next-copy','2026-09-03'),/unique/);
+ await query("INSERT INTO \"CommonModel\" VALUES ('study-next','c',56,101)");await task('next',today(),{study:'101'});await complete('next');
+ await assert.rejects(task('next-copy',today(),{study:'101'}),/unique/);
  assert.equal(Number((await one('SELECT SUM("amount") amount FROM "ReviewReward" WHERE "status"=\'earned\'')).amount),2);
 });
 test('历史重复来源和缺失老师、来源的记录不计薪',async()=>{
@@ -58,7 +59,7 @@ test('旧系统同步完成状态保留原完成时间且不自动计薪',async(
  assert.equal(reward.status,'historical');assert.equal(Number(reward.amount),0);assert.equal(record.wcsj,'2026-09-09 20:30:00');
 });
 test('奖励插入失败时完成状态一起回滚',async()=>{
- await task('rollback','2026-09-07');
+ await query("INSERT INTO \"CommonModel\" VALUES ('study-rollback','c',56,102)");await task('rollback',today(),{study:'102'});
  await db.exec(`CREATE FUNCTION test_fail_reward() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW."taskId"='rollback' THEN RAISE EXCEPTION 'injected failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_fail_reward BEFORE INSERT ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION test_fail_reward();`);
  await assert.rejects(complete('rollback'),/injected failure/);assert.equal((await one('SELECT "fxzt" FROM "MemoryPracticeRecord" WHERE "objectId"=\'rollback\'')).fxzt,'0');
  await db.exec('DROP TRIGGER test_fail_reward ON "ReviewReward"');await complete('rollback');
@@ -130,7 +131,7 @@ test('实际工资草稿刷新路径反复调用仍只有两条待结算奖励
 
 test('导入的待完成任务经新系统首次完成计薪一元,重试幂等且完成上下文不泄漏',async()=>{
  for(const [id,sourceKey,day] of [['import-pending','legacy:model:60:general:801','2026-10-01'],['bridge-pending','legacy-sync:memory-records:addon:802','2026-10-02']]){
-  await task(id,day,{sourceKey});await complete(id);await complete(id);
+  const study=id==='import-pending'?'801':'802';await query('INSERT INTO \"CommonModel\" VALUES ($1,\'c\',56,$2)',[id+'-study',Number(study)]);await task(id,today(),{sourceKey,study});await complete(id);await complete(id);
   const reward=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[id]);
   assert.equal(reward.status,'earned');assert.equal(Number(reward.amount),1);
   assert.equal(reward.incomeMonth,new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}).slice(0,7));
@@ -141,3 +142,22 @@ test('导入的待完成任务经新系统首次完成计薪一元,重试幂
  await query(`UPDATE "MemoryPracticeRecord" SET fxzt='1',wcsj='2026-10-03 12:00:00' WHERE "objectId"='later-import'`);
  assert.equal((await one(`SELECT status FROM "ReviewReward" WHERE "taskId"='later-import'`)).status,'historical');
 });
+
+test('北京时间自然日边界:跨月、午夜及无效计划日期',async()=>{
+ const eligibility=async(day,time)=>(await one('SELECT xiaoshu_review_eligibility($1,$2::timestamptz) value',[day,time])).value;
+ assert.equal(await eligibility('2026-10-14','2026-10-13T15:59:59.999Z'),'future');
+ assert.equal(await eligibility('2026-10-14','2026-10-13T16:00:00Z'),'eligible');
+ assert.equal(await eligibility('2026-10-14','2026-10-14T15:59:59.999Z'),'eligible');
+ assert.equal(await eligibility('2026-10-14','2026-10-14T16:00:00Z'),'overdue');
+ assert.equal(await eligibility('2026-09-30','2026-09-30T16:00:00Z'),'overdue');
+ assert.equal(await eligibility('2026-02-30','2026-03-01T00:00:00Z'),'invalid_date');
+});
+test('逾期补做零元、伪造手机完成时间无效、未来完成拒绝且不留凭据',async()=>{
+ await task('late-new','2001-01-01');await complete('late-new');await complete('late-new');
+ const late=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',['late-new']);assert.equal(late.status,'ineligible');assert.equal(Number(late.amount),0);assert.equal(late.ineligibleReason,'overdue');
+ const taskRow=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=$1',['late-new']);assert.equal(taskRow.fxzt,'1');assert.notEqual(taskRow.wcsj,'2026-08-01 10:00:00');
+ await task('future-new','2099-12-31');await assert.rejects(complete('future-new'),/未到期/);
+ assert.equal((await one('SELECT fxzt FROM "MemoryPracticeRecord" WHERE "objectId"=$1',['future-new'])).fxzt,'0');
+ assert.equal((await one('SELECT count(*)::int n FROM "ReviewReward" WHERE "taskId"=$1',['future-new'])).n,0);
+ await assert.rejects(query('UPDATE "MemoryPracticeRecord" SET kywrq=$1 WHERE "objectId"=$2',[today(),'late-new']),/不能修改/);
+});

+ 33 - 0
scripts/tests/teacher-earnings.test.mjs

@@ -0,0 +1,33 @@
+import {test} 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';
+import {teacherEarningsSupport} from '../teacher-earnings-transform.mjs';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const integrity=readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8');
+const mobile=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
+const support=teacherEarningsSupport(ops,integrity);
+test('老师工资复用后台计算器:快照、合法零元、规则版本和缺失分类结果一致',()=>{
+ const admin=vm.createContext({Date});vm.runInContext(ops+integrity,admin);
+ const app=vm.createContext({Date});vm.runInContext(support,app);const c=app.teacherEarningsCalculator();
+ const base={objectId:'l1',coachId:30,lessonAt:'2026-10-14T10:00:00+08:00',courseCategoryKey:'word',courseId:1,classType:2,durationMinutes:60,deliveryMode:'online'};
+ for(const row of [base,{...base,teacherPaySnapshot:45},{...base,teacherPaySnapshot:0},{...base,courseCategoryKey:''},{...base,teacherPaySnapshot:-1},{...base,lessonAt:'2026-08-15',teacherPaySnapshot:null}]){
+  const a=admin.payrollCalculatedLine(row,c.defaultRates(),'',c.defaultRules(),new Map()),b=c.calculate(row,c.defaultRates(),'',c.defaultRules(),new Map());
+  assert.equal(a.amount,b.amount);assert.equal(a.exception,b.exception);assert.equal(a.rateAmount,b.rateAmount);
+ }
+ assert.equal(c.calculate({...base,teacherPaySnapshot:45},[],'',[],new Map()).amount,45);
+ assert.equal(c.calculate({...base,teacherPaySnapshot:0},[],'',[],new Map()).amount,0);
+});
+test('今日查询在分页前筛选,超过20条未来任务仍不遮挡今日;统计与列表一致且精简响应',async()=>{
+ const db=new PGlite();try{
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  const calls=[];const query=async(sql,args)=>{calls.push(sql);return(await db.query(sql,args)).rows;};
+  const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),reviewIncomeTotal:async()=>{throw Error('首页不应读取收入');}});
+  ctx.Psql={query,one:async(s,a)=>(await query(s,a))[0]};vm.runInContext(mobile,ctx);
+  const current={get:()=>3};const today=await ctx.appReviewPage({scope:'today',includeMetadata:false,includeIncome:false,pageSize:4},current);
+  assert.equal(today.items.length,2);assert.equal(today.page.itemCount,2);assert.equal(Number(today.items[0].id),1);assert.equal(today.summary.todayTotal,2);assert.equal(today.summary.todayCompleted,1);assert.equal(today.summary.todayPending,1);assert.equal(today.summary.overduePending,1);assert.equal(today.students.length,0);assert.equal(today.dates.length,0);assert.equal(calls.length,3);
+  const overdue=await ctx.appReviewPage({scope:'overdue',includeMetadata:false,includeIncome:false},current);assert.equal(overdue.items.length,1);assert.equal(overdue.items[0].kywrq,'2026-10-13');
+ }finally{await db.close();}
+});

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