Преглед изворни кода

fix(payroll): infer writing stages from student evidence

彭峰 пре 1 недеља
родитељ
комит
9115d70aee

+ 32 - 0
docs/migration/writing-stage-confirmation-2026-10-12.md

@@ -0,0 +1,32 @@
+# 2026-10-12 按学员确认语法写作学段
+
+用户要求从系统中判断并确认语法写作课的实际学段,仅将无法判断的学员交给人工确认。
+
+## 判断与确认结果
+
+核查了本学年未确认的 267 条预约,涉及 26 名学员。范围包括待开始预约以及 2026 年 9、10 月已有规范完课来源的预约,预约日期为 2026-09-03 至 2027-01-16,均属于 2026—2027 学年。
+
+- 24 名学员可依据明确档案标注或本学年已有确认预约判断;新旧系统资料同步核查,没有发现相反学段证据。
+- 2 名学员没有可靠资料,已单独向用户提问。用户明确提供“初中”和“高中”后,保存其学段档案并确认对应预约。
+- 最终 267 条全部确认:小学 1 条、初中 94 条、高中 172 条。逐条回读分类与核对依据均与计划一致,剩余待核对为 0。
+- 首次确认新增 267 条审计;再次执行新增 0、已确认 267,未重复写入分类审计。
+
+确认前保存了私有数据库快照。写入事务锁定目标预约、学员和原始确认依据,核查预约版本、身份、日期、已有分类和工资快照;源证据变化即整体拒绝。仅保存预约分类、依据及两个人工确认档案;没有修改工资快照、课时账户或已审核工资单。
+
+## 后续页面行为
+
+学段核对页现在同时读取档案年级、旧档案学段、姓名中的明确标注及同学年已确认预约。证据一致时预填分类和依据,并展示判断说明,管理员可直接确认。资料缺失、证据冲突、日期无效或跨学年无对应证据时保持人工核对。
+
+“高安中学”等学校名称、城市、课程难度不能推定实际学段。不会把当前年级套到其他学年;例如本学年是初中,不能据此自动确认上一学年的小学/初中分类。十一年级不会误识别为一年级;不确定描述和多学段冲突不会自动选择分类。
+
+通用“阅读写作”课程仍按具体学员和预约确认,不能将整个课程统一映射为小学、初中或高中。课程映射中的通用课程待分类提示与本次待核对预约数量是不同口径。
+
+## 验证
+
+- 后端 27 项测试通过,覆盖档案空值回退、同学年来源、学员隔离、学年隔离、冲突和日期校验,以及真实 SQL 查询中的分类预填。
+- 管理端工资模块 13 项测试通过,管理端构建成功,11 个云函数定义校验通过。
+- V1、V3 运营网关已发布;两条真实接口在 9 月、10 月均返回待确认预约 0 条。
+- 数据库回读 267 条分类及依据一致,审计数量为 267,两个人工确认档案已保存。10 月课酬和复习工资仍分别为 7,940 元、1,267 元,收入预览 9,207 元。
+- 私有原始资料、确认计划、线上代码备份及核查报告位于 `/Users/mac/.local/state/xiaoshu/writing-stages/2026-10-12/`。用户资料不提交 Git。临时执行函数已删除并回读确认。
+
+本次确认不改变结算准备状态,也不代表夜间同步任务已经验收。

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

@@ -18,7 +18,7 @@
  @if(standards();as config){
   <section class="standards-summary"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
   <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button><button type="button" [class.active]="standardsSection()==='students'" (click)="loadWritingMappings()">按学员核对语法写作</button></nav>
- @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>包含待开始预约及所选工资月份的已完课记录。按学员确认分类并填写依据后,工资统计会刷新。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据'}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
+ @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>包含待开始预约及所选工资月份的已完课记录。按学员确认分类并填写依据后,工资统计会刷新。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.assessmentReason||(row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据')}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
  @if(standardsSection()==='rules'){
    <section class="content-card"><header><div><strong>当前课程工资标准</strong><span>按最新价目表统一为课程分类、时长、线上和线下单价;新版本从生效日期起计算,历史工资仍使用原规则</span></div></header>
    @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="updateRuleCategory($event)">@for(category of activeCategories();track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)">@for(duration of ruleDurations();track duration){<option [ngValue]="duration">{{duration}} 分钟</option>}</select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><app-operations-date-filter class="effective-date-filter" label="生效日期" [value]="effectiveFrom()" (valueChange)="effectiveFrom.set($event)" /><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}

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

@@ -19,7 +19,7 @@ const TEACHING_CATEGORY_DURATIONS:Record<string,number[]>={word:[30,60],trial:[6
 export interface TeachingRateMatrixRow{categoryKey:string;categoryName:string;durationMinutes:number;online?:TeachingRule;offline?:TeachingRule;}
 export function teachingRateMatrix(rules:TeachingRule[],categories:TeachingCourseCategory[],date=localDateKey(new Date())):TeachingRateMatrixRow[]{const rows:TeachingRateMatrixRow[]=[];for(const category of categories.filter((row)=>row.active)){for(const durationMinutes of TEACHING_CATEGORY_DURATIONS[category.key]||[]){const applicable=rules.filter((rule)=>rule.active&&rule.categoryKey===category.key&&rule.durationMinutes===durationMinutes&&rule.effectiveFrom<=date).sort((a,b)=>b.effectiveFrom.localeCompare(a.effectiveFrom)||Number(a.isDefault)-Number(b.isDefault));rows.push({categoryKey:category.key,categoryName:category.name,durationMinutes,online:applicable.find((rule)=>rule.deliveryMode==='online'),offline:applicable.find((rule)=>rule.deliveryMode==='offline')});}}return rows;}
 
-interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string}
+interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string;assessmentReason?:string;canAutoConfirm?:boolean}
 
 @Component({selector:'app-operations-payroll',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent,OperationsDateFilterComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsPayrollComponent implements OnInit,OnDestroy{

+ 34 - 2
scripts/cloud/payroll-integrity.js

@@ -113,8 +113,40 @@ async function historicalReservationPreview(context,input){const co=payrollTenan
 async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const co=payrollTenant(context,input),selected=new Set((payload.appointmentIds||[]).map(text));if(!selected.size)fail(400,'请选择预约');const before=await Psql.one('SELECT xs_payroll_source_version($1) AS version',[co]),preview=await historicalReservationPreview(context,input),items=preview.items.filter(i=>selected.has(i.appointmentId));if(!items.length)return{reserved:0,held:0};return(await payrollSql('SELECT xs_payroll_reservations($1,$2,$3::jsonb,$4,$5) AS result',[co,context.current.id,JSON.stringify(items),reasonOf(input),before.version])).result;}
 async function creditReconciliationPreview(context,input){const rows=await Psql.query('SELECT s AS row FROM xs_payroll_refund_rows($1) s',[payrollTenant(context,input)]),items=rows.map(r=>r.row);return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter(i=>i.adjustment>0).length,refundTotal:items.reduce((s,i)=>s+Number(i.adjustment),0)},refreshedAt:new Date().toISOString()};}
 async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);if(!payload.appointmentIds?.length)fail(400,'请选择要核对的预约');return(await payrollSql('SELECT xs_payroll_refunds($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(payload.appointmentIds.map(text)),reasonOf(input)])).result;}
-async function writingMappingPage(context,input){const co=payrollTenant(context,input),month=text(input.month);if(month&&!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');const rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND (a."dszt" IN ('0','10') OR ($2<>'' AND c."generalId"::text IN (SELECT source->>'yyds' FROM xs_payroll_sources($1) source WHERE to_char(xs_payroll_time(source->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM')=$2))) AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co,month]);
- return{items:rows.map(r=>{const stage=teachingStageFromDatabase([r.grade,r.nickname,r.username]);return{...r,suggestedCategoryKey:stage?stage+'_writing':'',evidence:r.evidence||r.grade||'',categoryKey:r.categoryKey||'',studentName:r.studentName||r.nickname};})};}
+function writingSchoolYear(value){
+ const match=text(value).match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T]|$)/);if(!match)return null;
+ const year=Number(match[1]),month=Number(match[2]),day=Number(match[3]),date=new Date(Date.UTC(year,month-1,day));
+ if(date.getUTCFullYear()!==year||date.getUTCMonth()+1!==month||date.getUTCDate()!==day)return null;
+ return month>=9?year:year-1;
+}
+function writingStagesInText(value){
+ const raw=text(value),stages=[];if(!raw||/待确认|不确定|未知|可能|疑似|不详|暂定|[??]|待核/.test(raw))return stages;
+ if(/高中|高[一二三123]|(?:^|[^一二三四五六七八九十\d])(?:十[一二]?|1[012])年级/.test(raw))stages.push('high');
+ if(/初中|初[一二三123]|(?:^|[^一二三四五六七八九十\d])[七八九789]年级/.test(raw))stages.push('middle');
+ if(/小学|(?:^|[^一二三四五六七八九十\d])[一二三四五六123456]年级/.test(raw))stages.push('primary');
+ return stages;
+}
+function writingStageAssessment(row,confirmed,asOf=businessDate()){
+ const schoolYear=writingSchoolYear(row.startsAt),currentYear=writingSchoolYear(asOf),sources=[];
+ const labels={primary:'小学',middle:'初中',high:'高中'};
+ if(schoolYear==null)return{canAutoConfirm:false,suggestedCategoryKey:'',evidence:'',assessmentReason:'预约日期无效,需人工核对'};
+ for(const prior of confirmed||[]){if(text(prior.studentId)!==text(row.studentId)||writingSchoolYear(prior.startsAt)!==schoolYear||text(prior.evidence).length<2)continue;
+  const stage={primary_writing:'primary',middle_writing:'middle',high_writing:'high'}[prior.category];
+  if(stage)sources.push({stage,evidence:'本学年已确认预约 '+text(prior.appointmentGeneralId)+' 的'+labels[stage]+'分类'});
+ }
+ // Current profile labels cannot establish a student's stage in another school year.
+ if(schoolYear===currentYear){
+  const fields=[['档案年级',row.grade],['旧档案学段',row.position],['旧扩展档案学段',row.basePosition],['学员姓名',row.realName],['学员昵称',row.nickname],['学员账号',row.username],['旧学员昵称',row.oldName],['旧学员账号',row.oldUsername],['预约学员名称',row.studentName]];
+  for(const [label,value]of fields)for(const stage of writingStagesInText(value))sources.push({stage,evidence:label+'“'+text(value)+'”明确标注'+labels[stage]});
+ }
+ const stages=[...new Set(sources.map(s=>s.stage))];
+ if(stages.length!==1)return{canAutoConfirm:false,suggestedCategoryKey:'',evidence:'',assessmentReason:stages.length?'学段证据冲突:'+stages.map(s=>labels[s]).join('/'):schoolYear!==currentYear?'跨学年且无该学年确认记录,需人工核对':'档案和本学年记录均无明确学段,需人工核对'};
+ const stage=stages[0],evidence=[...new Set(sources.map(s=>s.evidence))].slice(0,3).join(';');
+ return{canAutoConfirm:true,suggestedCategoryKey:stage+'_writing',evidence,assessmentReason:'系统证据一致:'+labels[stage]};
+}
+async function writingMappingPage(context,input){const co=payrollTenant(context,input),month=text(input.month);if(month&&!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');const rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,to_jsonb(u)->>'grade' AS grade,u."legacyUserData"->>'Position' AS position,u."legacyUserData"->'UserBase'->>'Position' AS "basePosition",u."legacyUserData"->>'HoneyName' AS "oldName",u."legacyUserData"->>'UserName' AS "oldUsername",u."realName",u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND (a."dszt" IN ('0','10') OR ($2<>'' AND c."generalId"::text IN (SELECT source->>'yyds' FROM xs_payroll_sources($1) source WHERE to_char(xs_payroll_time(source->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM')=$2))) AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co,month]);
+ const ids=[...new Set(rows.map(r=>text(r.studentId)))],confirmed=ids.length?await Psql.query(`SELECT c."generalId" AS "appointmentGeneralId",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."courseCategoryKey" AS category,a."categoryEvidence" AS evidence FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND a."szyh"::text=ANY($2::text[]) AND a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2`,[co,ids]):[];
+ return{items:rows.map(r=>{const decision=writingStageAssessment(r,confirmed);return{...r,...decision,grade:r.grade||r.position||r.basePosition||'',evidence:r.evidence||decision.evidence,categoryKey:r.categoryKey||decision.suggestedCategoryKey,studentName:r.studentName||r.nickname};})};}
 async function writingMappingCommit(context,input,payload){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',[payrollTenant(context,input),context.current.id,input.targetId,text(payload.categoryKey),text(payload.evidence),dateIso(input.expectedUpdatedAt)||null,reasonOf(input)])).result;}
 async function payRateRows(context,input){
  const rows=await Psql.query('SELECT * FROM "CoachPayRate" WHERE "company"=$1 ORDER BY "createdAt" DESC,"objectId" DESC',[payrollTenant(context,input)]),seen=new Set(),current=[];

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

@@ -85,3 +85,15 @@ test('手动同步真实收据显示完成时间,但不能冒充结算准备
  const status=await ctx.payrollSettlementSyncStatus(manager,{});assert.equal(status.state,'batch_verified');assert.equal(status.lastSuccessAt,receipt.completedAt);assert.equal(status.settlementReady,false);assert.equal(status.datasets[0].conflicts,2);
  receipt.datasets[0].reconciled=false;assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).state,'not_configured');
 });
+
+test('学段核对页预填一致证据,旧档案空值回退有效,冲突时不自动选分类',async()=>{
+ const previous=context.businessDate;context.businessDate=()=> '2026-10-12';try{
+  await db.exec(`UPDATE "_User" SET "legacyUserData"="legacyUserData"||'{"Position":"初中八年级"}'::jsonb WHERE "objectId"='student';`);
+  let row=(await context.writingMappingPage(manager,{month:'2026-10'})).items.find(r=>r.appointmentId==='completed-writing-common');
+  assert.equal(row.canAutoConfirm,true);assert.equal(row.categoryKey,'middle_writing');assert.match(row.evidence,/初中八年级/);
+  await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,kcid,yysj,"courseCategoryKey","categoryEvidence") VALUES ('stage-conflict','co',9201,'20','30','398','2026-09-02 10:00','high_writing','人工确认高中');
+   INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status,subtitle) VALUES ('stage-conflict-common','co',54,9201,9201,99,'阅读写作');`);
+  row=(await context.writingMappingPage(manager,{month:'2026-10'})).items.find(r=>r.appointmentId==='completed-writing-common');
+  assert.equal(row.canAutoConfirm,false);assert.equal(row.categoryKey,'');assert.match(row.assessmentReason,/冲突/);
+ }finally{context.businessDate=previous;}
+});

+ 29 - 0
scripts/tests/writing-stage-decision.test.mjs

@@ -0,0 +1,29 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+const context=vm.createContext({Date,text:v=>String(v??'').trim()});
+vm.runInContext(readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),context);
+const row={studentId:'20',startsAt:'2026-09-09 20:00',studentName:'同学',grade:'',nickname:'同学'};
+const decide=(r,prior=[])=>context.writingStageAssessment({...row,...r},prior,'2026-10-12');
+test('明确年级和姓名标注可判断,数字年级不会误判十一年级为一年级',()=>{
+ for(const [value,category] of [['小学五年级','primary_writing'],['7年级','middle_writing'],['初三','middle_writing'],['某同学高中','high_writing'],['某同学高三','high_writing'],['十一年级','high_writing'],['10年级','high_writing']]){
+  const result=decide({nickname:value});assert.equal(result.canAutoConfirm,true);assert.equal(result.suggestedCategoryKey,category);assert.ok(result.evidence.includes(value));
+ }
+});
+test('空年级仍能读取旧档案,城市学校名称和不确定描述不能推断学段',()=>{
+ assert.equal(decide({grade:'',position:'初中八年级'}).suggestedCategoryKey,'middle_writing');
+ for(const name of ['某同学丰城','小同学','某同学高安中学','可能高中','初中?'])assert.equal(decide({nickname:name}).canAutoConfirm,false);
+});
+test('同学年已确认预约可复用,其他学员和跨学年证据不会串用',()=>{
+ const prior={studentId:'20',startsAt:'2026-09-02 19:00',category:'middle_writing',evidence:'人工确认初中',appointmentGeneralId:123};
+ assert.equal(decide({},[prior]).suggestedCategoryKey,'middle_writing');
+ assert.equal(decide({},[{...prior,studentId:'21'}]).canAutoConfirm,false);
+ assert.equal(decide({},[{...prior,startsAt:'2025-09-02 19:00'}]).canAutoConfirm,false);
+ assert.equal(decide({startsAt:'2027-09-02 19:00',grade:'初中'}).canAutoConfirm,false);
+});
+test('档案、姓名或既往确认冲突时保留人工核对,日期无效不自动确认',()=>{
+ for(const r of [{grade:'初中',nickname:'某同学高中'},{grade:'小学/初中'},{startsAt:'2026-02-30 19:00',grade:'高中'}])assert.equal(decide(r).canAutoConfirm,false);
+ const conflicting=[{studentId:'20',startsAt:'2026-09-02',category:'middle_writing',evidence:'人工确认',appointmentGeneralId:1},{studentId:'20',startsAt:'2026-09-03',category:'high_writing',evidence:'人工确认',appointmentGeneralId:2}];
+ const result=decide({},conflicting);assert.equal(result.canAutoConfirm,false);assert.match(result.assessmentReason,/冲突/);assert.equal(result.suggestedCategoryKey,'');
+});