member-enrollment.sql 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. -- New database is authoritative for member accounts, credit transfers and enrollments.
  2. -- Run the read-only migration report and back up the affected tables before installation.
  3. CREATE SEQUENCE IF NOT EXISTS xiaoshu_business_id START 8000000000000;
  4. DO $$ BEGIN
  5. PERFORM setval('xiaoshu_business_id',GREATEST((SELECT last_value FROM xiaoshu_business_id),COALESCE((SELECT max("generalId") FROM "CommonModel"),0),COALESCE((SELECT max("id") FROM "CourseBinding"),0),COALESCE((SELECT max("id") FROM "CourseAppointment"),0),COALESCE((SELECT max("id") FROM "LessonRecord"),0))::bigint);
  6. END $$;
  7. CREATE TABLE IF NOT EXISTS "CreditTransaction" (
  8. "objectId" text PRIMARY KEY,"company" text NOT NULL,"createdAt" timestamptz,"updatedAt" timestamptz,
  9. "idempotencyKey" text NOT NULL,"request" jsonb NOT NULL,"kind" text NOT NULL,"account" text NOT NULL,
  10. "amount" numeric NOT NULL,"storeId" numeric,"studentId" numeric,"sourceTransactionId" text,
  11. "operatorId" text,"reason" text,"payMethod" text,"balances" jsonb
  12. );
  13. CREATE UNIQUE INDEX IF NOT EXISTS credit_transaction_request ON "CreditTransaction" ("company","idempotencyKey");
  14. CREATE TABLE IF NOT EXISTS "EnrollmentCommand" (
  15. "objectId" text PRIMARY KEY,"company" text NOT NULL,"createdAt" timestamptz,"updatedAt" timestamptz,
  16. "idempotencyKey" text NOT NULL,"request" jsonb,"result" jsonb
  17. );
  18. CREATE UNIQUE INDEX IF NOT EXISTS enrollment_command_request ON "EnrollmentCommand" ("company","idempotencyKey");
  19. ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "categoryKey" text;
  20. ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "categoryName" text;
  21. ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "enrollmentStatus" text;
  22. ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "bindingId" text;
  23. ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "bindingId" text;
  24. ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "courseCategoryKey" text;
  25. ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "courseCategoryName" text;
  26. ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "teacherPaySnapshot" numeric;
  27. ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "creditCostsSnapshot" jsonb;
  28. ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "bindingId" text;
  29. DO $$ DECLARE t text; BEGIN
  30. FOREACH t IN ARRAY ARRAY['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'] LOOP
  31. EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS "transactionId" text',t);
  32. END LOOP;
  33. END $$;
  34. CREATE OR REPLACE FUNCTION xs_object_id() RETURNS text LANGUAGE sql VOLATILE AS $$
  35. SELECT substr(md5(random()::text || clock_timestamp()::text),1,20)
  36. $$;
  37. CREATE OR REPLACE FUNCTION xs_insert(t text, d jsonb) RETURNS void LANGUAGE plpgsql AS $$
  38. BEGIN
  39. IF NOT t=ANY(ARRAY['CreditTransaction','EnrollmentCommand','CourseBinding','CommonModel','LessonRecord','CourseAppointment','CourseCreditReservation','UserExpDomP','UserSIcon','UserExpHis','UserUserPoint','SysLog']) THEN RAISE EXCEPTION 'Invalid business table'; END IF;
  40. EXECUTE format('INSERT INTO %I SELECT * FROM jsonb_populate_record(NULL::%I,$1)',t,t) USING d;
  41. END $$;
  42. CREATE OR REPLACE FUNCTION xs_reserved(co text, uid numeric, account text, excluded text DEFAULT '') RETURNS numeric LANGUAGE sql STABLE AS $$
  43. SELECT COALESCE(sum((cost->>'amount')::numeric),0) FROM "CourseCreditReservation" r,
  44. LATERAL jsonb_array_elements(COALESCE(r."creditCosts"::jsonb,'[]')) cost
  45. WHERE r."company"=co AND r."studentId"=uid AND r."state"='reserved'
  46. AND COALESCE(r."appointmentId",'')<>excluded AND cost->>'account'=account
  47. $$;
  48. CREATE OR REPLACE FUNCTION xs_ledger(co text, uid numeric, account text, delta numeric, before_value numeric, tx text, actor text, reason text, method text DEFAULT '') RETURNS void LANGUAGE plpgsql AS $$
  49. DECLARE t text; typ int;
  50. BEGIN
  51. t=CASE account WHEN 'Purse' THEN 'UserExpDomP' WHEN 'SilverCoin' THEN 'UserSIcon' WHEN 'UserExp' THEN 'UserExpHis' WHEN 'UserPoint' THEN 'UserUserPoint' END;
  52. typ=array_position(ARRAY['Purse','SilverCoin','UserExp','UserPoint'],account);
  53. IF t IS NULL THEN RAISE EXCEPTION '课时类型无效'; END IF;
  54. PERFORM xs_insert(t,jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','credit:'||tx||':'||uid||':'||account,
  55. 'transactionId',tx,'userId',uid,'score',delta,'scoreBefore',before_value,'hisTime',now(),'operator',CASE WHEN delta>0 THEN 1 ELSE 2 END,'scoreType',typ,'detail',reason,'remark',actor,'payMethod',method));
  56. END $$;
  57. CREATE OR REPLACE FUNCTION xs_credit_post(co text, actor text, kind text, target text, account text, amount numeric, original text, reason text, method text, idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
  58. DECLARE u "_User"; st "_User"; src "CreditTransaction"; existing "CreditTransaction"; req jsonb; tx text=xs_object_id(); delta numeric; sb numeric; ub numeric; refunded numeric; result jsonb;
  59. BEGIN
  60. IF co IS NULL OR length(idem)<8 OR length(trim(reason))<2 OR amount IS NULL OR amount<=0 OR amount>1000000000 OR amount::text IN ('NaN','Infinity') THEN RAISE EXCEPTION '数量、操作原因或请求编号无效'; END IF;
  61. IF account IS NULL OR NOT account=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR (account<>'UserPoint' AND trunc(amount)<>amount) OR round(amount,2)<>amount THEN RAISE EXCEPTION '课时必须为整数,陪练时长最多两位小数'; END IF;
  62. IF NOT kind=ANY(ARRAY['fund','transfer','refund']) OR length(method)=0 THEN RAISE EXCEPTION '业务类型或付款方式无效'; END IF;
  63. req=jsonb_build_object('actor',actor,'kind',kind,'target',target,'account',account,'amount',amount,'original',original,'reason',reason,'method',method);
  64. -- A company lock orders all fund/transfer/enrollment operations; account locks also serialize scheduling and completion.
  65. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  66. SELECT * INTO existing FROM "CreditTransaction" WHERE "company"=co AND "idempotencyKey"=idem;
  67. IF FOUND THEN IF existing."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他操作'; END IF; RETURN to_jsonb(existing); END IF;
  68. SELECT * INTO u FROM "_User" WHERE "company"=co AND "objectId"=target;
  69. IF NOT FOUND OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '账户不存在或已停用'; END IF;
  70. IF kind='fund' THEN
  71. IF COALESCE(u."identityType",'')<>'store' AND COALESCE(u."legacyGroupId",0)<>2 THEN RAISE EXCEPTION '目标必须是门店'; END IF; st=u;
  72. ELSE
  73. IF COALESCE(u."identityType",'')<>'member' AND COALESCE(u."legacyGroupId",0)<>1 THEN RAISE EXCEPTION '目标必须是学员'; END IF;
  74. IF kind='refund' THEN
  75. SELECT t.* INTO src FROM "CreditTransaction" t WHERE t."company"=co AND t."objectId"=original AND t."kind"='transfer';
  76. IF NOT FOUND OR src."studentId"<>u."legacyUserId" OR src."account"<>account THEN RAISE EXCEPTION '原划拨单不存在或不匹配;历史流水需先核对'; END IF;
  77. SELECT COALESCE(sum(t."amount"),0) INTO refunded FROM "CreditTransaction" t WHERE t."company"=co AND t."kind"='refund' AND t."sourceTransactionId"=original;
  78. IF amount+refunded>src."amount" THEN RAISE EXCEPTION '超过原划拨单可退数量'; END IF;
  79. SELECT * INTO st FROM "_User" WHERE "company"=co AND "legacyUserId"=src."storeId";
  80. ELSE SELECT * INTO st FROM "_User" WHERE "company"=co AND "legacyUserId"=COALESCE(NULLIF(u."legacyUserData"->>'ParentUserID',''),'0')::numeric; END IF;
  81. IF st."objectId" IS NULL OR COALESCE(st."isDisabled",false) OR (COALESCE(st."identityType",'')<>'store' AND COALESCE(st."legacyGroupId",0)<>2) THEN RAISE EXCEPTION '请先关联有效门店'; END IF;
  82. END IF;
  83. PERFORM 1 FROM "_User" WHERE "objectId" IN(u."objectId",st."objectId") ORDER BY "objectId" FOR UPDATE;
  84. SELECT * INTO u FROM "_User" WHERE "objectId"=u."objectId";
  85. SELECT * INTO st FROM "_User" WHERE "objectId"=st."objectId";
  86. IF COALESCE(u."isDisabled",false) OR COALESCE(st."isDisabled",false) THEN RAISE EXCEPTION '账户已停用';END IF;
  87. IF kind='transfer' AND COALESCE(NULLIF(u."legacyUserData"->>'ParentUserID',''),'0')::numeric<>st."legacyUserId" THEN RAISE EXCEPTION '所属门店已变更,请重新提交';END IF;
  88. PERFORM set_config('xiaoshu.credit_write','yes',true);
  89. sb=COALESCE((st."legacyUserData"->>account)::numeric,0);ub=COALESCE((u."legacyUserData"->>account)::numeric,0);
  90. IF kind='transfer' AND sb-xs_reserved(co,st."legacyUserId"::numeric,account)<amount THEN RAISE EXCEPTION '门店可用课时不足'; END IF;
  91. IF kind='refund' AND ub-xs_reserved(co,u."legacyUserId"::numeric,account)<amount THEN RAISE EXCEPTION '学员可退课时不足,已使用或已预留课时不能退回'; END IF;
  92. delta=CASE WHEN kind='transfer' THEN -amount ELSE amount END;
  93. UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[account],to_jsonb(sb+delta)),"updatedAt"=now() WHERE "objectId"=st."objectId";
  94. PERFORM xs_ledger(co,st."legacyUserId"::numeric,account,delta,sb,tx,actor,reason,method);
  95. IF kind<>'fund' THEN
  96. UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[account],to_jsonb(ub-delta)),"updatedAt"=now() WHERE "objectId"=u."objectId";
  97. PERFORM xs_ledger(co,u."legacyUserId"::numeric,account,-delta,ub,tx,actor,reason,method);
  98. END IF;
  99. result=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'kind',kind,'account',account,'amount',amount,'storeId',st."legacyUserId",'studentId',CASE WHEN kind='fund' THEN NULL ELSE u."legacyUserId" END,'sourceTransactionId',original,'operatorId',actor,'reason',reason,'payMethod',method,'balances',jsonb_build_object('storeBefore',sb,'storeAfter',sb+delta,'studentBefore',ub,'studentAfter',CASE WHEN kind='fund' THEN NULL ELSE ub-delta END));
  100. PERFORM xs_insert('CreditTransaction',result);
  101. PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','credit:'||tx,'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','课时业务','type1','operations-admin','type2','credit-'||kind,'type3','CreditTransaction','clevel',0,'cadminId',(SELECT "legacyUserId" FROM "_User" WHERE "objectId"=actor),'detail',result::text,'content1',reason,'content2',actor));
  102. RETURN result;
  103. END $$;
  104. -- Safely repair only unambiguous historical mappings. Preserve conflicting progress for review.
  105. UPDATE "CourseBinding" b SET "categoryKey"=p."categoryKey","categoryName"=CASE p."categoryKey" WHEN 'word' THEN '单词课' WHEN 'trial' THEN '体验课' WHEN 'primary_writing' THEN '小学语法写作课' WHEN 'middle_writing' THEN '中学语法写作课' WHEN 'high_writing' THEN '高中语法写作课' WHEN 'word_self_study' THEN '单词自学课' ELSE p."categoryKey" END,"enrollmentStatus"=CASE WHEN EXISTS(SELECT 1 FROM "CourseBinding" other WHERE other."company"=b."company" AND other."yhid"=b."yhid" AND other."kcid"=b."kcid" AND other."objectId"<>b."objectId" AND COALESCE(other."enrollmentStatus",'')<>'recycled' AND (other."categoryKey" IS NULL OR other."categoryKey"=p."categoryKey")) THEN 'pending' ELSE 'active' END
  106. FROM (SELECT "company","courseId",min("categoryKey") AS "categoryKey",true AS "confirmed" FROM "CourseTeachingProfile" WHERE "confirmed"=true GROUP BY "company","courseId" HAVING count(DISTINCT "categoryKey")=1) p WHERE b."company"=p."company" AND b."kcid"::text=p."courseId"::text AND p."confirmed"=true
  107. AND p."categoryKey" IN ('word','trial','primary_writing','middle_writing','high_writing','word_self_study') AND b."categoryKey" IS NULL;
  108. UPDATE "CourseBinding" SET "enrollmentStatus"=CASE WHEN "categoryKey" IN ('word','trial','primary_writing','middle_writing','high_writing','word_self_study') THEN 'active' ELSE 'pending' END WHERE "enrollmentStatus" IS NULL;
  109. UPDATE "CourseBinding" b SET "enrollmentStatus"='recycled' FROM "CommonModel" c WHERE c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" AND c."status"=-2;
  110. UPDATE "CourseBinding" b SET "enrollmentStatus"='pending' WHERE (b."company",b."yhid",b."kcid",b."categoryKey") IN
  111. (SELECT "company","yhid","kcid","categoryKey" FROM "CourseBinding" WHERE "enrollmentStatus"='active' GROUP BY 1,2,3,4 HAVING count(*)>1);
  112. UPDATE "CommonModel" c SET "nodeId"=28,"tableName"='ZL_C_kcbd' FROM "CourseBinding" b WHERE c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" AND (c."nodeId"<>28 OR c."tableName"<>'ZL_C_kcbd');
  113. UPDATE "CourseBinding" b SET "enrollmentStatus"='pending' WHERE b."enrollmentStatus"='active' AND (
  114. b."categoryKey" NOT IN ('word','trial','primary_writing','middle_writing','high_writing','word_self_study')
  115. OR NOT EXISTS(SELECT 1 FROM "CommonModel" c WHERE c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id")
  116. OR NOT EXISTS(SELECT 1 FROM "Node" n WHERE n."company"=b."company" AND n."nodeId"::text=b."kcid"::text)
  117. OR NOT EXISTS(SELECT 1 FROM "_User" u WHERE u."company"=b."company" AND u."legacyUserId"::text=b."yhid"::text));
  118. CREATE UNIQUE INDEX IF NOT EXISTS enrollment_active_identity ON "CourseBinding" ("company","yhid","categoryKey","kcid") WHERE "enrollmentStatus"='active';
  119. CREATE OR REPLACE FUNCTION xs_course_size(co text,course text) RETURNS numeric LANGUAGE plpgsql AS $$
  120. DECLARE total numeric;
  121. BEGIN
  122. PERFORM 1 FROM "Node" WHERE "company"=co AND "nodeId"::text=course AND COALESCE("zstatus",99)=99 FOR SHARE;
  123. IF NOT FOUND THEN RAISE EXCEPTION '课程内容不存在或已停用';END IF;
  124. WITH RECURSIVE nodes AS (
  125. SELECT n."nodeId",ARRAY[n."nodeId"] visited FROM "Node" n WHERE n."company"=co AND n."nodeId"::text=course
  126. UNION ALL SELECT n."nodeId",p.visited||n."nodeId" FROM nodes p JOIN "Node" n ON n."company"=co AND n."parentId"=p."nodeId" WHERE NOT n."nodeId"=ANY(p.visited) AND COALESCE(n."zstatus",99)=99
  127. ) SELECT count(DISTINCT c."objectId") INTO total FROM nodes n JOIN "CommonModel" c ON c."company"=co AND c."nodeId"=n."nodeId" AND c."modelId"=52 AND COALESCE(c."status",99)=99;
  128. IF total=0 THEN RAISE EXCEPTION '课程内容为空';END IF;
  129. RETURN total;
  130. END $$;
  131. CREATE OR REPLACE FUNCTION xs_enroll(co text, actor text, uid numeric, category text, items jsonb, costs jsonb, reason text, idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
  132. DECLARE u "_User"; req jsonb; old "EnrollmentCommand"; item jsonb; option_costs jsonb; cost jsonb; affordable boolean=false; valid boolean; ids jsonb='[]'; bid text; cid text; ident bigint; existing text; content_count numeric;
  133. BEGIN
  134. IF NOT category=ANY(ARRAY['word','trial','primary_writing','middle_writing','high_writing','word_self_study']) OR jsonb_array_length(items)<1 OR length(idem)<8 THEN RAISE EXCEPTION '请选择分类和课程内容'; END IF;
  135. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  136. req=jsonb_build_object('uid',uid,'category',category,'courseIds',(SELECT jsonb_agg((value->>'courseId')::numeric ORDER BY (value->>'courseId')::numeric) FROM jsonb_array_elements(items)),'actor',actor,'reason',reason);
  137. SELECT * INTO old FROM "EnrollmentCommand" WHERE "company"=co AND "idempotencyKey"=idem;
  138. IF FOUND THEN IF old."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他开课操作'; END IF;RETURN old."result";END IF;
  139. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"=uid FOR UPDATE;
  140. IF NOT FOUND OR COALESCE(u."isDisabled",false) OR COALESCE(u."identityType",'member')<>'member' THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
  141. FOR option_costs IN SELECT value FROM jsonb_array_elements(costs) LOOP
  142. valid=jsonb_array_length(option_costs)>0;
  143. FOR cost IN SELECT value FROM jsonb_array_elements(option_costs) LOOP
  144. IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,uid,cost->>'account')<(cost->>'amount')::numeric THEN valid=false;END IF;
  145. END LOOP;
  146. affordable=affordable OR valid;
  147. END LOOP;
  148. IF NOT affordable THEN RAISE EXCEPTION '对应可用课时不足,请先充值(含规则要求的陪练时长)'; END IF;
  149. FOR item IN SELECT value FROM jsonb_array_elements(items) LOOP
  150. content_count=xs_course_size(co,item->>'courseId');
  151. SELECT b."objectId" INTO existing FROM "CourseBinding" b WHERE b."company"=co AND b."yhid"::text=uid::text AND b."kcid"::text=item->>'courseId' AND b."categoryKey"=category AND b."enrollmentStatus"='active';
  152. IF existing IS NOT NULL THEN RAISE EXCEPTION '该内容在所选分类已开课,请刷新列表';END IF;
  153. bid=xs_object_id();cid=xs_object_id();ident=nextval('xiaoshu_business_id');
  154. PERFORM xs_insert('CourseBinding',jsonb_build_object('objectId',bid,'company',co,'id',ident,'createdAt',now(),'updatedAt',now(),'sourceKey','enroll:'||bid,'yhid',uid,'kcid',(item->>'courseId')::numeric,'yxx',0,'cksl',content_count,'syjd','','categoryKey',category,'categoryName',item->>'categoryName','enrollmentStatus','active'));
  155. PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',cid,'company',co,'createdAt',now(),'updatedAt',now(),'createTime',now(),'sourceKey','enroll:'||bid,'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',58,'nodeId',28,'tableName','ZL_C_kcbd','status',99,'title',item->>'courseName','inputer',actor));
  156. ids=ids||jsonb_build_array(bid);
  157. END LOOP;
  158. req=jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'result',jsonb_build_object('bindingIds',ids,'count',jsonb_array_length(ids)));
  159. PERFORM xs_insert('EnrollmentCommand',req);RETURN req->'result';
  160. END $$;
  161. -- Shared account lock protects EVERY reservation against parallel transfers/refunds/completion.
  162. CREATE OR REPLACE FUNCTION xs_reservation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  163. DECLARE u "_User"; cost jsonb;
  164. BEGIN
  165. SELECT * INTO u FROM "_User" WHERE "company"=NEW."company" AND "legacyUserId"=NEW."studentId" FOR UPDATE;
  166. IF NEW."state"='reserved' THEN
  167. IF COALESCE(jsonb_array_length(NEW."creditCosts"::jsonb),0)=0 THEN RAISE EXCEPTION '预约缺少有效课时';END IF;
  168. IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
  169. FOR cost IN SELECT value FROM jsonb_array_elements(NEW."creditCosts"::jsonb) LOOP
  170. IF NOT (cost->>'account')=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR (cost->>'amount')::numeric<=0 OR COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(NEW."company",NEW."studentId"::numeric,cost->>'account',COALESCE(NEW."appointmentId",''))<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足,不能重复预留';END IF;
  171. END LOOP;
  172. END IF;
  173. RETURN NEW;
  174. END $$;
  175. DROP TRIGGER IF EXISTS xs_reservation_guard ON "CourseCreditReservation";
  176. CREATE TRIGGER xs_reservation_guard BEFORE INSERT OR UPDATE ON "CourseCreditReservation" FOR EACH ROW EXECUTE FUNCTION xs_reservation_guard();
  177. CREATE OR REPLACE FUNCTION xs_enrollment_status(co text,bid text,restore boolean,actor text,reason text,costs jsonb DEFAULT '[]') RETURNS jsonb LANGUAGE plpgsql AS $$
  178. DECLARE b "CourseBinding"; u "_User"; option_costs jsonb; cost jsonb; valid boolean; affordable boolean=false;
  179. BEGIN
  180. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  181. SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=bid;
  182. IF NOT FOUND THEN RAISE EXCEPTION '开课记录不存在';END IF;
  183. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=b."yhid"::text FOR UPDATE;
  184. IF EXISTS(SELECT 1 FROM "CourseAppointment" a JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" WHERE a."company"=co AND a."szyh"::text=b."yhid"::text AND c."status"<>-2 AND a."dszt"::text IN ('0','10') AND (a."bindingId"=bid OR (a."bindingId" IS NULL AND a."kcid"::text=b."kcid"::text))) THEN RAISE EXCEPTION '请先处理该课程未结束的预约';END IF;
  185. IF restore AND (b."categoryKey" IS NULL OR b."enrollmentStatus"='pending') THEN RAISE EXCEPTION '历史开课分类待确认';END IF;
  186. IF restore THEN
  187. PERFORM xs_course_size(co,b."kcid"::text);
  188. IF COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员已停用';END IF;
  189. FOR option_costs IN SELECT value FROM jsonb_array_elements(costs) LOOP
  190. valid=jsonb_array_length(option_costs)>0;
  191. FOR cost IN SELECT value FROM jsonb_array_elements(option_costs) LOOP
  192. IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,b."yhid"::numeric,cost->>'account')<(cost->>'amount')::numeric THEN valid=false;END IF;
  193. END LOOP;
  194. affordable=affordable OR valid;
  195. END LOOP;
  196. IF NOT affordable THEN RAISE EXCEPTION '对应可用课时不足,请先充值';END IF;
  197. END IF;
  198. UPDATE "CourseBinding" SET "enrollmentStatus"=CASE WHEN restore THEN 'active' ELSE 'recycled' END,"updatedAt"=now() WHERE "objectId"=bid;
  199. UPDATE "CommonModel" SET "status"=CASE WHEN restore THEN 99 ELSE -2 END,"updatedAt"=now() WHERE "company"=co AND "modelId"=58 AND "itemId"=b."id";
  200. 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,'bindingId',bid,'restore',restore),'result','{}'::jsonb));
  201. RETURN jsonb_build_object('bindingId',bid,'restored',restore);
  202. END $$;
  203. CREATE OR REPLACE FUNCTION xs_validate_appointment(co text,aid text) RETURNS text LANGUAGE plpgsql AS $$
  204. DECLARE a "CourseAppointment"; c "CommonModel"; u "_User"; matches int; cost jsonb;
  205. BEGIN
  206. SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "modelId"=54 AND "objectId"=aid;
  207. SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"=c."itemId";
  208. IF a."objectId" IS NULL OR c."status"=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
  209. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  210. IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
  211. SELECT count(*) INTO matches FROM "CourseBinding" b JOIN "CommonModel" bc ON bc."company"=b."company" AND bc."modelId"=58 AND bc."itemId"=b."id" WHERE b."company"=co AND b."yhid"::text=a."szyh"::text AND b."kcid"::text=a."kcid"::text AND b."enrollmentStatus"='active' AND bc."status"=99 AND (NULLIF(a."bindingId",'') IS NULL OR b."objectId"=a."bindingId");
  212. PERFORM xs_course_size(co,a."kcid"::text);
  213. IF matches<>1 THEN RAISE EXCEPTION '预约必须关联唯一有效开课记录,请先确认分类';END IF;
  214. IF COALESCE(a."creditHoldStatus",'ready')<>'ready' THEN RAISE EXCEPTION '历史预约课时待处理';END IF;
  215. IF jsonb_array_length(COALESCE(a."creditCostsSnapshot"::jsonb,'[]'))=0 THEN RAISE EXCEPTION '预约缺少课时快照';END IF;
  216. FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
  217. IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId"::numeric,cost->>'account',aid)<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足'; END IF;
  218. END LOOP;
  219. RETURN u."objectId";
  220. END $$;
  221. CREATE OR REPLACE FUNCTION xs_complete_appointment(co text,aid text,actor text) RETURNS jsonb LANGUAGE plpgsql AS $$
  222. DECLARE a "CourseAppointment"; c "CommonModel"; u "_User"; existing text; lid text=xs_object_id(); lc text=xs_object_id(); ident bigint; cost jsonb; before_value numeric; tx text; charged boolean=false;
  223. BEGIN
  224. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  225. -- Acquire user before appointment/reservation locks, matching transfer and reservation lock ordering.
  226. SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54;
  227. SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"=c."itemId";
  228. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  229. IF u."objectId" IS NULL THEN RAISE EXCEPTION '预约学员不存在';END IF;
  230. SELECT "objectId" INTO existing FROM "LessonRecord" WHERE "company"=co AND "yyds"::text=c."generalId"::text LIMIT 1;
  231. IF existing IS NOT NULL THEN RETURN jsonb_build_object('lessonId',existing,'idempotent',true,'periodDeducted',false);END IF;
  232. SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
  233. IF a."dszt"::text NOT IN ('10','11') OR COALESCE(NULLIF(a."pl"::text,''),a."fxpl"::text,'0')='0' THEN RAISE EXCEPTION '预约尚未上课或未分配教师';END IF;
  234. PERFORM xs_validate_appointment(co,aid);
  235. SELECT EXISTS(SELECT 1 FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=aid AND "state"='consumed') INTO charged;
  236. tx='complete:'||aid;
  237. IF NOT charged THEN
  238. PERFORM set_config('xiaoshu.credit_write','yes',true);
  239. UPDATE "CourseCreditReservation" SET "state"='consumed',"consumedAt"=now(),"updatedAt"=now() WHERE "company"=co AND "appointmentId"=aid;
  240. FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
  241. before_value=COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0);
  242. u."legacyUserData"=jsonb_set(u."legacyUserData",ARRAY[cost->>'account'],to_jsonb(before_value-(cost->>'amount')::numeric));
  243. PERFORM xs_ledger(co,u."legacyUserId"::numeric,cost->>'account',-(cost->>'amount')::numeric,before_value,tx,actor,'完课扣除课时');
  244. END LOOP;
  245. UPDATE "_User" SET "legacyUserData"=u."legacyUserData","updatedAt"=now() WHERE "objectId"=u."objectId";
  246. END IF;
  247. ident=nextval('xiaoshu_business_id');
  248. PERFORM xs_insert('LessonRecord',jsonb_build_object('objectId',lid,'company',co,'id',ident,'sourceKey',tx,'createdAt',now(),'updatedAt',now(),'xymz',a."szyh",'jsmz',COALESCE(NULLIF(a."pl"::text,''),a."fxpl"::text),'kcid',a."kcid",'kclx',a."dslx",'kcmc',c."subtitle",'yyds',c."generalId"::text,'jffs',a."jffs",'kzsj','[]','lessonAt',now(),'bindingId',a."bindingId",'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'creditCostsSnapshot',a."creditCostsSnapshot"));
  249. PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',lc,'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey',tx,'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',59,'nodeId',296,'tableName','ZL_C_skjl','title',c."title",'inputer',actor,'status',99));
  250. UPDATE "CourseAppointment" SET "dszt"='11',"jssj"=COALESCE(NULLIF("jssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
  251. RETURN jsonb_build_object('lessonId',lid,'idempotent',false,'periodDeducted',NOT charged);
  252. END $$;
  253. CREATE OR REPLACE FUNCTION xs_schedule_save(co text,actor text,req jsonb,entries jsonb,idem text,edit_id text DEFAULT '',expected text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
  254. DECLARE old "EnrollmentCommand"; entry jsonb; uid numeric; student "_User"; b "CourseBinding"; cid text; aid text; ident bigint; common_data jsonb; addon_data jsonb; ids jsonb='[]'; costs jsonb; old_c "CommonModel"; old_a "CourseAppointment"; requested_start timestamptz; requested_end timestamptz;
  255. BEGIN
  256. IF length(idem)<8 OR jsonb_array_length(entries)=0 THEN RAISE EXCEPTION '排课请求无效';END IF;
  257. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  258. SELECT * INTO old FROM "EnrollmentCommand" WHERE "company"=co AND "idempotencyKey"=idem;
  259. IF FOUND THEN IF old."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他排课操作';END IF;RETURN old."result";END IF;
  260. IF edit_id<>'' THEN
  261. SELECT * INTO old_c FROM "CommonModel" WHERE "company"=co AND "objectId"=edit_id AND "modelId"=54;
  262. SELECT * INTO old_a FROM "CourseAppointment" WHERE "company"=co AND "id"=old_c."itemId";
  263. IF old_a."objectId" IS NULL OR old_a."dszt"::text<>'0' OR old_c."status"=-2 THEN RAISE EXCEPTION '只有未开始的预约可以修改';END IF;
  264. IF expected<>'' AND old_c."updatedAt"<>expected::timestamptz THEN RAISE EXCEPTION '预约已修改,请刷新';END IF;
  265. END IF;
  266. -- Account locks precede reservation locks. Stable order handles changing an appointment's student.
  267. PERFORM 1 FROM "_User" WHERE "company"=co AND ("legacyUserId"::text=old_a."szyh"::text OR "legacyUserId"::text IN(SELECT value->'addon'->>'szyh' FROM jsonb_array_elements(entries))) ORDER BY "objectId" FOR UPDATE;
  268. FOR entry IN SELECT value FROM jsonb_array_elements(entries) LOOP
  269. addon_data=entry->'addon';uid=(addon_data->>'szyh')::numeric;costs=addon_data->'creditCostsSnapshot';
  270. SELECT * INTO student FROM "_User" WHERE "company"=co AND "legacyUserId"=uid;
  271. SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=addon_data->>'bindingId' AND "yhid"::text=uid::text AND "enrollmentStatus"='active';
  272. IF b."objectId" IS NULL OR b."kcid"::text<>addon_data->>'kcid' OR b."categoryKey"<>addon_data->>'courseCategoryKey' OR COALESCE(student."isDisabled",false) THEN RAISE EXCEPTION '学员或开课记录已失效';END IF;
  273. PERFORM xs_course_size(co,b."kcid"::text);
  274. requested_start=(addon_data->>'yysj')::timestamp AT TIME ZONE 'Asia/Shanghai';requested_end=requested_start+make_interval(mins=>(addon_data->>'durationMinutes')::int);
  275. IF EXISTS(SELECT 1 FROM "CourseAppointment" a JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" WHERE a."company"=co AND c."status"<>-2 AND c."objectId"<>edit_id AND a."dszt"::text IN ('0','10','11','20') AND (a."szyh"::text=uid::text OR (NULLIF(addon_data->>'pl','') IS NOT NULL AND a."pl"::text=addon_data->>'pl')) AND COALESCE(a."yysj",'') ~ '^\d{4}-\d{2}-\d{2} ' AND (a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')<requested_end AND (a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')+make_interval(mins=>COALESCE(a."durationMinutes",30)::int)>requested_start) THEN RAISE EXCEPTION '会员或老师时间冲突,请重新检查排课';END IF;
  276. IF edit_id='' THEN
  277. cid=xs_object_id();aid=xs_object_id();ident=nextval('xiaoshu_business_id');
  278. addon_data=addon_data||jsonb_build_object('objectId',aid,'id',ident,'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','schedule:'||idem||':'||jsonb_array_length(ids));
  279. common_data=(entry->'common')||jsonb_build_object('objectId',cid,'company',co,'createdAt',now(),'updatedAt',now(),'createTime',now(),'sourceKey','schedule:'||idem||':'||jsonb_array_length(ids),'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',54,'nodeId',29,'tableName','ZL_C_order','status',99,'inputer',actor);
  280. PERFORM xs_insert('CourseAppointment',addon_data);PERFORM xs_insert('CommonModel',common_data);
  281. ELSE
  282. cid=old_c."objectId";aid=old_a."objectId";
  283. -- Preserve all unrelated lesson/feedback fields; replace only scheduling fields supplied by the gateway.
  284. addon_data=to_jsonb(old_a)||addon_data||jsonb_build_object('updatedAt',now());
  285. UPDATE "CourseAppointment" a SET ("szyh","pl","fxpl","plxm","kcid","yykcid","dslx","jffs","bindingId","courseCategoryKey","courseCategoryName","durationMinutes","deliveryMode","teachingRuleId","teacherPaySnapshot","creditCostsSnapshot","bxrq","sdsd","yysj","updatedAt")=(SELECT r."szyh",r."pl",r."fxpl",r."plxm",r."kcid",r."yykcid",r."dslx",r."jffs",r."bindingId",r."courseCategoryKey",r."courseCategoryName",r."durationMinutes",r."deliveryMode",r."teachingRuleId",r."teacherPaySnapshot",r."creditCostsSnapshot",r."bxrq",r."sdsd",r."yysj",r."updatedAt" FROM jsonb_populate_record(NULL::"CourseAppointment",addon_data) r) WHERE a."objectId"=aid;
  286. UPDATE "CommonModel" SET "subtitle"=entry->'common'->>'subtitle',"updatedAt"=now() WHERE "objectId"=cid;
  287. UPDATE "CourseCreditReservation" SET "state"='released',"updatedAt"=now() WHERE "company"=co AND "appointmentId"=cid;
  288. DELETE FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=cid AND "state"='released';
  289. common_data=to_jsonb(old_c);
  290. END IF;
  291. PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:appointment-reservation:'||cid,'appointmentId',cid,'appointmentGeneralId',common_data->'generalId','studentId',uid,'ruleId',addon_data->>'teachingRuleId','creditCosts',costs,'state','reserved','reservedAt',now()));
  292. ids=ids||jsonb_build_array(cid);
  293. END LOOP;
  294. common_data=jsonb_build_object('created',jsonb_array_length(ids),'targetIds',ids);
  295. PERFORM xs_insert('EnrollmentCommand',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'result',common_data));
  296. RETURN common_data;
  297. END $$;
  298. CREATE OR REPLACE FUNCTION xs_schedule_cancel(co text,aid text,restore boolean,actor text,reason text,expected text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
  299. DECLARE c "CommonModel"; a "CourseAppointment"; u "_User"; requested_start timestamptz; requested_end timestamptz; restore_coach text;
  300. BEGIN
  301. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  302. SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54;
  303. IF expected<>'' AND c."updatedAt"<>expected::timestamptz THEN RAISE EXCEPTION '预约已变更,请刷新';END IF;
  304. SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"=c."itemId";
  305. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  306. SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
  307. IF a."objectId" IS NULL OR a."dszt"::text NOT IN ('0','10') THEN RAISE EXCEPTION '预约已经上课或不存在';END IF;
  308. IF restore THEN
  309. requested_start=a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai';requested_end=requested_start+make_interval(mins=>COALESCE(a."durationMinutes",30)::int);restore_coach=a."pl"::text;
  310. IF EXISTS(SELECT 1 FROM "CourseAppointment" ca JOIN "CommonModel" cc ON cc."company"=ca."company" AND cc."modelId"=54 AND cc."itemId"=ca."id" WHERE ca."company"=co AND cc."status"<>-2 AND cc."objectId"<>aid AND ca."dszt"::text IN ('0','10','11','20') AND (ca."szyh"::text=u."legacyUserId"::text OR (NULLIF(restore_coach,'') IS NOT NULL AND ca."pl"::text=restore_coach)) AND COALESCE(ca."yysj",'') ~ '^\d{4}-\d{2}-\d{2} ' AND (ca."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')<requested_end AND (ca."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')+make_interval(mins=>COALESCE(ca."durationMinutes",30)::int)>requested_start) THEN RAISE EXCEPTION '会员或老师时间冲突,请重新检查排课';END IF;
  311. UPDATE "CommonModel" SET "status"=99,"updatedAt"=now() WHERE "objectId"=aid;
  312. PERFORM xs_validate_appointment(co,aid);
  313. UPDATE "CourseCreditReservation" SET "state"='reserved',"updatedAt"=now() WHERE "company"=co AND "appointmentId"=aid;
  314. IF NOT FOUND THEN PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:appointment-reservation:'||aid,'appointmentId',aid,'studentId',u."legacyUserId",'creditCosts',a."creditCostsSnapshot",'state','reserved'));END IF;
  315. ELSE
  316. UPDATE "CourseCreditReservation" SET "state"='released',"updatedAt"=now() WHERE "company"=co AND "appointmentId"=aid;
  317. UPDATE "CommonModel" SET "status"=-2,"updatedAt"=now() WHERE "objectId"=aid;
  318. END IF;
  319. 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('appointmentId',aid,'restore',restore,'actor',actor,'reason',reason),'result','{}'::jsonb));
  320. RETURN jsonb_build_object('targetId',aid,'restored',restore);
  321. END $$;
  322. CREATE OR REPLACE FUNCTION xs_start_appointment(co text,aid text) RETURNS void LANGUAGE plpgsql AS $$
  323. DECLARE a "CourseAppointment";
  324. BEGIN
  325. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  326. PERFORM xs_validate_appointment(co,aid);
  327. SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c."company"=a0."company" AND c."modelId"=54 AND c."itemId"=a0."id" WHERE c."company"=co AND c."objectId"=aid FOR UPDATE OF a0;
  328. IF a."dszt"::text NOT IN ('0','10') THEN RAISE EXCEPTION '预约状态已变更';END IF;
  329. IF COALESCE(NULLIF(a."pl"::text,''),'0')='0' THEN RAISE EXCEPTION '请先分配教师';END IF;
  330. UPDATE "CourseAppointment" SET "dszt"='10',"kssj"=COALESCE(NULLIF("kssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
  331. END $$;
  332. -- Reject stale profile writes that would silently overwrite balances changed by a transaction.
  333. CREATE OR REPLACE FUNCTION xs_user_credit_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  334. DECLARE account text;
  335. BEGIN
  336. IF current_setting('xiaoshu.credit_write',true) IS DISTINCT FROM 'yes' THEN
  337. FOREACH account IN ARRAY ARRAY['Purse','SilverCoin','UserExp','UserPoint'] LOOP
  338. IF COALESCE((OLD."legacyUserData"->>account)::numeric,0)<>COALESCE((NEW."legacyUserData"->>account)::numeric,0) THEN RAISE EXCEPTION '课时账户已变更,请刷新;余额仅能通过课时业务单修改';END IF;
  339. END LOOP;
  340. END IF;
  341. RETURN NEW;
  342. END $$;
  343. DROP TRIGGER IF EXISTS xs_user_credit_guard ON "_User";
  344. CREATE TRIGGER xs_user_credit_guard BEFORE UPDATE OF "legacyUserData" ON "_User" FOR EACH ROW EXECUTE FUNCTION xs_user_credit_guard();
  345. CREATE UNIQUE INDEX IF NOT EXISTS enrollment_reservation_identity ON "CourseCreditReservation" ("company","appointmentId") WHERE "state"='reserved';