Kaynağa Gözat

fix: highlight pending feedback and lock reports after student review

彭峰 1 saat önce
ebeveyn
işleme
6b04d82f14

+ 27 - 0
docs/qa/lesson-feedback-lock-2026-10-15.md

@@ -0,0 +1,27 @@
+# 反馈待补提醒与学生评价后锁定
+
+## 规则
+
+- 老师销课记录中的“反馈待补”用橙色 `#b45309` 加粗,已提交仍用原有灰色。
+- 老师已提交反馈、学生尚未评价:可修改。
+- 老师已提交反馈、学生已经评价:隐藏修改入口,报告和详情展示“学生已评价,课堂反馈已锁定”;服务端拒绝修改,旧表单也不能绕过。
+- 学生先评价但老师未填写反馈(pending 或历史无报告):允许首次补交;补交后锁定。
+- 已成功保存的请求在评价后重试返回原成功结果,不重复写入。
+
+## 实现和校验
+
+接口能力和 SQL 写入同时判断学生评价,兼容新 `LessonReview` 和旧 `LessonRecord.pf` 评分。学生评价与老师修改使用相同的事务锁;校验旧评分时锁定上课记录。仅约束老师课堂反馈,保留学生评价、课时、工资及复习计划。
+
+- `npm run test:lesson-workflow`:17/17 通过,覆盖评价前修改、评价后拒绝、旧评分锁定、首次补交后锁定及幂等重试。
+- `npm run build:mobile`:通过,现有预算/CommonJS 警告。
+- `npm run cloud:validate`:11 个函数定义通过。
+- `node scripts/check-lesson-feedback-browser.mjs`:补交/修改/锁定三个状态、旧编辑链接、学生独立评价通过。
+- `node scripts/check-teacher-earnings-browser.mjs`:销课待补文字计算颜色为 `rgb(180, 83, 9)`,360/390px 布局和原工资流程通过。
+
+## 发布与边界
+
+后端版本 `2026-10-15-feedback-lock-v14` 已发布:只更新 app gateway 能力规则和版本标记,以及 `xs_lesson_feedback_update`、`xs_lesson_review` 两个数据库函数。发布前确认旧系统同步空闲,备份实际线上定义;发布后回读代码、SQL 与版本。两个 ops gateway 未改变。
+
+私有备份及真实会话只读验证结果位于 `~/.local/state/xiaoshu/cloud-releases/2026-10-15-feedback-lock/`。恢复时用该目录备份还原上述两个 SQL 定义及 app gateway,保留所有业务记录和幂等命令。没有代用户提交反馈或评价。
+
+前端本地构建和浏览器验收已完成,尚未部署线上前端;没有进行实际微信 WebView 验收。浏览器保存测试使用隔离接口数据。

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

@@ -149,6 +149,7 @@
         <header class="feedback-heading"><div><h2>完课反馈</h2><small>课堂内容与下次学习安排</small></div>
           @if (!isStudentView && can('editFeedback')) { <button type="button" class="edit-report" (click)="openFeedbackEditor()" [disabled]="submitting()">{{feedbackPending()?'补充反馈':'修改报告'}}</button> }
         </header>
+        @if (order()['feedbackLocked']) { <p class="saved-notice" role="status">学生已评价,课堂反馈已锁定</p> }
         @if (feedbackSaved()) { <p class="saved-notice" role="status">课堂反馈已保存</p> }
         @if(f['feedbackStatus']==='pending'){<p role="status">课程已结束,课堂反馈待老师补充。</p>}@else{<app-lesson-feedback [feedback]="f" />}
       </section>

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html

@@ -17,6 +17,7 @@
       <header><h2><lucide-icon [img]="icons.ClipboardList" [size]="19" aria-hidden="true" />完课反馈</h2>
         @if ($any(r.order['allowedActions'])?.includes('editFeedback')) { <a class="edit-link" routerLink="/pages/shop/get_order" [queryParams]="{id:orderId,editReport:1}">{{ !r.feedback || r.feedback['feedbackStatus'] === 'pending' ? '补充反馈' : '修改报告' }}</a> }
       </header>
+      @if (r.order['feedbackLocked']) { <p class="empty-copy" role="status">学生已评价,课堂反馈已锁定</p> }
       @if (r.feedback && r.feedback['feedbackStatus'] !== 'pending') { <app-lesson-feedback [feedback]="r.feedback" /> }
       @else { <p class="empty-copy">老师尚未提交反馈,提交后将在这里展示。</p> }
     </section>

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

@@ -116,7 +116,7 @@
               <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> }
+                @if (isHistory && isCoach && !item['cancelled']) { <div class="lesson-pay"><strong>{{ item['payStatus'] === 'earned' ? '带课工资 ¥' + ($any(item['teacherPay']) | number:'1.2-2') : '工资待核对' }}</strong><small [class.feedback-pending]="item['feedbackStatus'] !== 'submitted'">{{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" />

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

@@ -117,3 +117,5 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .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; }
+
+.lesson-pay small.feedback-pending { color:#b45309; font-weight:600; }

+ 9 - 4
scripts/check-lesson-feedback-browser.mjs

@@ -10,9 +10,9 @@ const server=createServer((req,res)=>{let file=resolve(root,'.'+decodeURICompone
 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 errors=[],calls=[];
-let feedback=null,failSave=false,role=3;
+let feedback=null,failSave=false,role=3,reviewed=false;
 const revision='2026-10-04 06:00:00.123456+00';
-const order=()=>({GeneralID:901,szyh:100,pl:300,HoneyName:'测试学员',kcid:801,kcmc:'历史课程',dszt:30,updatedAt:revision,allowedActions:role===3?['editFeedback']:['review'],reviewStatus:'pending',yysj:'2026-10-04 14:00',feedback});
+const order=()=>({GeneralID:901,szyh:100,pl:300,HoneyName:'测试学员',kcid:801,kcmc:'历史课程',dszt:30,updatedAt:revision,allowedActions:role===3?(reviewed&&feedback?.feedbackStatus==='submitted'?[]:['editFeedback']):(reviewed?[]:['review']),reviewStatus:reviewed?'submitted':'pending',feedbackLocked:reviewed&&feedback?.feedbackStatus==='submitted',yysj:'2026-10-04 14:00',feedback});
 try {
  const context=await browser.newContext({viewport:{width:390,height:844}});
  await context.addInitScript(()=>{if(!localStorage.getItem('xiaoshu.angular.session'))localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-fixture-only',user:{userId:300,groupId:3,honeyName:'测试老师'}}));});
@@ -47,13 +47,18 @@ try {
  assert.equal(calls.filter(c=>c.action==='app_lesson_complete').length,0);
  await page.goto(origin+'/pages/shop/baobiao?sid=901');await page.getByRole('link',{name:'修改报告',exact:true}).waitFor();
  assert.match(await page.locator('.lesson-report').innerText(),/历史课程补充学习内容/);
+ reviewed=true;await page.reload();await page.getByText('学生已评价,课堂反馈已锁定',{exact:true}).waitFor();
+ assert.equal(await page.getByRole('link',{name:'修改报告',exact:true}).count(),0);
+ await page.goto(origin+'/pages/shop/get_order?id=901&editReport=1');await page.getByText('学生已评价,课堂反馈已锁定',{exact:true}).waitFor();
+ assert.equal(await page.locator('dialog[open]').count(),0);
+ await page.goto(origin+'/pages/shop/baobiao?sid=901');
  // Pending modern reports use the same clear supplement action.
  feedback={feedbackStatus:'pending',revision};await page.reload();await page.getByRole('link',{name:'补充反馈',exact:true}).waitFor();
  assert.match(await page.locator('.status-badge').innerText(),/老师反馈待补/);
- role=1;feedback=null;await page.evaluate(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:student-fixture',user:{userId:100,groupId:1,honeyName:'测试学员'}})));
+ role=1;feedback=null;reviewed=false;await page.evaluate(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:student-fixture',user:{userId:100,groupId:1,honeyName:'测试学员'}})));
  await page.reload();await page.locator('.report-card').first().waitFor();assert.equal(await page.getByRole('link',{name:'补充反馈',exact:true}).count(),0);
  await page.goto(origin+'/pages/shop/get_order?id=901');await page.getByRole('button',{name:'提交评价',exact:true}).waitFor();assert.equal(await page.getByRole('button',{name:'补充反馈',exact:true}).count(),0);
  assert.deepEqual(errors,[]);
- writeFileSync(out+'/result.json',JSON.stringify({passed:true,checks:['历史报告补交入口与自动打开表单','保存失败保留输入,重试成功后报告可见','仅调用反馈接口,不调用结课接口','待补报告文案','学生没有老师补交入口,保留独立评价'],environment:'local production build + isolated API fixtures; not real WeChat'},null,2));
+ writeFileSync(out+'/result.json',JSON.stringify({passed:true,checks:['历史报告补交入口与自动打开表单','保存失败保留输入,重试成功后报告可见','仅调用反馈接口,不调用结课接口','评价后隐藏编辑入口,旧编辑链接不打开表单','先评价仍可首次补交','待补报告文案','学生没有老师补交入口,保留独立评价'],environment:'local production build + isolated API fixtures; not real WeChat'},null,2));
  console.log('Feedback browser checks passed');
 } finally {await browser.close();await new Promise(r=>server.close(r));}

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

@@ -57,6 +57,7 @@ try{
  await page.getByRole('link',{name:'查看全部学员复习计划'}).click();await page.locator('.memory-date-tasks').waitFor();assert.match(page.url(),/\/pages\/stu\/stu_calendar$/);
  await page.goto(origin+'/pages/stu/stu_calendar?scope=overdue');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());
+ assert.equal(await page.locator('.lesson-pay small.feedback-pending').count(),1);assert.equal(await page.locator('.lesson-pay small.feedback-pending').evaluate(e=>getComputedStyle(e).color),'rgb(180, 83, 9)');
  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宽度无横向溢出且固定导航可见');
  for(const mode of ['legacy','split','zero']){

+ 5 - 3
scripts/cloud/lesson-workflow.js

@@ -64,6 +64,9 @@ async function lessonOrderCapabilities(order, current) {
   }
   const lesson = await Psql.oneOrNone('SELECT pf,pjnr,pldp FROM "LessonRecord" WHERE company=$1 AND yyds::text=$2 ORDER BY "createdAt" LIMIT 1',[DEFAULT_COMPANY_ID,id]);
   const review = await Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"=$2',[DEFAULT_COMPANY_ID,id]);
+  const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
+  const submittedReport = reviewed && [30,40].includes(status) ? await Psql.oneOrNone('SELECT "feedbackStatus" FROM "LessonCompletionReport" WHERE company=$1 AND "appointmentGeneralId"::text=$2 LIMIT 1',[DEFAULT_COMPANY_ID,id]) : null;
+  const feedbackLocked = Boolean(submittedReport && submittedReport.feedbackStatus !== 'pending');
   const teacher = uid === number(order.pl), student = uid === number(order.szyh), actions = [];
   let blockedReason = '';
   if (order.cancelled) blockedReason = '预约已取消';
@@ -73,11 +76,10 @@ async function lessonOrderCapabilities(order, current) {
     else if (status === 0) actions.push('start');
     if (status === 10) actions.push('continue','complete');
     if ([11,20].includes(status)) actions.push('complete');
-    if ([30,40].includes(status)) actions.push('editFeedback');
+    if ([30,40].includes(status) && !feedbackLocked) actions.push('editFeedback');
   }
-  const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
   if (!order.cancelled && student && [30,40].includes(status) && !reviewed) actions.push('review');
-  return {...order,...state,serverNow:new Date().toISOString(),allowedActions:actions,blockedReason,reviewStatus:reviewed?'submitted':'pending',studentReview:review || (reviewed ? {score:number(lesson.pf),comment:lesson.pjnr,submittedAt:null} : null)};
+  return {...order,...state,serverNow:new Date().toISOString(),allowedActions:actions,blockedReason,feedbackLocked,reviewStatus:reviewed?'submitted':'pending',studentReview:review || (reviewed ? {score:number(lesson.pf),comment:lesson.pjnr,submittedAt:null} : null)};
 }
 async function databaseAppointmentVersions(input,current) {
   assertLessonTenant(current);

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
scripts/deploy-admin-functions.mjs


+ 8 - 0
scripts/sql/lesson-workflow.sql

@@ -150,6 +150,8 @@ DECLARE a record; l record; existing "LessonReview";
 BEGIN
  IF rating IS NULL OR rating NOT BETWEEN 1 AND 5 OR review_comment IS NULL OR length(review_comment)>200 THEN RAISE EXCEPTION '评分应为1至5分,评价最多200字';END IF;
  IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
+ -- Serialize student evaluation with teacher amendments using the same lock order.
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT ca.*,c.status AS common_status INTO a FROM "CourseAppointment" ca JOIN "CommonModel" c ON c.company=ca.company AND c."modelId"=54 AND c."itemId"=ca.id WHERE c.company=co AND c."generalId"::text=gid FOR UPDATE OF ca;
  IF NOT FOUND OR a.common_status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
  IF a.szyh::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约学生可以评价';END IF;
@@ -229,6 +231,12 @@ 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 FOR UPDATE;
+ -- Lock the legacy lesson too, so old-client ratings cannot race the decision.
+ PERFORM 1 FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
+ IF report."objectId" IS NOT NULL AND report."feedbackStatus" IS DISTINCT FROM 'pending' AND (
+  EXISTS(SELECT 1 FROM "LessonReview" WHERE company=co AND "appointmentGeneralId"=gid)
+  OR EXISTS(SELECT 1 FROM "LessonRecord" WHERE company=co AND yyds::text=gid AND COALESCE(pf::text,'') ~ '^[1-5](\.0+)?$')
+ ) THEN RAISE EXCEPTION '学生已评价,课堂反馈已锁定,不能再修改';END IF;
  -- Old completed lessons may have no report. First submission uses the appointment
  -- revision from the verified detail; later edits use the report revision.
  IF report."objectId" IS NULL THEN

+ 25 - 4
scripts/tests/lesson-workflow.test.mjs

@@ -41,9 +41,10 @@ before(async()=>{
 });
 after(()=>db.close());
 let serial=0;
-async function appointment(start=true){
+async function appointment(start=true,timeOverride=''){
  const date=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;
- const time=String(5+(serial++%16)).padStart(2,'0')+':00';
+ const time=timeOverride||String(5+(serial++%16)).padStart(2,'0')+':00';
+ if(timeOverride)serial++;
  const entries=[{common:{title:'学员',subtitle:'词表'},addon:{szyh:'20',pl:'30',fxpl:'30',plxm:'老师',kcid:'100',yykcid:'100',dslx:'1',jffs:'线上',bindingId:binding,courseCategoryKey:'word',durationMinutes:30,deliveryMode:'online',creditCostsSnapshot:[{account:'Purse',amount:1}],teacherPaySnapshot:20,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}}];
  const result=(await one("SELECT xs_schedule_save('co','admin','{}',$1::jsonb,$2) result",[JSON.stringify(entries),'schedule-'+serial])).result;
  const common=result.targetIds[0];
@@ -208,7 +209,7 @@ test('选填复习情况和训练状态允许留空或自由文本,报告保
 });
 
 test('修改报告只更新文字;重试幂等、旧版本和越权拒绝,保留课时、学习成果与评价',async()=>{
- const a=await appointment();await complete(a.gid,a.updatedAt,'edit-setup-request');await review(a.gid,4,'学生原评价');
+ const a=await appointment();await complete(a.gid,a.updatedAt,'edit-setup-request');
  const report=await one('SELECT *,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
  const before=await balance();
  const lesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);
@@ -224,6 +225,9 @@ test('修改报告只更新文字;重试幂等、旧版本和越权拒绝,
  const afterLesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);
  assert.equal(afterLesson.kzsj,lesson.kzsj);assert.equal(afterLesson.pf,lesson.pf);assert.equal(afterLesson.pjnr,lesson.pjnr);assert.equal(afterLesson.pldp,'新的点评');assert.equal(await balance(),before);
  assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).dszt,'30');
+ await review(a.gid,4,'学生原评价');
+ assert.equal((await edit(report.revision)).saved,true); // A successful request retry stays successful after review.
+ await assert.rejects(edit(updated.updatedAt,'edit-after-review'),/已锁定/);
  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'),/已取消/);
 });
@@ -276,7 +280,7 @@ test('历史已结课且无报告可首次补交,重复并发只生成一份
  await db.exec('ALTER TABLE "LessonCompletionReport" DROP CONSTRAINT fail_first_report');
  const results=await Promise.all([edit('history-rollback-test'),edit('history-rollback-test')]);
  assert.ok(results.every(r=>r.result.saved));assert.equal(results[0].result.feedbackId,results[1].result.feedbackId);
- await assert.rejects(edit('history-distinct-request'),/报告已被更新/);
+ await assert.rejects(edit('history-distinct-request'),/已锁定/);
  const reports=(await db.query('SELECT * FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid])).rows;
  assert.equal(reports.length,1);assert.equal(reports[0].feedbackStatus,'submitted');assert.equal(reports[0].contentSummary,feedback.contentSummary);
  assert.equal(reports[0].lessonId,beforeLesson.objectId);assert.equal(await balance(),beforeBalance);assert.deepEqual(await snapshots(),before);
@@ -297,3 +301,20 @@ test('历史补交入口仅属于本课老师,学生保持独立评价入口',
  const student=await context.lessonOrderCapabilities(order,user(20));assert.ok(student.allowedActions.includes('review'));assert.ok(!student.allowedActions.includes('editFeedback'));
  assert.equal((await context.lessonOrderCapabilities({...order,cancelled:true},user(30))).allowedActions.length,0);
 });
+
+
+test('学生评价后锁定报告,包括旧评分;先评价仍允许一次补交',async()=>{
+ const a=await appointment(true,'22:30');await complete(a.gid,a.updatedAt,'review-lock-setup');
+ const report=await one('SELECT *,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
+ await db.query('UPDATE "LessonRecord" SET pf=5 WHERE yyds=$1',[a.gid]);
+ await assert.rejects(one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,report.revision,'legacy-rating-lock',JSON.stringify(feedback)]),/已锁定/);
+ assert.deepEqual(await one('SELECT * FROM "LessonCompletionReport" WHERE "objectId"=$1',[report.objectId]),Object.fromEntries(Object.entries(report).filter(([k])=>k!=='revision')));
+ const {default:vm}=await import('node:vm');const context={DEFAULT_COMPANY_ID:'co',number:Number,ownLegacyId:()=>30,fail:(_c,m)=>{throw Error(m);},Psql:{oneOrNone:async(s,p)=>(await db.query(s,p)).rows[0]||null}};
+ vm.createContext(context);vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
+ const order={GeneralID:a.gid,pl:30,szyh:20,dszt:30},user={get:()=>({id:'co'})};
+ const locked=await context.lessonOrderCapabilities(order,user);assert.equal(locked.feedbackLocked,true);assert.ok(!locked.allowedActions.includes('editFeedback'));
+ await db.query('UPDATE "LessonCompletionReport" SET "feedbackStatus"=$1 WHERE "objectId"=$2',['pending',report.objectId]);
+ const pending=await context.lessonOrderCapabilities(order,user);assert.equal(pending.feedbackLocked,false);assert.ok(pending.allowedActions.includes('editFeedback'));
+ await one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,report.revision,'legacy-rating-first-submit',JSON.stringify(feedback)]);
+ assert.equal((await context.lessonOrderCapabilities(order,user)).feedbackLocked,true);
+});

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor