lesson-workflow.sql 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. -- Install after member-enrollment.sql and mobile-teaching.sql. No historical backfill.
  2. CREATE TABLE IF NOT EXISTS "LessonWorkflowCommand" (
  3. company text NOT NULL, actor text NOT NULL, "requestKey" text NOT NULL,
  4. fingerprint jsonb NOT NULL, result jsonb NOT NULL, "createdAt" timestamptz DEFAULT now(),
  5. PRIMARY KEY(company,actor,"requestKey")
  6. );
  7. CREATE TABLE IF NOT EXISTS "LessonReview" (
  8. company text NOT NULL, "appointmentGeneralId" text NOT NULL, "studentId" numeric NOT NULL,
  9. score integer NOT NULL CHECK(score BETWEEN 1 AND 5), comment text NOT NULL CHECK(length(comment)<=200),
  10. "submittedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"appointmentGeneralId")
  11. );
  12. CREATE TABLE IF NOT EXISTS "LessonWorkflowIssue" (
  13. company text NOT NULL, "appointmentGeneralId" text NOT NULL, message text NOT NULL,
  14. "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"appointmentGeneralId")
  15. );
  16. ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
  17. ALTER TABLE "CommonModel" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
  18. ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
  19. ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "coachComment" text;
  20. ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS attachments jsonb NOT NULL DEFAULT '[]'::jsonb;
  21. CREATE TABLE IF NOT EXISTS "LessonFeedbackAttachment" (
  22. "objectId" text PRIMARY KEY, company text NOT NULL, "appointmentGeneralId" text NOT NULL,
  23. "coachId" numeric NOT NULL, "fileName" text NOT NULL, "publicUrl" text NOT NULL,
  24. "contentType" text NOT NULL, size numeric NOT NULL, "createdAt" timestamptz DEFAULT now(), "updatedAt" timestamptz
  25. );
  26. CREATE INDEX IF NOT EXISTS lesson_feedback_attachment_owner ON "LessonFeedbackAttachment" (company,"appointmentGeneralId","coachId");
  27. -- Existing reports were submitted; new feedback-free completions explicitly use pending.
  28. ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "feedbackStatus" text NOT NULL DEFAULT 'submitted';
  29. UPDATE "LessonCompletionReport" SET "feedbackStatus"='submitted' WHERE "feedbackStatus" IS NULL;
  30. ALTER TABLE "LessonCompletionReport" ALTER COLUMN "feedbackStatus" SET DEFAULT 'submitted';
  31. ALTER TABLE "LessonCompletionReport" ALTER COLUMN "feedbackStatus" SET NOT NULL;
  32. ALTER TABLE "LessonCompletionReport" DROP CONSTRAINT IF EXISTS lesson_feedback_status;
  33. ALTER TABLE "LessonCompletionReport" ADD CONSTRAINT lesson_feedback_status CHECK ("feedbackStatus" IN ('pending','submitted'));
  34. CREATE OR REPLACE FUNCTION xs_lesson_complete(co text,gid text,actor text,actor_uid numeric,expected timestamptz,request_key text,feedback jsonb)
  35. RETURNS jsonb LANGUAGE plpgsql AS $$
  36. DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "LessonCompletionReport";
  37. previous "LessonWorkflowCommand"; result jsonb; lesson_count int; cost jsonb; ledger_table text; evidence int;
  38. study record; study_words jsonb; study_course text; found_study boolean=false; words jsonb='[]'; review_date text; review_key text; review_id numeric; review_object text;
  39. total_reviews int; completed_reviews int; report_id text; was_charged boolean=false;
  40. BEGIN
  41. IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
  42. IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
  43. IF feedback IS NOT NULL AND (NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR length(feedback->>'contentSummary')>1000
  44. OR length(COALESCE(feedback->>'reviewStatus',''))>200
  45. OR length(COALESCE(feedback->>'trainingStatus',''))>200
  46. OR NULLIF(trim(feedback->>'trainingSummary'),'') IS NULL OR length(feedback->>'trainingSummary')>1000
  47. OR (NOT COALESCE((feedback->>'noHomework')::boolean,false) AND NULLIF(trim(feedback->>'homework'),'') IS NULL)
  48. ) THEN RAISE EXCEPTION '请完整填写学习内容、进度说明和作业';END IF;
  49. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  50. SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "generalId"::text=gid;
  51. SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId";
  52. IF a."objectId" IS NULL OR c.status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
  53. IF a.pl::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约老师可以结课';END IF;
  54. -- Same lock order as credit transfers and xs_complete_appointment.
  55. PERFORM 1 FROM "_User" WHERE company=co AND "legacyUserId"::text=a.szyh::text FOR UPDATE;
  56. SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
  57. SELECT * INTO previous FROM "LessonWorkflowCommand" WHERE company=co AND "LessonWorkflowCommand".actor=xs_lesson_complete.actor AND "requestKey"=request_key;
  58. IF FOUND THEN
  59. IF previous.fingerprint<>jsonb_build_object('id',gid,'feedback',feedback) THEN RAISE EXCEPTION '请求编号已用于其他内容';END IF;
  60. RETURN previous.result;
  61. END IF;
  62. IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)>1 THEN RAISE EXCEPTION '历史凭据存在重复报告,请由后台核对';END IF;
  63. SELECT * INTO report FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid ORDER BY "submittedAt" DESC LIMIT 1;
  64. IF a.dszt='30' AND report."objectId" IS NOT NULL THEN RETURN jsonb_build_object('GeneralID',gid,'status',30,'unchanged',true,'feedbackId',report."objectId",'feedbackStatus',report."feedbackStatus",'teacherPay',(SELECT COALESCE(lr."teacherPaySnapshot",a."teacherPaySnapshot") FROM "LessonRecord" lr WHERE lr.company=co AND lr."objectId"=report."lessonId"),'payStatus',CASE WHEN (SELECT COALESCE(lr."teacherPaySnapshot",a."teacherPaySnapshot") FROM "LessonRecord" lr WHERE lr.company=co AND lr."objectId"=report."lessonId") IS NULL THEN 'pending' ELSE 'earned' END);END IF;
  65. IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
  66. IF a.dszt NOT IN ('10','11','20') THEN RAISE EXCEPTION '请先开始课程';END IF;
  67. -- Versioned batches and completion serialize on the same xs-credit lock.
  68. IF to_regclass('"LessonStudyBatch"') IS NOT NULL THEN
  69. IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='draft') THEN RAISE EXCEPTION '还有未确认的学习批次,请先确认掌握状态或放弃该批次'; END IF;
  70. IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='confirmed') OR EXISTS(SELECT 1 FROM "DailyStudyRecord" WHERE company=co AND dsid::text=gid AND "userId"::text=a.szyh::text) THEN
  71. PERFORM xs_lesson_study_rebuild(co,gid);
  72. IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='draft') THEN RAISE EXCEPTION '历史学习批次尚未确认,请先确认掌握状态'; END IF;
  73. UPDATE "DailyStudyRecord" SET fxrl=CASE WHEN COALESCE(learned,0)>0 THEN (SELECT string_agg(to_char((now() AT TIME ZONE 'Asia/Shanghai')::date+offset_days,'YYYYMMDD'),',' ORDER BY offset_days) FROM unnest(ARRAY[1,2,3,5,7,9,11,14,17,21]) offset_days) ELSE '' END WHERE company=co AND dsid::text=gid AND "recordScope"='lesson';
  74. END IF;
  75. END IF;
  76. SELECT count(*) INTO lesson_count FROM "LessonRecord" WHERE company=co AND yyds::text=gid;
  77. IF lesson_count>1 THEN RAISE EXCEPTION '历史凭据存在重复上课记录,请由后台核对';END IF;
  78. SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid LIMIT 1;
  79. IF l."objectId" IS NOT NULL THEN words=COALESCE(NULLIF(l.kzsj,'')::jsonb,'[]');END IF;
  80. IF l."objectId" IS NOT NULL OR a.dszt IN ('11','20') THEN
  81. IF l."objectId" IS NULL OR l.xymz::text IS DISTINCT FROM a.szyh::text OR l.jsmz::text IS DISTINCT FROM a.pl::text
  82. OR NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE company=co AND "modelId"=59 AND "itemId"=l.id AND status=99)
  83. OR jsonb_array_length(COALESCE(a."creditCostsSnapshot"::jsonb,'[]'))=0
  84. OR (SELECT count(*) FROM "CourseCreditReservation" WHERE company=co AND "appointmentId"=c."objectId" AND state='consumed' AND "creditCosts"::jsonb=a."creditCostsSnapshot"::jsonb)<>1
  85. THEN RAISE EXCEPTION '历史凭据不完整,请由后台核对课时,不能自动补扣';END IF;
  86. FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
  87. ledger_table=CASE cost->>'account' WHEN 'Purse' THEN 'UserExpDomP' WHEN 'SilverCoin' THEN 'UserSIcon' WHEN 'UserExp' THEN 'UserExpHis' WHEN 'UserPoint' THEN 'UserUserPoint' END;
  88. IF ledger_table IS NULL THEN RAISE EXCEPTION '历史凭据的账户无效';END IF;
  89. EXECUTE format('SELECT count(*) FROM %I WHERE company=$1 AND "userId"::text=$2 AND "sourceKey"=$3 AND score=$4',ledger_table)
  90. INTO evidence USING co,a.szyh::text,'credit:complete:'||c."objectId"||':'||a.szyh||':'||(cost->>'account'),-(cost->>'amount')::numeric;
  91. IF evidence<>1 THEN RAISE EXCEPTION '历史凭据与课时扣减不一致,请由后台核对';END IF;
  92. END LOOP;
  93. ELSE
  94. -- A fresh completion must consume exactly the reservation created by scheduling.
  95. IF (SELECT count(*) FROM "CourseCreditReservation" WHERE company=co AND "appointmentId"=c."objectId" AND state='reserved' AND "creditCosts"::jsonb=a."creditCostsSnapshot"::jsonb)<>1
  96. THEN RAISE EXCEPTION '历史凭据缺少匹配的课时预留,请由后台核对';END IF;
  97. result=xs_complete_appointment(co,c."objectId",actor);
  98. was_charged=COALESCE((result->>'periodDeducted')::boolean,false);
  99. SELECT * INTO l FROM "LessonRecord" WHERE company=co AND "objectId"=result->>'lessonId';
  100. END IF;
  101. FOR study IN SELECT d.*,sc."generalId" AS study_general_id,b.kcid AS study_course_id FROM "DailyStudyRecord" d
  102. JOIN "CommonModel" sc ON sc.company=d.company AND sc."modelId"=56 AND sc."itemId"=d.id AND sc.status=99
  103. LEFT JOIN "CourseBinding" b ON b.company=d.company AND b."objectId"=to_jsonb(d)->>'bindingId' AND b.yhid::text=a.szyh::text
  104. WHERE d.company=co AND d."userId"::text=a.szyh::text AND d.dsid::text=gid AND COALESCE(to_jsonb(d)->>'recordScope','')<>'lesson-alias' ORDER BY d."updatedAt" DESC,d.id DESC LOOP
  105. IF NOT found_study THEN words='[]';found_study=true;END IF;
  106. study_words=COALESCE(NULLIF(study.xxqs,'')::jsonb,'[]');
  107. IF jsonb_typeof(study_words)<>'array' THEN RAISE EXCEPTION '课堂学习记录格式异常,请核对后重试';END IF;
  108. words=words||study_words;study_course=COALESCE(study.study_course_id,a.kcid);
  109. IF jsonb_array_length(study_words)>0 THEN
  110. FOR review_date IN SELECT DISTINCT value FROM unnest(string_to_array(COALESCE(study.fxrl,''),',')) value WHERE value ~ '^\d{8}$' LIMIT 15 LOOP
  111. review_key='cloud:e_order_update:'||gid||CASE WHEN NULLIF(to_jsonb(study)->>'bindingId','') IS NOT NULL AND (to_jsonb(study)->>'bindingId') IS DISTINCT FROM a."bindingId" THEN ':study:'||study.study_general_id ELSE '' END||':review:'||review_date;
  112. SELECT id,"objectId" INTO review_id,review_object FROM "MemoryPracticeRecord" WHERE company=co AND "sourceKey"=review_key LIMIT 1;
  113. IF review_object IS NULL THEN
  114. review_id=nextval('xiaoshu_business_id');review_object=xs_object_id();
  115. INSERT INTO "MemoryPracticeRecord" SELECT * FROM jsonb_populate_record(NULL::"MemoryPracticeRecord",jsonb_build_object(
  116. 'objectId',review_object,'company',co,'id',review_id,'createdAt',now(),'updatedAt',now(),'sourceKey',review_key,
  117. 'yhid',a.szyh,'plid',COALESCE(NULLIF(a.fxpl,''),a.pl),'orderId',gid,'xxjlid',study.study_general_id,'kcid',study_course,
  118. 'fxzt','0','kywrq',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD'),'kywsj',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD')||' '||COALESCE(a.sdsd,'00:00')));
  119. END IF;
  120. IF NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE company=co AND "sourceKey"=review_key) THEN
  121. PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey',review_key,'generalId',nextval('xiaoshu_business_id'),'itemId',review_id,'modelId',60,'nodeId',388,'tableName','ZL_C_gywjl','title',c.title||'@抗遗忘@'||review_date,'inputer',actor,'status',99));
  122. END IF;
  123. END LOOP;
  124. END IF;
  125. END LOOP;
  126. -- Combine all selected books for the lesson report without counting a word twice.
  127. SELECT COALESCE(jsonb_agg(value ORDER BY ord),'[]'::jsonb) INTO words FROM (
  128. SELECT DISTINCT ON (COALESCE(value->>'GeneralID',value::text)) value,ord
  129. FROM jsonb_array_elements(words) WITH ORDINALITY w(value,ord)
  130. ORDER BY COALESCE(value->>'GeneralID',value::text),ord
  131. ) unique_words;
  132. UPDATE "LessonRecord" SET kzsj=words::text,pldp=COALESCE(NULLIF(feedback->>'coachComment',''),NULLIF(feedback->>'notes',''),pldp),plpjsj=now()::text,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
  133. UPDATE "CourseAppointment" SET dszt='30',jssj=COALESCE(NULLIF(jssj,''),now()::text),"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
  134. UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND ("objectId"=c."objectId" OR ("modelId"=59 AND "itemId"=l.id));
  135. SELECT count(*),count(*) FILTER(WHERE fxzt='1') INTO total_reviews,completed_reviews FROM "MemoryPracticeRecord" WHERE company=co AND "orderId"::text=gid;
  136. report_id=COALESCE(report."objectId",xs_object_id());
  137. -- Existing partial reports are replaced in the same transaction, preserving their ID.
  138. DELETE FROM "LessonCompletionReport" WHERE "objectId"=report_id AND company=co;
  139. INSERT INTO "LessonCompletionReport" SELECT * FROM jsonb_populate_record(NULL::"LessonCompletionReport",COALESCE(feedback,'{}'::jsonb)||jsonb_build_object(
  140. 'objectId',report_id,'company',co,'createdAt',COALESCE(report."createdAt",now()),'updatedAt',now(),'sourceKey','cloud:lesson-feedback:'||co||':'||gid,
  141. 'appointmentId',a."objectId",'appointmentGeneralId',gid,'lessonId',l."objectId",'studentId',a.szyh,'coachId',a.pl,
  142. 'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
  143. 'actualStartAt',NULLIF(a.kssj,'')::timestamptz,'actualEndAt',COALESCE(NULLIF(a.jssj,'')::timestamptz,now()),'creditCosts',a."creditCostsSnapshot",
  144. 'vocabularyCount',jsonb_array_length(words),'antiForgetSummary',jsonb_build_object('total',total_reviews,'completed',completed_reviews,'pending',total_reviews-completed_reviews),'attachments',COALESCE(feedback->'attachments','[]'::jsonb),
  145. 'homework',CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,'feedbackStatus',CASE WHEN feedback IS NULL THEN 'pending' ELSE 'submitted' END,'submittedBy',CASE WHEN feedback IS NULL THEN NULL ELSE actor END,'submittedAt',CASE WHEN feedback IS NULL THEN NULL ELSE now() END));
  146. DELETE FROM "LessonWorkflowIssue" WHERE company=co AND "appointmentGeneralId"=gid;
  147. result=jsonb_build_object('GeneralID',gid,'status',30,'lessonId',l."objectId",'feedbackId',report_id,'periodDeducted',was_charged,'feedbackStatus',CASE WHEN feedback IS NULL THEN 'pending' ELSE 'submitted' END,'teacherPay',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),'payStatus',CASE WHEN COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot") IS NULL THEN 'pending' ELSE 'earned' END);
  148. INSERT INTO "LessonWorkflowCommand" VALUES(co,actor,request_key,jsonb_build_object('id',gid,'feedback',feedback),result,now());
  149. RETURN result;
  150. END $$;
  151. REVOKE ALL ON FUNCTION xs_lesson_complete(text,text,text,numeric,timestamptz,text,jsonb) FROM PUBLIC;
  152. CREATE OR REPLACE FUNCTION xs_lesson_review(co text,gid text,actor text,actor_uid numeric,rating integer,review_comment text)
  153. RETURNS jsonb LANGUAGE plpgsql AS $$
  154. DECLARE a record; l record; existing "LessonReview";
  155. BEGIN
  156. 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;
  157. 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;
  158. -- Serialize student evaluation with teacher amendments using the same lock order.
  159. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  160. 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;
  161. IF NOT FOUND OR a.common_status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
  162. IF a.szyh::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约学生可以评价';END IF;
  163. IF a.dszt NOT IN ('30','40') THEN RAISE EXCEPTION '课程尚未结课';END IF;
  164. SELECT * INTO existing FROM "LessonReview" WHERE company=co AND "appointmentGeneralId"=gid;
  165. IF FOUND THEN RETURN to_jsonb(existing)||jsonb_build_object('unchanged',true,'status',a.dszt::int);END IF;
  166. IF (SELECT count(*) FROM "LessonRecord" WHERE company=co AND yyds::text=gid)<>1 THEN RAISE EXCEPTION '上课记录缺失或重复,请由后台核对';END IF;
  167. SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
  168. IF COALESCE(l.pf::text,'') ~ '^[1-5](\.0+)?$' THEN RETURN jsonb_build_object('score',l.pf,'comment',l.pjnr,'unchanged',true,'status',a.dszt::int);END IF;
  169. INSERT INTO "LessonReview" VALUES(co,gid,actor_uid,rating,review_comment,now()) RETURNING * INTO existing;
  170. UPDATE "LessonRecord" SET pf=rating,pjnr=review_comment,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
  171. UPDATE "CourseAppointment" SET "teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
  172. UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND (("modelId"=54 AND "itemId"=a.id) OR ("modelId"=59 AND "itemId"=l.id));
  173. RETURN to_jsonb(existing)||jsonb_build_object('status',a.dszt::int);
  174. END $$;
  175. REVOKE ALL ON FUNCTION xs_lesson_review(text,text,text,numeric,integer,text) FROM PUBLIC;
  176. -- Recheck assignment under the same lock as scheduling; the preceding HTTP read is not authority.
  177. CREATE OR REPLACE FUNCTION xs_start_appointment_for_actor(co text,aid text,actor text,actor_uid numeric)
  178. RETURNS void LANGUAGE plpgsql AS $$
  179. DECLARE assigned text;
  180. BEGIN
  181. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  182. SELECT a.pl INTO assigned FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=co AND c."objectId"=aid;
  183. IF assigned IS DISTINCT FROM actor_uid::text OR NOT EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=actor AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '仅预约老师可以开始课程';END IF;
  184. PERFORM xs_start_appointment(co,aid);
  185. END $$;
  186. REVOKE ALL ON FUNCTION xs_start_appointment_for_actor(text,text,text,numeric) FROM PUBLIC;
  187. -- Protect completed lessons even if a legacy sync read races with the teacher command.
  188. CREATE OR REPLACE FUNCTION xs_lesson_no_regression() RETURNS trigger LANGUAGE plpgsql AS $$
  189. BEGIN
  190. IF OLD."teachingManaged" IS TRUE AND (
  191. (OLD.dszt IN ('30','40') AND NEW.dszt IS DISTINCT FROM OLD.dszt AND NOT (OLD.dszt='30' AND NEW.dszt='40'))
  192. OR (OLD.dszt='10' AND NEW.dszt NOT IN ('10','11','20','30','40'))
  193. OR NEW."teachingManaged" IS DISTINCT FROM true)
  194. THEN RAISE EXCEPTION '新系统课次不可由旧流程回退';END IF;
  195. RETURN NEW;
  196. END $$;
  197. DROP TRIGGER IF EXISTS lesson_no_regression ON "CourseAppointment";
  198. CREATE TRIGGER lesson_no_regression BEFORE UPDATE ON "CourseAppointment" FOR EACH ROW EXECUTE FUNCTION xs_lesson_no_regression();
  199. -- Administrators retain the existing correction operation with a required, transactional audit reason.
  200. CREATE OR REPLACE FUNCTION xs_admin_lesson_correction(co text,aid text,actor text,reason text)
  201. RETURNS jsonb LANGUAGE plpgsql AS $$
  202. DECLARE result jsonb;
  203. BEGIN
  204. IF NULLIF(trim(reason),'') IS NULL THEN RAISE EXCEPTION '请填写纠错原因';END IF;
  205. result=xs_complete_appointment(co,aid,actor);
  206. PERFORM xs_insert('EnrollmentCommand',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',xs_object_id(),'request',jsonb_build_object('actor',actor,'reason',reason,'appointmentId',aid,'operation','admin.lesson-correction'),'result',result));
  207. RETURN result;
  208. END $$;
  209. REVOKE ALL ON FUNCTION xs_admin_lesson_correction(text,text,text,text) FROM PUBLIC;
  210. -- Feedback-only amendment: never re-run completion, credit or learning projections.
  211. CREATE OR REPLACE FUNCTION xs_lesson_feedback_update(co text,gid text,actor text,actor_uid numeric,expected timestamptz,request_key text,feedback jsonb)
  212. RETURNS jsonb LANGUAGE plpgsql AS $$
  213. DECLARE a "CourseAppointment"; c "CommonModel"; report "LessonCompletionReport"; lesson "LessonRecord";
  214. previous "LessonWorkflowCommand"; result jsonb; old_feedback jsonb;
  215. BEGIN
  216. IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
  217. IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
  218. IF NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR NULLIF(trim(feedback->>'trainingSummary'),'') IS NULL
  219. OR (NOT COALESCE((feedback->>'noHomework')::boolean,false) AND NULLIF(trim(feedback->>'homework'),'') IS NULL)
  220. OR EXISTS(SELECT 1 FROM jsonb_each_text(feedback) f WHERE f.key<>'attachments' AND length(f.value)>CASE WHEN f.key IN ('reviewStatus','trainingStatus') THEN 200 ELSE 1000 END)
  221. THEN RAISE EXCEPTION '请完整填写学习内容、进度说明和作业,并检查文字长度';END IF;
  222. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  223. SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "generalId"::text=gid FOR UPDATE;
  224. SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId" FOR UPDATE;
  225. IF a."objectId" IS NULL OR c.status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
  226. IF a.pl::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约老师可以修改报告';END IF;
  227. IF a.dszt NOT IN ('30','40') THEN RAISE EXCEPTION '请先完成课程再修改报告';END IF;
  228. SELECT * INTO previous FROM "LessonWorkflowCommand" WHERE company=co AND "LessonWorkflowCommand".actor=xs_lesson_feedback_update.actor AND "requestKey"=request_key;
  229. IF FOUND THEN
  230. IF previous.fingerprint<>jsonb_build_object('action','feedback_update','id',gid,'feedback',feedback) THEN RAISE EXCEPTION '请求编号已用于其他内容';END IF;
  231. RETURN previous.result - 'previousFeedback';
  232. END IF;
  233. IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)>1 THEN RAISE EXCEPTION '报告不存在或不唯一,请联系管理员核对';END IF;
  234. SELECT * INTO report FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid FOR UPDATE;
  235. -- Lock the legacy lesson too, so old-client ratings cannot race the decision.
  236. PERFORM 1 FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
  237. IF report."objectId" IS NOT NULL AND report."feedbackStatus" IS DISTINCT FROM 'pending' AND (
  238. EXISTS(SELECT 1 FROM "LessonReview" WHERE company=co AND "appointmentGeneralId"=gid)
  239. OR EXISTS(SELECT 1 FROM "LessonRecord" WHERE company=co AND yyds::text=gid AND COALESCE(pf::text,'') ~ '^[1-5](\.0+)?$')
  240. ) THEN RAISE EXCEPTION '学生已评价,课堂反馈已锁定,不能再修改';END IF;
  241. -- Old completed lessons may have no report. First submission uses the appointment
  242. -- revision from the verified detail; later edits use the report revision.
  243. IF report."objectId" IS NULL THEN
  244. IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
  245. IF (SELECT count(*) FROM "LessonRecord" WHERE company=co AND yyds::text=gid)<>1 THEN RAISE EXCEPTION '历史上课记录缺失或不唯一,请联系管理员核对';END IF;
  246. SELECT * INTO lesson FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
  247. IF lesson.xymz::text IS DISTINCT FROM a.szyh::text OR lesson.jsmz::text IS DISTINCT FROM a.pl::text THEN RAISE EXCEPTION '历史上课记录归属不一致,请联系管理员核对';END IF;
  248. INSERT INTO "LessonCompletionReport" SELECT * FROM jsonb_populate_record(NULL::"LessonCompletionReport",jsonb_build_object(
  249. 'objectId',xs_object_id(),'company',co,'createdAt',clock_timestamp(),'updatedAt',clock_timestamp(),
  250. 'sourceKey','cloud:lesson-feedback:'||co||':'||gid,'appointmentId',a."objectId",'appointmentGeneralId',gid,
  251. 'lessonId',lesson."objectId",'studentId',a.szyh,'coachId',a.pl,'courseCategoryKey',a."courseCategoryKey",
  252. 'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
  253. 'creditCosts',a."creditCostsSnapshot",'feedbackStatus','pending','attachments','[]'::jsonb)) RETURNING * INTO report;
  254. ELSE
  255. IF expected IS NULL OR report."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '报告已被更新,请关闭编辑并刷新详情后重试';END IF;
  256. END IF;
  257. SELECT jsonb_object_agg(key,value) INTO old_feedback FROM jsonb_each(to_jsonb(report)) WHERE key IN ('contentSummary','reviewStatus','reviewSummary','trainingStatus','trainingSummary','homework','noHomework','coachComment','notes','attachments');
  258. UPDATE "LessonCompletionReport" SET
  259. "contentSummary"=feedback->>'contentSummary',"reviewStatus"=feedback->>'reviewStatus',"reviewSummary"=feedback->>'reviewSummary',
  260. "trainingStatus"=feedback->>'trainingStatus',"trainingSummary"=feedback->>'trainingSummary',
  261. attachments=CASE WHEN feedback?'attachments' THEN feedback->'attachments' ELSE COALESCE(report.attachments,'[]'::jsonb) END,
  262. homework=CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,
  263. "feedbackStatus"='submitted',"submittedBy"=actor,"submittedAt"=COALESCE(report."submittedAt",clock_timestamp()),"noHomework"=COALESCE((feedback->>'noHomework')::boolean,false),"coachComment"=feedback->>'coachComment',notes=feedback->>'notes',"updatedAt"=clock_timestamp()
  264. WHERE "objectId"=report."objectId";
  265. UPDATE "LessonRecord" SET pldp=COALESCE(feedback->>'coachComment',''),plpjsj=now()::text,"updatedAt"=now()
  266. WHERE company=co AND "objectId"=report."lessonId" AND yyds::text=gid;
  267. UPDATE "CourseAppointment" SET "updatedAt"=now() WHERE "objectId"=a."objectId";
  268. UPDATE "CommonModel" SET "updatedAt"=now() WHERE "objectId"=c."objectId";
  269. result=jsonb_build_object('GeneralID',gid,'feedbackId',report."objectId",'saved',true,'previousFeedback',old_feedback);
  270. INSERT INTO "LessonWorkflowCommand" VALUES(co,actor,request_key,jsonb_build_object('action','feedback_update','id',gid,'feedback',feedback),result,now());
  271. RETURN result - 'previousFeedback';
  272. END $$;
  273. REVOKE ALL ON FUNCTION xs_lesson_feedback_update(text,text,text,numeric,timestamptz,text,jsonb) FROM PUBLIC;