// Appointment reads and commands share the same new-database identity. function assertLessonTenant(current) { const company=current.get('company'); if ((company && (company.id || company.objectId)) !== DEFAULT_COMPANY_ID) fail(403, '无权访问该帐套'); } function lessonFeedbackInput(input) { const raw = parseObject(input.feedback, '完课反馈'), feedback = {}; for (const key of ['contentSummary','reviewSummary','trainingSummary','homework','notes','coachComment']) { const value = String(raw[key] || '').trim(); if (value.length > 1000) fail(400, '课堂反馈每项最多1000字'); feedback[key] = value; } feedback.reviewStatus = String(raw.reviewStatus || ''); feedback.trainingStatus = String(raw.trainingStatus || ''); feedback.noHomework = raw.noHomework === true; if (!feedback.contentSummary || !['完成','部分完成','未复习','无复习任务'].includes(feedback.reviewStatus) || !['正常进行','需要巩固','已完成','暂停'].includes(feedback.trainingStatus) || !feedback.trainingSummary || !feedback.noHomework && !feedback.homework) fail(400, '请完整填写学习内容、复习、训练进度和作业'); return feedback; } async function appLessonComplete(input, current) { assertLessonTenant(current); const id = String(input.orderId || '').trim(), key = String(input.requestKey || ''); if (!id || key.length < 10 || key.length > 160 || !input.expectedUpdatedAt || Number.isNaN(Date.parse(input.expectedUpdatedAt))) fail(400, '缺少预约编号、请求编号或详情版本,请刷新后重试'); const feedback = lessonFeedbackInput(input); try { return (await Psql.one('SELECT xs_lesson_complete($1,$2,$3,$4,$5::timestamptz,$6,$7::jsonb) AS result', [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),input.expectedUpdatedAt,key,JSON.stringify(feedback)])).result; } catch (error) { const message = String(error.message || error); if (message.includes('历史凭据')) await Psql.none('INSERT INTO "LessonWorkflowIssue" (company,"appointmentGeneralId",message) VALUES($1,$2,$3) ON CONFLICT(company,"appointmentGeneralId") DO UPDATE SET message=EXCLUDED.message,"updatedAt"=now()', [DEFAULT_COMPANY_ID,id,message]); fail(409, message); } } async function appLessonReview(input, current) { assertLessonTenant(current); const score = Number(input.score), comment = String(input.comment || '').trim(), id = String(input.orderId || ''); if (!id || !Number.isInteger(score) || score < 1 || score > 5 || comment.length > 200) fail(400, '评分应为1至5分,评价最多200字'); try { return (await Psql.one('SELECT xs_lesson_review($1,$2,$3,$4,$5,$6) AS result', [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),score,comment])).result; } catch (error) { fail(409, String(error.message || error)); } } async function lessonOrderCapabilities(order, current) { assertLessonTenant(current); const id = String(order.GeneralID), uid = ownLegacyId(current), status = number(order.dszt); const state = await Psql.oneOrNone('SELECT a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" 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', [DEFAULT_COMPANY_ID,id]); if (!state) fail(404, '预约不存在'); 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 teacher = uid === number(order.pl), student = uid === number(order.szyh), actions = []; let blockedReason = ''; if (order.cancelled) blockedReason = '预约已取消'; else if (teacher) { if (status === 0 && !state.validToday) blockedReason = '课程仅在预约当天可开始,其他日期请联系管理员改期'; else if (status === 0 && (!state.bindingId || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = '开课记录或课时凭据尚未核实,请联系管理员'; else if (status === 0) actions.push('start'); if (status === 10) actions.push('continue','complete'); if ([11,20].includes(status)) actions.push('complete'); } 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)}; } async function databaseAppointmentVersions(input,current) { assertLessonTenant(current); const coach = number(current.get('legacyGroupId')) === 3; const uid = ownLegacyId(current), selected = coach && number(input.studentId) ? await appLearningTarget(current,input.studentId) : 0; const row=await Psql.one('SELECT MAX(GREATEST(a."updatedAt",c."updatedAt"))::text AS changed_at,COUNT(*)::int AS total FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE a.company=$1 AND a."'+(coach?'pl':'szyh')+'"::text=$2 AND ($3::numeric=0 OR a.szyh::text=$3::text)',[DEFAULT_COMPANY_ID,String(uid),selected]); const value=String(row.changed_at||'')+':'+row.total; return {version:'appointments='+value,scopeVersions:{appointments:value},refreshedAt:new Date().toISOString(),syncStatus:{state:'current'},source:'database'}; }