Kaynağa Gözat

Confirm historical review pay with verified legacy coach evidence

彭峰 4 saat önce
ebeveyn
işleme
b87eea5d7b

+ 23 - 15
docs/operations/review-reconciliation-2026-10-20.md

@@ -1,28 +1,36 @@
 # 2026-10-20 历史抗遗忘工资核定
 
-用户要求尽量核定可确定的历史记录,沿用 30 分钟 0.5 元、60 分钟 1 元,学生自习和逾期补做不计薪,同一复习不重复计薪的规则。
+用户明确确认历史口径:来源、课时时长和计划当天完成日期核实后,按绑定老师计薪;明确学生自习的剔除。历史未保存实际完成账号,核定证据如实记录这一点,不伪造实际操作者。今后新系统复习仍按实际登录账号区分老师操作与学生自习。
 
-## 已执行
+执行范围为 2026-09-01 起的历史待核对凭据,以及月份缺失的异常凭据。9 月 1 日前不自动补发的既有规则未变。30 分钟课计 0.5 元,60 分钟课计 1 元;逾期、学生自习和重复副本不计薪。本次只核定收入,没有执行工资发放。
 
-核查 2026-09-01 起的待核对凭据,以及月份缺失的异常凭据。发现 5,372 条待核对:9 月历史 4,510 条、重复异常 9 条;10 月历史 783 条、重复异常 69 条;日期和来源缺失 1 条。
+## 生产结果
 
-- 9 条历史凭据的完成日期晚于计划日期,确定不计薪。
-- 78 条重复异常涉及 48 组来源相同的任务,师生归属一致。优先保留已经计薪的凭据,其次已有历史凭据,最后保留最早完成的一份;65 条重复副本确定不重复计薪。另 13 组只有重复副本完成,保留一份待核定,不能把唯一实际完成记录也排除。
-- 合计 74 条已在生产核定为不计薪,原始 6,663 条范围内凭据、现有 PayrollBatch 和 PayrollLine 回读完全一致。
-- 10 月页面:有效计薪 1,286 条、¥1,196.50;历史待核对 783 条;异常待核对从 70 降至 14(13 份保留的完成凭据和 1 条日期来源缺失记录)。
+| 月份 | 本次新增计薪 | 本次新增金额 | 核定后有效计薪 | 核定后工资合计 |
+| --- | ---: | ---: | ---: | ---: |
+| 2026-09 | 4,501 条 | ¥4,107.50 | 4,504 条 | ¥4,110.50 |
+| 2026-10 | 796 条 | ¥733.00 | 2,082 条 | ¥1,929.50 |
+| 合计 | 5,297 条 | ¥4,840.50 | 6,586 条 | ¥6,040.00 |
 
-## 仍需明确的历史身份口径
+此前已核定 74 条不计薪:65 条重复副本、9 条逾期补做。重复记录优先保留已计薪凭据,其次已有历史凭据,最后保留最早完成的一份。13 组只有重复副本实际完成,保留其中一份并在本次按历史口径计薪,避免全部排除后漏计。
 
-旧系统完成操作只更新完成状态和时间,未保存实际完成账号;录入人是创建者,不能当作完成者。5,284 条按时完成历史记录和 13 份保留的重复组完成记录不能凭绑定老师直接断言“老师账号操作”。用户先前要求“身份不明的保留待核对”。已询问是否对这批历史记录采用“来源、日期、时长核实后按绑定老师计薪,有明确学生自习证据则剔除”的历史核算口径,尚未收到答复,因此本次没有新增这部分工资。
+9 月起的历史待核对已全部清零。10 月页面由历史待核对 783 条、异常待核对 70 条,降为历史待核对 0 条、异常待核对 1 条。唯一保留项是日期和来源均缺失的原始凭据,无法计算。
 
-4 节本地缺失的来源课已通过旧系统 `content_get` 核实,关联的 20 条复习可确定课时时长。完整来源快照仅保存在受限的本地审计目录。假如历史身份口径获得确认,候选共 5,297 条,另查出其中 76 条涉及系统标记为身份冲突的老师账号,须单独解决身份归属;其余 5,221 条按来源时长试算为 ¥4,764.50,仍须执行服务端逐条验证。以上金额均尚未入账。
+## 补齐的核查证据
 
-## 实现及验证
+- 4 节本地缺失来源课,通过旧系统 `content_get` 查到原课次,20 条复习的课时时长因此可以确定;保存原始课次快照,不猜测时长。
+- 5,221 条先通过逐批事务回滚试算,再正式计薪 ¥4,764.50。
+- 另 76 条属于史远见陪练(旧账号 1535)。账号被分类为 `conflict`,核查发现只有 18 条单词练习记录触发学员用途;不存在学员排课、DailyStudyRecord 或课程绑定。旧系统实时 `user_info`、当前账号和历史原始账号的 ID、用户名及老师分组 3 一致;账号唯一,未查到对应待处理身份冲突事项。因此按已核实老师身份追加计薪 76 条、¥76.00。
+- 对上述仅单词练习导致的分类冲突,历史核定函数要求管理员提交匹配的旧系统身份快照,并再次检查原始账号、老师分组和不存在学员业务关联。缺少证据、姓名不一致或实际存在学员关联均拒绝。保留账号原有分类及登录规则,不将核定依据冒充为实际完成账号。
 
-`ReviewRewardResolution` 追加核定依据,不改写原始凭据、历史完成账号或已存在工资单。`ReviewRewardIncome` 投影核定后的状态和金额。核定事务要求管理身份、明确凭据列表及原因,对来源、师生、完成日期、重复关系和已有结算进行复核。历史绑定老师口径必须显式启用,并记录实际完成身份未留存,不伪造老师操作证据。
+## 实现及验证
 
-重复副本必须指向同一师生、同一来源学习记录、同一计划日期的保留凭据。已经核定计薪的任务即使原始重复任务再次完成,也不能重复记工资。
+`ReviewRewardResolution` 追加核定依据,`ReviewRewardIncome` 投影核定后的状态和金额。原始凭据、历史完成账号、已存在工资单不改写。明确凭据列表、管理身份、来源、日期、时长、重复关系及未结算状态均由服务端复核,重复执行不重复入账。
 
-数据库规则 10 项、回归 47 项、移动端 43 项、管理端 6 项测试通过,双端生产构建及 11 个云函数定义校验通过;双端显示分别区分学生自习、逾期和重复不计薪。生产迁移和批处理前检查同步租约空闲,保存快照,事务执行,随后比对原始凭据和工资单。三个网关仅选择性替换 `reviewRewardPage`,保留线上其余差异并逐个回读代码哈希。
+- 执行前保存快照并确认同步租约空闲;所有批次都先进行事务回滚试算,正式写入时重新验证并回读。
+- 第一批 5,221 条、第二批 76 条均正式写入成功。6,663 条范围内原始凭据与执行前逐条一致,已有 PayrollBatch、PayrollLine 完全一致;核定后的计薪键唯一。
+- 最新历史核定规则 11 项测试通过,包括缺少或不符身份证据时拒绝、有真实学员关联时拒绝、保留原账号分类、重复防护和课时时长规则。此前回归 47 项、移动端 43 项、管理端 6 项通过。
+- 双端已区分学生自习、逾期和重复不计薪;前一发布 `40b9e39833db74d3b12f0e99595c56d1709cb066` 公网校验移动端 106 文件/6 路由、管理端 95 文件/4 路由,CDN 7933812152、7933812572 均完成。本次追加核定只更新数据库数据及单个核定函数,无前端改动。
+- 线上 `xs_review_resolve` 更新前逐字比对已备份函数,选择性更新后回读验证。管理端 API 回读确认 10 月有效计薪 2,082 条、¥1,929.50、历史待核对 0 条、异常待核对 1 条。
 
-私有审计目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-20-review-reconciliation/`。内含执行前后快照、74 条核定清单、旧课次来源证据和未执行的历史核定候选清单;不提交账号数据和生产明细。
+私有审计目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-20-review-reconciliation/history-approved/`。内含执行前后快照、分批试算和执行回执、身份核查快照、函数发布证据及 API 验证。账号和生产明细不提交 Git。

+ 22 - 2
scripts/sql/review-reconciliation.sql

@@ -34,7 +34,7 @@ LEFT JOIN "ReviewRewardRateCorrection" rc ON rc.company=r.company AND rc."reward
 LEFT JOIN "ReviewRewardResolution" d ON d.company=r.company AND d."rewardId"=r."objectId";
 
 CREATE OR REPLACE FUNCTION xs_review_resolve(co text, actor text, items jsonb, reason text, historical_assignment boolean DEFAULT false) RETURNS jsonb LANGUAGE plpgsql AS $$
-DECLARE item jsonb; r "ReviewReward"; m "MemoryPracticeRecord"; canonical record; minutes integer; k text; outcome text; code text; snapshot jsonb; source jsonb; n integer:=0; existing integer:=0; prior text:=COALESCE(current_setting('xiaoshu.review_resolution',true),'');
+DECLARE item jsonb; r "ReviewReward"; m "MemoryPracticeRecord"; canonical record; minutes integer; k text; outcome text; code text; snapshot jsonb; source jsonb; coach_data jsonb; coach_profile jsonb; n integer:=0; existing integer:=0; prior text:=COALESCE(current_setting('xiaoshu.review_resolution',true),'');
 BEGIN
  PERFORM xs_review_manager(co,actor);
  IF length(trim(COALESCE(reason,'')))<2 OR jsonb_typeof(items) IS DISTINCT FROM 'array' OR jsonb_array_length(items) NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION '需要明确核定依据和1至100条凭据'; END IF;
@@ -62,7 +62,27 @@ BEGIN
    IF NOT historical_assignment THEN RAISE EXCEPTION '历史绑定老师核算口径未确认'; END IF;
    IF COALESCE(r."completionRole",'') IN ('student','unknown','coach') OR NULLIF(r."completedBy",'') IS NOT NULL OR r."ineligibleReason"='self_study' THEN RAISE EXCEPTION '已有实际身份凭据不能按历史口径覆盖'; END IF;
    IF COALESCE(m."sourceKey",'') NOT LIKE 'legacy:model:60:%' OR r."incomeMonth"<'2026-09' OR r."completedAt">now() OR xiaoshu_review_eligibility(r."plannedDate",r."completedAt")<>'eligible' OR r.exception NOT IN ('','来源复习任务重复') THEN RAISE EXCEPTION '历史来源或计划当天完成条件不满足'; END IF;
-   IF NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."coachId" AND COALESCE(to_jsonb(u)->>'identityType','coach')='coach' AND COALESCE(to_jsonb(u)->>'legacyGroupId',u."legacyUserData"->>'GroupID')='3') OR NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."studentId") OR r."coachId"=r."studentId" THEN RAISE EXCEPTION '历史师生身份无效'; END IF;
+   IF (SELECT count(*) FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."coachId")<>1 OR NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."studentId") OR r."coachId"=r."studentId" THEN RAISE EXCEPTION '历史师生身份无效'; END IF;
+   SELECT to_jsonb(u) INTO coach_data FROM "_User" u WHERE u.company=co AND u."legacyUserId"=r."coachId";
+   IF COALESCE(coach_data->>'legacyGroupId',coach_data->'legacyUserData'->>'GroupID') IS DISTINCT FROM '3' THEN RAISE EXCEPTION '历史老师分组无效'; END IF;
+   IF COALESCE(coach_data->>'identityType','coach')<>'coach' THEN
+    -- Word-practice use alone can flag an otherwise verified legacy coach as conflict.
+    -- This narrow historical exception preserves the account's classification and login rules.
+    coach_profile:=item->'legacyCoachProfile';
+    IF coach_data->>'identityType' IS DISTINCT FROM 'conflict' OR coach_profile IS NULL
+     OR coach_profile->>'action' IS DISTINCT FROM 'user_info' OR coach_profile->>'userId' IS DISTINCT FROM r."coachId"::text
+     OR coach_profile->>'groupId' IS DISTINCT FROM '3' OR NULLIF(coach_profile->>'userName','') IS NULL
+     OR coach_profile->>'userName' IS DISTINCT FROM coach_data->>'username'
+     OR coach_profile->>'userName' IS DISTINCT FROM coach_data->'legacyUserData'->>'UserName'
+     OR coach_data->'legacyUserData'->>'UserID' IS DISTINCT FROM r."coachId"::text
+     OR coach_data->'legacyUserData'->>'GroupID' IS DISTINCT FROM '3'
+     OR EXISTS(SELECT 1 FROM "CourseAppointment" a WHERE a.company=co AND a.szyh::text=r."coachId"::text)
+     OR EXISTS(SELECT 1 FROM "DailyStudyRecord" d WHERE d.company=co AND d."userId"=r."coachId")
+     OR EXISTS(SELECT 1 FROM "CourseBinding" b WHERE b.company=co AND b.yhid::text=r."coachId"::text)
+     OR NOT EXISTS(SELECT 1 FROM "PracticeRecord" p WHERE p.company=co AND p.yhid::text=r."coachId"::text)
+     THEN RAISE EXCEPTION '历史师生身份冲突仍未核清'; END IF;
+    snapshot:=snapshot||jsonb_build_object('legacyCoachProfile',coach_profile,'identityReview','legacy_coach_with_word_practice_only');
+   END IF;
    -- Existing earned receipts win; no second payout even if a duplicate has a null taskKey.
    IF EXISTS(SELECT 1 FROM "ReviewRewardIncome" v JOIN "MemoryPracticeRecord" t ON t.company=v.company AND t."objectId"=v."taskId" WHERE v.company=co AND v.status='earned' AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10)) THEN RAISE EXCEPTION '同一复习已经计薪'; END IF;
    IF EXISTS(SELECT 1 FROM "MemoryPracticeRecord" t WHERE t.company=co AND t.yhid=m.yhid AND t.xxjlid=m.xxjlid AND left(t.kywrq::text,10)=left(m.kywrq::text,10) AND t.plid<>m.plid) THEN RAISE EXCEPTION '重复任务的老师归属冲突'; END IF;

+ 17 - 0
scripts/tests/review-pay-policy.test.mjs

@@ -160,3 +160,20 @@ test('缺失来源不猜时长;旧课次证据必须匹配学生和学习记
  await confirm({modelId:54,generalId:m.orderId,szyh:20,dslx:'1'});assert.equal(Number((await receipt('source-evidence')).amount),.5);
  await db.exec(sql('review-reconciliation'));assert.equal(Number((await receipt('source-evidence')).amount),.5);
 });
+test('历史老师的单词练习误标冲突须原系统身份佐证,真实学员关联仍阻止核定',async()=>{
+ await task('verified-conflict',{minutes:60,day:'2026-09-05',source:'legacy:model:60:general:1006',done:true});
+ await q(`UPDATE "_User" SET "identityType"='conflict',username='原老师',"legacyUserData"='{"UserID":10,"UserName":"原老师","GroupID":3}' WHERE "objectId"='coach'`);
+ await db.exec('CREATE TABLE IF NOT EXISTS "PracticeRecord"("objectId" text PRIMARY KEY,company text,yhid numeric)');
+ await q(`INSERT INTO "PracticeRecord"("objectId",company,yhid) VALUES('coach-word-practice','c',10)`);
+ const r=await receipt('verified-conflict'),profile={userId:10,userName:'原老师',groupId:3,action:'user_info'};
+ const confirm=proof=>q("SELECT xs_review_resolve('c','manager',$1::jsonb,'核对旧系统老师身份及单词练习误标',true)",[JSON.stringify([{rewardId:r.objectId,reasonCode:'legacy_assignment_policy',legacyCoachProfile:proof}])]);
+ await assert.rejects(confirm(null),/身份冲突仍未核清/);
+ await assert.rejects(confirm({...profile,userName:'另一人'}),/身份冲突仍未核清/);
+ await q(`INSERT INTO "CourseBinding"("objectId",company,id,yhid,kcid) VALUES('actual-enrollment','c',99999,'10','30')`);
+ await assert.rejects(confirm(profile),/身份冲突仍未核清/);
+ await q(`DELETE FROM "CourseBinding" WHERE "objectId"='actual-enrollment'`);
+ await confirm(profile);assert.equal(Number((await receipt('verified-conflict')).amount),1);
+ assert.equal((await one(`SELECT "identityType" FROM "_User" WHERE "objectId"='coach'`)).identityType,'conflict');
+ const evidence=await one('SELECT evidence FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[r.objectId]);assert.equal(evidence.evidence.identityReview,'legacy_coach_with_word_practice_only');
+ assert.equal(evidence.evidence.completionIdentity,'not_recorded');
+});