member-credit-ledger.sql 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. -- Additive migration. Does not rewrite balances or replay historical charges.
  2. CREATE TABLE IF NOT EXISTS "CreditOperation" (
  3. "objectId" text PRIMARY KEY, company text NOT NULL, "studentId" numeric NOT NULL,
  4. "idempotencyKey" text NOT NULL, request jsonb NOT NULL, result jsonb NOT NULL,
  5. "createdAt" timestamptz NOT NULL DEFAULT now(), UNIQUE(company,"idempotencyKey")
  6. );
  7. CREATE TABLE IF NOT EXISTS "TrialEligibility" (
  8. company text NOT NULL, "studentId" numeric NOT NULL, status text NOT NULL CHECK(status IN ('pending','eligible','issued')),
  9. evidence jsonb NOT NULL DEFAULT '{}', "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"studentId")
  10. );
  11. CREATE TABLE IF NOT EXISTS "LegacyFinanceReceipt" (
  12. company text NOT NULL, "studentId" numeric NOT NULL, account text NOT NULL,
  13. "runId" text NOT NULL, ids jsonb NOT NULL, checksum text NOT NULL, "createdAt" timestamptz DEFAULT now(),
  14. PRIMARY KEY(company,"studentId",account,"runId")
  15. );
  16. DO $$ DECLARE t text; BEGIN
  17. FOREACH t IN ARRAY ARRAY['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'] LOOP
  18. EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS "legacyFinanceId" text, ADD COLUMN IF NOT EXISTS "legacyFinanceRaw" jsonb, ADD COLUMN IF NOT EXISTS "legacyFinanceHash" text',t);
  19. EXECUTE format('CREATE UNIQUE INDEX IF NOT EXISTS %I ON %I(company,"legacyFinanceId") WHERE "legacyFinanceId" IS NOT NULL',lower(t)||'_finance_source',t);
  20. END LOOP;
  21. END $$;
  22. ALTER TABLE "CreditTransaction" ADD COLUMN IF NOT EXISTS "operationId" text;
  23. INSERT INTO "TrialEligibility"(company,"studentId",status,evidence)
  24. SELECT u.company,u."legacyUserId",CASE WHEN COALESCE((u."legacyUserData"->>'UserExp')::numeric,0)>0
  25. OR EXISTS(SELECT 1 FROM "UserExpHis" h WHERE h.company=u.company AND h."userId"=u."legacyUserId" AND h.score<>0)
  26. OR EXISTS(SELECT 1 FROM "LessonRecord" l WHERE l.company=u.company AND l.xymz::text=u."legacyUserId"::text AND (l."courseCategoryKey"='trial' OR l.kclx::text='3'))
  27. THEN 'issued' ELSE 'pending' END,jsonb_build_object('source','migration-local-evidence') FROM "_User" u
  28. WHERE u.company IS NOT NULL AND u."legacyUserId" IS NOT NULL AND u."identityType"='member'
  29. ON CONFLICT(company,"studentId") DO UPDATE SET status=CASE WHEN EXCLUDED.status='issued' THEN 'issued' ELSE "TrialEligibility".status END;
  30. -- All balance entry points, including older gateways, must obey the lifetime grant guard.
  31. CREATE OR REPLACE FUNCTION xs_trial_grant_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  32. DECLARE previous numeric=0; next_value numeric; qualification text;
  33. BEGIN
  34. IF NEW."identityType" IS DISTINCT FROM 'member' THEN RETURN NEW;END IF;
  35. next_value=COALESCE((NEW."legacyUserData"->>'UserExp')::numeric,0);
  36. IF TG_OP='UPDATE' THEN previous=COALESCE((OLD."legacyUserData"->>'UserExp')::numeric,0);END IF;
  37. IF next_value<=previous THEN RETURN NEW;END IF;
  38. IF TG_OP='INSERT' AND COALESCE(to_jsonb(NEW)->>'sourceKey','') LIKE 'legacy%' AND NEW."legacyUserId" IS NOT NULL THEN
  39. INSERT INTO "TrialEligibility"(company,"studentId",status,evidence) VALUES(NEW.company,NEW."legacyUserId",'issued',jsonb_build_object('source','initial-legacy-balance')) ON CONFLICT(company,"studentId") DO UPDATE SET status='issued';RETURN NEW;
  40. END IF;
  41. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||NEW.company));
  42. SELECT status INTO qualification FROM "TrialEligibility" WHERE company=NEW.company AND "studentId"=NEW."legacyUserId" FOR UPDATE;
  43. IF qualification IS DISTINCT FROM 'eligible' THEN RAISE EXCEPTION '体验资格已使用或待核验,每位会员仅可发放一次';END IF;
  44. IF next_value-previous<>1 OR previous<>0 THEN RAISE EXCEPTION '体验数量固定 1 次';END IF;
  45. UPDATE "TrialEligibility" SET status='issued',evidence=evidence||jsonb_build_object('issuedAt',now()),"updatedAt"=now() WHERE company=NEW.company AND "studentId"=NEW."legacyUserId";
  46. RETURN NEW;
  47. END $$;
  48. DROP TRIGGER IF EXISTS xs_trial_grant_guard ON "_User";
  49. CREATE TRIGGER xs_trial_grant_guard BEFORE INSERT OR UPDATE OF "legacyUserData" ON "_User" FOR EACH ROW EXECUTE FUNCTION xs_trial_grant_guard();
  50. CREATE OR REPLACE FUNCTION xs_credit_plan(co text,target text,p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
  51. DECLARE u "_User"; st "_User"; source "CreditTransaction"; companion "CreditTransaction"; a text=p->>'account'; n numeric=(p->>'amount')::numeric;
  52. kind text=COALESCE(p->>'direction','add'); lines jsonb='[]'; item jsonb; signed numeric; paired boolean=true; before_value numeric; held numeric; store_value numeric; store_held numeric; delta numeric; qualification text; token text;
  53. BEGIN
  54. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  55. SELECT * INTO u FROM "_User" WHERE company=co AND "objectId"=target;
  56. IF NOT FOUND OR u."identityType"<>'member' OR COALESCE(u."isDisabled",false) OR COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false) THEN RAISE EXCEPTION '学员不存在或已停用';END IF;
  57. IF a IS NULL OR NOT a=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR n IS NULL OR n<=0 OR n>1000000000 OR n::text IN ('NaN','Infinity') OR round(n,2)<>n OR (a<>'UserPoint' AND trunc(n)<>n) THEN RAISE EXCEPTION '课时数量无效';END IF;
  58. IF kind NOT IN ('add','deduct','adjust') OR length(trim(COALESCE(p->>'payMethod','')))<1 THEN RAISE EXCEPTION '请选择记账方式和业务类型';END IF;
  59. IF kind='adjust' AND COALESCE(p->>'adjustMode','paired') NOT IN ('paired','single') THEN RAISE EXCEPTION '调整范围无效';END IF;
  60. IF kind='adjust' AND (length(trim(COALESCE(p->>'evidence','')))<2 OR COALESCE(p->>'adjustSign','') NOT IN ('add','deduct')) THEN RAISE EXCEPTION '差错调整需要依据和增减方向';END IF;
  61. SELECT status INTO qualification FROM "TrialEligibility" WHERE company=co AND "studentId"=u."legacyUserId";
  62. IF a='UserExp' AND (kind='add' OR (kind='adjust' AND p->>'adjustSign'='add')) AND (qualification IS DISTINCT FROM 'eligible' OR n<>1) THEN RAISE EXCEPTION '体验资格已使用或待核验,数量固定 1 次';END IF;
  63. IF kind='deduct' THEN
  64. SELECT c.* INTO source FROM "CreditTransaction" c WHERE c.company=co AND c."objectId"=p->>'sourceTransactionId' AND c."studentId"=u."legacyUserId" AND c.account=a AND c.kind='transfer';
  65. IF NOT FOUND THEN RAISE EXCEPTION '请选择有效原单;无原单请使用差错调整';END IF;
  66. IF a='UserPoint' AND source."operationId" IS NOT NULL AND EXISTS(SELECT 1 FROM "CreditTransaction" c WHERE c.company=co AND c."operationId"=source."operationId" AND c.account IN ('Purse','SilverCoin')) THEN RAISE EXCEPTION '成套充值请从原课时主单按比例退回';END IF;
  67. IF n>(source.amount-COALESCE((SELECT sum(t.amount) FROM "CreditTransaction" t WHERE t.company=co AND t."sourceTransactionId"=source."objectId" AND t.kind='refund'),0)) THEN RAISE EXCEPTION '超过原单剩余可退数量';END IF;
  68. SELECT * INTO st FROM "_User" WHERE company=co AND "legacyUserId"=source."storeId";
  69. IF source."operationId" IS NOT NULL AND a IN ('Purse','SilverCoin') THEN
  70. SELECT c.* INTO companion FROM "CreditTransaction" c WHERE c.company=co AND c."operationId"=source."operationId" AND c.account='UserPoint' AND c.kind='transfer';
  71. paired=FOUND;
  72. ELSE paired=false;END IF;
  73. ELSIF kind='add' THEN
  74. SELECT * INTO st FROM "_User" WHERE company=co AND "legacyUserId"=NULLIF(u."legacyUserData"->>'ParentUserID','')::numeric;
  75. ELSE paired=COALESCE(p->>'adjustMode','paired')='paired';END IF;
  76. IF kind<>'adjust' AND (st."objectId" IS NULL OR st."identityType"<>'store' OR COALESCE(st."isDisabled",false) OR COALESCE((to_jsonb(st)->>'isDeleted')::boolean,false)) THEN RAISE EXCEPTION '请先关联有效门店';END IF;
  77. signed=CASE WHEN kind='deduct' OR (kind='adjust' AND p->>'adjustSign'='deduct') THEN -1 ELSE 1 END;
  78. lines=jsonb_build_array(jsonb_build_object('account',a,'amount',n,'sourceTransactionId',source."objectId"));
  79. IF paired AND a IN ('Purse','SilverCoin') THEN lines=lines||jsonb_build_array(jsonb_build_object('account','UserPoint','amount',CASE WHEN kind='deduct' THEN n*companion.amount/source.amount WHEN a='Purse' THEN n*0.5 ELSE n END,'sourceTransactionId',companion."objectId"));END IF;
  80. token=md5(jsonb_build_object('student',u."legacyUserData",'store',st."legacyUserData",'trial',qualification,'lines',lines,'source',source."objectId",'refunds',(SELECT jsonb_agg(to_jsonb(t) ORDER BY t."objectId") FROM "CreditTransaction" t WHERE t.company=co AND t.kind='refund' AND t."studentId"=u."legacyUserId"),'reserved',(SELECT jsonb_agg(jsonb_build_array(x,xs_reserved(co,u."legacyUserId"::numeric,x),xs_reserved(co,st."legacyUserId"::numeric,x))) FROM unnest(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) x))::text);
  81. FOR item IN SELECT value FROM jsonb_array_elements(lines) LOOP
  82. a=item->>'account';delta=signed*(item->>'amount')::numeric;before_value=COALESCE((u."legacyUserData"->>a)::numeric,0);held=xs_reserved(co,u."legacyUserId"::numeric,a);
  83. IF delta<0 AND before_value-held+delta<0 THEN RAISE EXCEPTION '% 可扣回额度不足,不能扣回已使用或已预留额度',a;END IF;
  84. store_value=COALESCE((st."legacyUserData"->>a)::numeric,0);store_held=xs_reserved(co,st."legacyUserId"::numeric,a);
  85. IF kind='add' AND store_value-store_held<delta THEN RAISE EXCEPTION '门店 % 库存不足,缺少 %;请在门店账户办理总部入账',a,delta-(store_value-store_held);END IF;
  86. lines=(SELECT jsonb_agg(CASE WHEN v->>'account'=a THEN v||jsonb_build_object('delta',delta,'before',before_value,'after',before_value+delta,'reserved',held,'availableBefore',before_value-held,'availableAfter',before_value-held+delta,'storeBefore',CASE WHEN kind='adjust' THEN NULL ELSE store_value END,'storeAfter',CASE WHEN kind='adjust' THEN NULL ELSE store_value-delta END,'storeAvailableBefore',CASE WHEN kind='adjust' THEN NULL ELSE store_value-store_held END,'storeAvailableAfter',CASE WHEN kind='adjust' THEN NULL ELSE store_value-store_held-delta END) ELSE v END) FROM jsonb_array_elements(lines) v);
  87. END LOOP;
  88. RETURN jsonb_build_object('version',token,'studentId',u."legacyUserId",'storeId',st."legacyUserId",'storeObjectId',st."objectId",'lines',lines,'trialStatus',COALESCE(qualification,'pending'));
  89. END $$;
  90. CREATE OR REPLACE FUNCTION xs_credit_operation(co text,actor text,target text,p jsonb,reason text,idem text,expected text) RETURNS jsonb LANGUAGE plpgsql AS $$
  91. DECLARE req jsonb=jsonb_build_object('actor',actor,'target',target,'payload',p,'reason',reason); previous "CreditOperation"; plan jsonb; item jsonb; r jsonb; results jsonb='[]'; oid text=xs_object_id(); tx text; kind text=p->>'direction'; uid numeric;
  92. BEGIN
  93. IF length(trim(reason))<2 OR length(idem)<8 THEN RAISE EXCEPTION '缺少原因或幂等编号';END IF;
  94. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  95. SELECT * INTO previous FROM "CreditOperation" WHERE company=co AND "idempotencyKey"=idem;
  96. IF FOUND THEN IF previous.request<>req THEN RAISE EXCEPTION '请求编号已用于其他操作';END IF;RETURN previous.result;END IF;
  97. plan=xs_credit_plan(co,target,p);uid=(plan->>'studentId')::numeric;
  98. IF expected IS NULL OR expected IS DISTINCT FROM plan->>'version' THEN RAISE EXCEPTION '账户或预留已变化,请刷新预览后提交';END IF;
  99. FOR item IN SELECT value FROM jsonb_array_elements(plan->'lines') LOOP
  100. IF kind='adjust' THEN
  101. tx=xs_object_id();PERFORM set_config('xiaoshu.credit_write','yes',true);
  102. UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[item->>'account'],item->'after'),"updatedAt"=now() WHERE company=co AND "objectId"=target;
  103. PERFORM xs_ledger(co,uid,item->>'account',(item->>'delta')::numeric,(item->>'before')::numeric,tx,actor,reason||';依据:'||(p->>'evidence'),p->>'payMethod');
  104. r=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem||':'||(item->>'account'),'request',req,'kind','adjust','account',item->>'account','amount',(item->>'amount')::numeric,'studentId',uid,'operatorId',actor,'reason',reason,'payMethod',p->>'payMethod','balances',jsonb_build_object('studentBefore',item->'before','studentAfter',item->'after'),'operationId',oid);
  105. PERFORM xs_insert('CreditTransaction',r);
  106. ELSE
  107. r=xs_credit_post(co,actor,CASE kind WHEN 'add' THEN 'transfer' ELSE 'refund' END,target,item->>'account',(item->>'amount')::numeric,COALESCE(item->>'sourceTransactionId',''),reason,p->>'payMethod',idem||':'||(item->>'account'));
  108. UPDATE "CreditTransaction" SET "operationId"=oid WHERE "objectId"=r->>'objectId';
  109. END IF;
  110. results=results||jsonb_build_array(r-'request');
  111. END LOOP;
  112. r=plan||jsonb_build_object('objectId',oid,'transactions',results);
  113. INSERT INTO "CreditOperation" VALUES(oid,co,uid,idem,req,r,now());
  114. PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','credit-operation:'||oid,'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','会员额度业务','type1','operations-admin','type2',kind,'type3','CreditOperation','clevel',0,'content1',reason,'content2',actor,'detail',jsonb_build_object('request',req,'result',r)::text));
  115. RETURN r;
  116. END $$;
  117. CREATE TABLE IF NOT EXISTS "LegacyFinanceSourcePage" (
  118. company text NOT NULL,"studentId" numeric NOT NULL,account text NOT NULL,"runId" text NOT NULL,page integer NOT NULL,
  119. rows jsonb NOT NULL,"createdAt" timestamptz DEFAULT now(),PRIMARY KEY(company,"studentId",account,"runId",page)
  120. );
  121. CREATE OR REPLACE VIEW xs_member_ledger AS
  122. WITH raw AS (
  123. SELECT 'Purse'::text account,to_jsonb(l) j FROM "UserExpDomP" l UNION ALL
  124. SELECT 'SilverCoin',to_jsonb(l) FROM "UserSIcon" l UNION ALL
  125. SELECT 'UserExp',to_jsonb(l) FROM "UserExpHis" l UNION ALL
  126. SELECT 'UserPoint',to_jsonb(l) FROM "UserUserPoint" l
  127. )
  128. SELECT r.account,r.j->>'objectId' id,r.j->>'company' company,(r.j->>'userId')::numeric "studentId",
  129. COALESCE(t."operationId",r.account||':'||(r.j->>'objectId')) "groupId",
  130. COALESCE((r.j->>'hisTime')::timestamptz,(r.j->>'createdAt')::timestamptz) "happenedAt",
  131. (r.j->>'score')::numeric delta,(r.j->>'scoreBefore')::numeric "before",
  132. CASE WHEN r.j->>'legacyFinanceId' IS NULL THEN (r.j->>'scoreBefore')::numeric+(r.j->>'score')::numeric ELSE NULL END "after",
  133. COALESCE(t.kind,CASE WHEN r.j->>'transactionId' LIKE 'complete:%' OR r.j->>'detail' ~ '上课|结课|订单|陪练扣|课时扣' THEN 'lesson' WHEN (r.j->>'score')::numeric>0 THEN 'recharge' ELSE 'deduction' END) kind,
  134. COALESCE(t.reason,r.j->>'detail',r.j->>'remark') reason,
  135. CASE WHEN r.j->>'legacyFinanceId' IS NOT NULL OR r.j->>'sourceKey' NOT LIKE 'credit:%' THEN 'legacy' ELSE 'new' END source,
  136. COALESCE(actor."realName",actor.nickname,t."operatorId",r.j->'legacyFinanceRaw'->>'Operator',r.j->>'remark') operator,
  137. COALESCE(t."objectId",r.j->>'legacyFinanceId',r.j->>'transactionId',r.j->>'sourceKey') reference,
  138. t."storeId",t.balances,r.j->'legacyFinanceRaw' "legacyRaw",
  139. (SELECT l."objectId" FROM "LessonRecord" l WHERE l.company=r.j->>'company' AND l.xymz::text=r.j->>'userId' AND
  140. (r.j->>'transactionId'='complete:'||(SELECT c."objectId" FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=54 AND c."generalId"::text=l.yyds::text LIMIT 1) OR (r.j->>'detail') ~ ('订单[:: ]*'||l.yyds::text||'([^0-9]|$)')) LIMIT 1) "lessonId"
  141. FROM raw r LEFT JOIN "CreditTransaction" t ON t.company=r.j->>'company' AND t."objectId"=r.j->>'transactionId'
  142. LEFT JOIN "_User" actor ON actor."objectId"=t."operatorId" AND actor.company=t.company;
  143. CREATE OR REPLACE FUNCTION xs_trial_reservation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  144. DECLARE cost jsonb;
  145. BEGIN
  146. IF NEW.state NOT IN ('reserved','consumed') THEN RETURN NEW;END IF;
  147. SELECT value INTO cost FROM jsonb_array_elements(COALESCE(NEW."creditCosts"::jsonb,'[]')) WHERE value->>'account'='UserExp';
  148. IF cost IS NULL THEN RETURN NEW;END IF;
  149. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||NEW.company));
  150. IF (cost->>'amount')::numeric<>1 OR jsonb_array_length(NEW."creditCosts"::jsonb)<>1 THEN RAISE EXCEPTION '体验课只扣一次体验,不扣普通陪练';END IF;
  151. IF TG_OP='UPDATE' AND OLD.state='consumed' THEN RETURN NEW;END IF;
  152. IF xs_reserved(NEW.company,NEW."studentId"::numeric,'UserExp',NEW."appointmentId")>0 OR EXISTS(
  153. SELECT 1 FROM "LessonRecord" l WHERE l.company=NEW.company AND l.xymz::text=NEW."studentId"::text AND (l."courseCategoryKey"='trial' OR l.kclx::text='3')
  154. ) THEN RAISE EXCEPTION '体验已使用或已有体验预约,不能重复预约';END IF;
  155. RETURN NEW;
  156. END $$;
  157. DROP TRIGGER IF EXISTS xs_trial_reservation_guard ON "CourseCreditReservation";
  158. CREATE TRIGGER xs_trial_reservation_guard BEFORE INSERT OR UPDATE ON "CourseCreditReservation" FOR EACH ROW EXECUTE FUNCTION xs_trial_reservation_guard();