// Explicit teacher acknowledgement of an entire unit. This is progress only, // never a classroom study batch, review plan, credit deduction or pay event. async function completeLessonUnit(input,current) { assertLessonTenant(current); const gid=String(input.orderId||''),bindingId=String(input.bindingId||''),nodeId=String(input.nodeId||''); if(number(current.get('legacyGroupId'))!==3)fail(403,'仅预约老师可以标记学完'); if(!gid||!bindingId||!/^\d+$/.test(nodeId))fail(400,'缺少预约、开课或单元'); const key=mobileCommandKey(input);if(!key)fail(400,'缺少操作标识,请刷新重试'); try{return await Psql.tx(async t=>{ await t.one('SELECT pg_advisory_xact_lock(hashtext($1))',['xs-credit:'+DEFAULT_COMPANY_ID]); const a=await t.oneOrNone('SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."generalId"::text=$2 AND c.status<>-2 FOR UPDATE OF a',[DEFAULT_COMPANY_ID,gid]); if(!a||number(a.pl)!==ownLegacyId(current))fail(403,'仅预约老师可以标记该学员进度'); if(![0,10].includes(number(a.dszt)))fail(409,'预约已结束,请刷新详情'); const b=await t.oneOrNone('SELECT b.* FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id JOIN "Node" n ON n.company=b.company AND n."nodeId"::text=b.kcid WHERE b.company=$1 AND b."objectId"=$2 AND b.yhid=$3 AND b."enrollmentStatus"=\'active\' AND c.status=99 AND COALESCE(n.zstatus,99)=99 FOR UPDATE OF b',[DEFAULT_COMPANY_ID,bindingId,String(a.szyh)]); if(!b)fail(403,'该课程尚未分配给当前学员'); const nodes=await t.any('SELECT "nodeId"::text id,"nodeName" name FROM "Node" WHERE company=$1 AND "parentId"::text=$2',[DEFAULT_COMPANY_ID,String(b.kcid)]); const unit=(nodes.length?nodes:courseAssignedUnits(b.syjd)).find(n=>n.id===nodeId); if(!unit)fail(403,'单元不属于所选课程'); const words=await t.any('SELECT "generalId" FROM "CommonModel" WHERE company=$1 AND "modelId"=52 AND "nodeId"::text=$2 ORDER BY "generalId"',[DEFAULT_COMPANY_ID,nodeId]); if(!words.length)fail(409,'该单元暂无单词'); if(words.length>10000)fail(409,'单元词量异常,请联系管理员'); // Same learner lock as e_add_words: do not overwrite a concurrent practice count. await t.one('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',[DEFAULT_COMPANY_ID+':'+a.szyh]); const rows=await t.any('SELECT scid,xxcs FROM "PracticeRecord" WHERE company=$1 AND yhid::text=$2 AND scid::text=ANY($3::text[])',[DEFAULT_COMPANY_ID,String(a.szyh),words.map(w=>String(w.generalId))]); const learned=new Set(rows.filter(r=>number(r.xxcs)>0).map(r=>String(r.scid))); const missing=words.filter(w=>!learned.has(String(w.generalId))); for(let offset=0;offset({table:'PracticeRecord',match:{company:DEFAULT_COMPANY_ID,yhid:number(a.szyh),scid:number(w.generalId)},data:{objectId:randomObjectId(),createdAt:new Date().toISOString(),company:DEFAULT_COMPANY_ID,sourceKey:'teacher:unit:'+a.szyh+':'+w.generalId,yhid:number(a.szyh),scid:number(w.generalId),kcid:String(b.kcid),xxcs:1}})); await t.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) result',[DEFAULT_COMPANY_ID,String(a.szyh),current.id+':unit:'+key+':'+offset,JSON.stringify({gid,bindingId,nodeId,words:operations.map(o=>o.data.scid)}),JSON.stringify(operations)]); } let progress;try{progress=JSON.parse(b.syjd||'{}');}catch(_){progress={};} if(!progress||typeof progress!=='object'||Array.isArray(progress))progress={}; progress[nodeId]=nodeId+'@'+unit.name+'@1'; await t.none('UPDATE "CourseBinding" SET syjd=$3,"updatedAt"=now() WHERE company=$1 AND "objectId"=$2',[DEFAULT_COMPANY_ID,bindingId,JSON.stringify(progress)]); // Stable audit key makes repeated clicks visible without manufacturing learning events. await t.none('INSERT INTO "MobileTeachingCommand" (company,"actorId","requestKey",fingerprint,result) VALUES($1,$2,$3,$4,$5::jsonb) ON CONFLICT DO NOTHING',[DEFAULT_COMPANY_ID,current.id,'unit-complete:'+key,JSON.stringify({gid,bindingId,nodeId}),JSON.stringify({orderId:gid,bindingId,nodeId,teacherId:ownLegacyId(current),studentId:number(a.szyh),total:words.length,changed:missing.length})]); return {nodeId,total:words.length,learned:words.length,percent:100,syjd:JSON.stringify(progress)}; });}catch(e){if(e.status||e.statusCode)throw e;fail(409,String(e.message||e));} }