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

fix(courses): map legacy courses without directory records by stage

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

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

@@ -22,7 +22,7 @@
    @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="updateRuleCategory($event)">@for(category of activeCategories();track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)">@for(duration of ruleDurations();track duration){<option [ngValue]="duration">{{duration}} 分钟</option>}</select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
    <div class="table-scroll"><table class="data-table standards-table rate-matrix"><thead><tr><th>课程分类</th><th>课程时长</th><th>线上单价</th><th>线下单价</th><th>课时扣减</th><th>当前版本</th><th>操作</th></tr></thead><tbody>@for(row of standardMatrix();track row.categoryKey+':'+row.durationMinutes){<tr><td><strong>{{row.categoryName}}</strong></td><td>{{row.durationMinutes}}分钟</td><td class="money">¥{{row.online?.teacherPay|number:'1.2-2'}}</td><td class="money offline-price">¥{{row.offline?.teacherPay|number:'1.2-2'}}</td><td>{{row.online?creditSummary(row.online):row.offline?creditSummary(row.offline):'—'}}</td><td>{{row.online?.effectiveFrom||row.offline?.effectiveFrom}}<small>{{row.online?.isDefault&&row.offline?.isDefault?'系统价目':'管理员版本'}}</small></td><td>@if(isManager()){<span class="matrix-actions"><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'online')">编辑线上</button><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'offline')">编辑线下</button></span>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
   } @else if(standardsSection()==='mappings'){
-   <section class="content-card"><header><div><strong>历史课程映射</strong><span>课程大类由数据库的课程层级自动读取;管理员只需确认具体课程分类</span></div><label class="mapping-search"><span class="sr-only">搜索课程</span><input [ngModel]="mappingSearch()" (ngModelChange)="mappingSearch.set($event)" placeholder="搜索课程、学段、分类或 ID" /></label></header><div class="table-scroll"><table class="data-table mapping-table"><thead><tr><th>历史课程</th><th>系统建议</th><th>数据库大类</th><th>课程分类</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of filteredMappings();track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId}}</small></td><td>{{row.suggestedCategoryName}}</td><td><span class="stage-badge" [class.missing]="!row.stageConfirmed">{{row.stageName||'数据库未归类'}}</span></td><td><select [ngModel]="mappingSelections()[row.courseId]" (ngModelChange)="setMapping(row.courseId,$event)" [disabled]="!row.stageConfirmed">@if(mappingCategoryUnavailable(row)){<option [value]="row.categoryKey" disabled>{{row.categoryName||'旧分类'}}(不再使用)</option>}@for(category of mappingCategories(row);track category.key){<option [value]="category.key">{{category.name}}</option>}</select></td><td><span class="badge" [class.warn]="!row.confirmed">{{row.confirmed?'已确认':!row.stageConfirmed?'数据库大类缺失':'待确认'}}</span></td><td>@if(isManager()){<button class="ui-button secondary compact" type="button" (click)="confirmMapping(row)" [disabled]="saving()||!row.stageConfirmed">{{savingMappingId()===row.courseId?'确认中…':row.confirmed?'更新':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6"><div class="empty">没有匹配的课程</div></td></tr>}</tbody></table></div></section>
+   <section class="content-card"><header><div><strong>历史课程映射</strong><span>优先读取课程目录;旧预约保留的课程也可确认分类,语法写作须对应学段</span></div><label class="mapping-search"><span class="sr-only">搜索课程</span><input [ngModel]="mappingSearch()" (ngModelChange)="mappingSearch.set($event)" placeholder="搜索课程、学段、分类或 ID" /></label></header><div class="table-scroll"><table class="data-table mapping-table"><thead><tr><th>历史课程</th><th>系统建议</th><th>数据库大类</th><th>课程分类</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of filteredMappings();track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId}}</small></td><td>{{row.suggestedCategoryName||'需按学段确认'}}</td><td><span class="stage-badge" [class.missing]="!row.stageConfirmed">{{row.stageName||'数据库未归类'}}</span></td><td><select [ngModel]="mappingSelections()[row.courseId]" (ngModelChange)="setMapping(row.courseId,$event)" [disabled]="!mappingCategories(row).length">@if(mappingCategoryUnavailable(row)){<option [value]="row.categoryKey" disabled>{{row.categoryName||'旧分类'}}(不再使用)</option>}@for(category of mappingCategories(row);track category.key){<option [value]="category.key">{{category.name}}</option>}</select></td><td><span class="badge" [class.warn]="!row.confirmed">{{row.confirmed?'已确认':!row.stageConfirmed?'数据库大类缺失':'待确认'}}</span></td><td>@if(isManager()){<button class="ui-button secondary compact" type="button" (click)="confirmMapping(row)" [disabled]="saving()||!mappingCategories(row).length">{{savingMappingId()===row.courseId?'确认中…':row.confirmed?'更新':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6"><div class="empty">没有匹配的课程</div></td></tr>}</tbody></table></div></section>
   } @else if(standardsSection()==='categories') {
    <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类名称与价目表完全一致,不再显示或使用旧分类</span></div></header><div class="stage-overview" aria-label="数据库课程大类">@for(stage of stageOptions;track stage.key){<span>{{stage.name}}·数据库大类</span>}</div><div class="category-grid">@for(category of activeCategories();track category.key){<article><span>{{category.key}}</span><strong>{{category.name}}</strong><small>固定分类</small></article>}</div></section>
   } @else if(standardsSection()==='reservations') {

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

@@ -37,12 +37,12 @@ export class OperationsPayrollComponent implements OnInit{
  creditSummary(rule:TeachingRule):string{return rule.creditCosts.map((item)=>`${item.label} -${item.amount}`).join('+');}
  editMatrixRule(row:TeachingRateMatrixRow,delivery:'online'|'offline'):void{const rule=row[delivery];this.ruleCategoryKey.set(row.categoryKey);this.ruleDuration.set(row.durationMinutes);this.ruleDelivery.set(delivery);this.rulePay.set(rule?.teacherPay||0);this.effectiveFrom.set(localDateKey(new Date()));}
  updateRuleCategory(categoryKey:string):void{this.ruleCategoryKey.set(categoryKey);const durations=TEACHING_CATEGORY_DURATIONS[categoryKey]||[];if(!durations.includes(this.ruleDuration()))this.ruleDuration.set(durations[0]||30);}
- mappingCategories(row:CourseTeachingMapping):TeachingCourseCategory[]{return this.activeCategories().filter((category)=>!TEACHING_CATEGORY_STAGE[category.key]||TEACHING_CATEGORY_STAGE[category.key]===row.stageKey);}
+ mappingCategories(row:CourseTeachingMapping):TeachingCourseCategory[]{if(!row.stageConfirmed&&/语法|写作|作文|强化/.test(row.courseName))return [];return this.activeCategories().filter((category)=>!TEACHING_CATEGORY_STAGE[category.key]||TEACHING_CATEGORY_STAGE[category.key]===row.stageKey);}
  mappingCategoryUnavailable(row:CourseTeachingMapping):boolean{return Boolean(row.categoryKey&&!this.mappingCategories(row).some((category)=>category.key===row.categoryKey));}
  setMapping(courseId:number,categoryKey:string):void{this.mappingSelections.update((value)=>({...value,[courseId]:categoryKey}));}
  async saveTeachingRule():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/teaching-rules/save','',`调整 ${this.ruleCategoryKey()} ${this.ruleDuration()}分钟${this.ruleDelivery()==='online'?'线上':'线下'}标准`,{categoryKey:this.ruleCategoryKey(),durationMinutes:this.ruleDuration(),deliveryMode:this.ruleDelivery(),teacherPay:this.rulePay(),creditCosts:this.creditCosts(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('工资与课时规则已保存,历史版本已保留');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'规则保存失败');}finally{this.saving.set(false);}}
  async deactivateTeachingRule(rule:TeachingRule):Promise<void>{if(rule.isDefault||!rule.active)return;if(!globalThis.confirm(`确定停用“${rule.categoryName} ${rule.durationMinutes}分钟 ${rule.deliveryLabel}”从 ${rule.effectiveFrom} 生效的规则吗?停用后,找不到其他有效版本的课程将无法排课。`))return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/teaching-rules/deactivate',rule.objectId,`停用 ${rule.categoryName} ${rule.durationMinutes}分钟${rule.deliveryLabel}规则`,{},rule.updatedAt);this.message.set('规则已停用,历史版本和既有预约快照保持不变');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'规则停用失败');}finally{this.saving.set(false);}}
- async confirmMapping(row:CourseTeachingMapping):Promise<void>{const categoryKey=this.mappingSelections()[row.courseId];if(!row.stageConfirmed){this.error.set('该课程在数据库中缺少上级大类,请先修正课程数据');return;}if(!categoryKey){this.error.set('请确认课程分类');return;}this.saving.set(true);this.savingMappingId.set(row.courseId);this.error.set('');this.message.set('');try{await this.operations.mutate('ops/course-mappings/commit','',`确认课程「${row.courseName}」的标准分类`,{items:[{courseId:row.courseId,categoryKey}]});const standards=await this.operations.teachingStandards();this.standards.set(standards);this.mappingSelections.set(Object.fromEntries(standards.mappings.map((item)=>[item.courseId,item.categoryKey||item.suggestedCategoryKey])));this.message.set('课程分类已确认,大类已按数据库课程数据记录');}catch(error){this.error.set(error instanceof Error?error.message:'课程映射保存失败');}finally{this.savingMappingId.set(0);this.saving.set(false);}}
+ async confirmMapping(row:CourseTeachingMapping):Promise<void>{const categoryKey=this.mappingSelections()[row.courseId];if(!this.mappingCategories(row).some((category)=>category.key===categoryKey)){this.error.set('请按课程学段选择有效分类;通用语法写作课程需按学员学段分别确认');return;}if(!categoryKey){this.error.set('请确认课程分类');return;}this.saving.set(true);this.savingMappingId.set(row.courseId);this.error.set('');this.message.set('');try{await this.operations.mutate('ops/course-mappings/commit','',`确认课程「${row.courseName}」的标准分类`,{items:[{courseId:row.courseId,categoryKey}]});const standards=await this.operations.teachingStandards();this.standards.set(standards);this.mappingSelections.set(Object.fromEntries(standards.mappings.map((item)=>[item.courseId,item.categoryKey||item.suggestedCategoryKey])));this.message.set('课程分类已确认');}catch(error){this.error.set(error instanceof Error?error.message:'课程映射保存失败');}finally{this.savingMappingId.set(0);this.saving.set(false);}}
  async previewReservations():Promise<void>{this.saving.set(true);this.error.set('');try{this.reservationReconciliation.set(await this.operations.mutate<CreditReservationReconciliation>('ops/credit-reservations/preview','','预览历史未来预约课时预留',{}));}catch(error){this.error.set(error instanceof Error?error.message:'历史预约预留核对失败');}finally{this.saving.set(false);}}
  async commitReservations():Promise<void>{const data=this.reservationReconciliation();if(!data?.summary.total)return;this.saving.set(true);this.error.set('');try{const result=await this.operations.mutate<{reserved:number;held:number}>('ops/credit-reservations/commit','','确认历史未来预约课时预留',{appointmentIds:data.items.map((item)=>item.appointmentId)});this.message.set(`已补建 ${result.reserved} 条课时预留,${result.held} 条预约已标记为待处理`);await this.previewReservations();}catch(error){this.error.set(error instanceof Error?error.message:'历史预约预留确认失败');}finally{this.saving.set(false);}}
  async previewReconciliation():Promise<void>{this.saving.set(true);this.error.set('');try{this.reconciliation.set(await this.operations.mutate('ops/credit-reconciliation/preview','', '查看2026年9月起复习课课时差异',{}));}catch(error){this.error.set(error instanceof Error?error.message:'课时差异预览失败');}finally{this.saving.set(false);}}

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

@@ -151,7 +151,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return Array.from(grouped, ([date, items]) => ({ date, items }));
   }
 
-  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作',middle_writing:'初中语法写作',high_writing:'高中语法写作',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
+  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const sourceTitle = String(item['Title'] ?? '').trim();
     if (sourceTitle.includes('@')) {

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

@@ -244,7 +244,7 @@ export class StudyRecordsPageComponent implements OnInit {
     return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
   }
 
-  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作',middle_writing:'初中语法写作',high_writing:'高中语法写作',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
+  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const title = String(item['Title2'] ?? item['Title'] ?? '课程内容');
     if (item['Title2'] || !title.includes('@')) return title;

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


+ 53 - 0
scripts/tests/teaching-course-mapping.test.mjs

@@ -0,0 +1,53 @@
+import { test, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import { PGlite } from '@electric-sql/pglite';
+const db = new PGlite();
+const context = { Psql: { query: async (sql, values) => (await db.query(sql, values)).rows } };
+vm.createContext(context);
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1], context);
+const profile = (id, categoryKey) => ({ id: String(id), get: key => ({ courseId: id, categoryKey, confirmed: true })[key] });
+context.companyIdOf = () => 'company-a';
+context.companyPointer = () => 'company-a';
+context.requireRole = () => {};
+context.reasonOf = () => 'mapping test';
+context.newClassObjects = async name => name === 'CourseTeachingProfile' ? [profile(454, 'word'), profile(464, 'word'), profile(398, 'word')] : [];
+before(async () => {
+  await db.exec(`
+    CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text);
+    CREATE TABLE "CourseBinding" (company text,kcid text);
+    CREATE TABLE "CourseAppointment" (company text,id numeric,kcid text);
+    CREATE TABLE "CommonModel" (company text,"modelId" numeric,"itemId" numeric,status numeric,subtitle text);
+    INSERT INTO "Node" VALUES ('company-a',8,0,'小学'),('company-a',81,8,'小学语法写作课'),('company-b',464,0,'其他帐套课程');
+    INSERT INTO "CourseBinding" VALUES ('company-a','81');
+    INSERT INTO "CourseAppointment" VALUES ('company-a',1,'398'),('company-a',2,'454'),('company-a',3,'464'),('company-a',4,'999'),('company-b',5,'455');
+    INSERT INTO "CommonModel" VALUES ('company-a',54,1,99,'阅读写作'),('company-a',54,2,99,'牛津上海版初中新课标'),('company-a',54,3,99,'体育单招1700词'),('company-a',54,4,-2,'已取消的缺失课程'),('company-b',54,5,99,'其他帐套课程');
+  `);
+});
+after(() => db.close());
+test('writing maps only to its explicit school stage; generic writing is never suggested as word lessons', () => {
+  for (const stage of ['primary', 'middle', 'high']) assert.equal(context.suggestedTeachingCategory('阅读写作', stage), stage + '_writing');
+  assert.equal(context.suggestedTeachingCategory('语法写作课', ''), '');
+  for (const name of ['牛津上海版初中新课标', '体育单招1700词']) assert.equal(context.suggestedTeachingCategory(name, ''), 'word');
+});
+test('real course query recovers missing Node courses, preserves tenant isolation and stage checks', async () => {
+  const rows = await context.courseTeachingRows({}, {});
+  const byId = new Map(rows.map(row => [row.courseId, row]));
+  assert.equal(byId.get(81).stageKey, 'primary');
+  assert.equal(byId.get(81).suggestedCategoryKey, 'primary_writing');
+  assert.equal(byId.get(454).stageKey, 'middle');
+  assert.equal(byId.get(454).confirmed, true);
+  assert.equal(byId.get(464).courseName, '体育单招1700词');
+  assert.equal(byId.get(464).confirmed, true);
+  assert.equal(byId.get(398).confirmed, false);
+  assert.equal(byId.get(398).suggestedCategoryKey, '');
+  assert.equal(byId.has(999), false);
+  assert.equal(byId.has(455), false);
+});
+test('mapping rejects cross-stage writing, unknown courses and generic writing before saving', async () => {
+  await assert.rejects(context.commitCourseMappings({}, {}, { items: [{ courseId: 81, categoryKey: 'high_writing' }] }), /语法写作分类必须/);
+  await assert.rejects(context.commitCourseMappings({}, {}, { items: [{ courseId: 398, categoryKey: 'word' }] }), /语法写作课程缺少学段/);
+  await assert.rejects(context.commitCourseMappings({}, {}, { items: [{ courseId: 999, categoryKey: 'word' }] }), /未找到该课程/);
+});

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