store-credit.sql 9.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. -- Additive only: existing balances and receipts are never rewritten.
  2. CREATE TABLE IF NOT EXISTS "StoreCreditGrant" (
  3. company text NOT NULL, "userId" text NOT NULL, "grantedBy" text NOT NULL,
  4. "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"userId")
  5. );
  6. CREATE TABLE IF NOT EXISTS "StoreCreditGrantAudit" (
  7. id bigserial PRIMARY KEY, company text NOT NULL, "userId" text NOT NULL,
  8. actor text NOT NULL, enabled boolean NOT NULL, reason text NOT NULL, at timestamptz NOT NULL DEFAULT now()
  9. );
  10. CREATE TABLE IF NOT EXISTS "StoreCreditOperation" (
  11. company text NOT NULL,"idempotencyKey" text NOT NULL, request jsonb NOT NULL,
  12. result jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(company,"idempotencyKey")
  13. );
  14. CREATE OR REPLACE FUNCTION xs_store_credit_role(actor text) RETURNS text LANGUAGE sql STABLE AS $$
  15. SELECT CASE WHEN COALESCE(u."isDisabled",false) OR COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false) THEN ''
  16. ELSE COALESCE(NULLIF(to_jsonb(u)->>'adminRoleKey',''),
  17. (SELECT v FROM jsonb_array_elements_text(COALESCE(NULLIF(to_jsonb(u)->'roles','null'::jsonb),'[]'::jsonb)) v WHERE v IN ('super-admin','ops-manager','ops-staff','ops-auditor') ORDER BY CASE v WHEN 'super-admin' THEN 0 WHEN 'ops-manager' THEN 1 WHEN 'ops-staff' THEN 2 ELSE 3 END LIMIT 1),
  18. CASE WHEN to_jsonb(u)->>'isAdmin'='true' OR to_jsonb(u)->>'role'='admin' OR to_jsonb(u)->'roles' ? 'admin' THEN 'ops-manager' ELSE '' END) END
  19. FROM "_User" u WHERE u."objectId"=actor
  20. $$;
  21. CREATE OR REPLACE FUNCTION xs_store_credit_allowed(co text,actor text) RETURNS boolean LANGUAGE sql STABLE AS $$
  22. SELECT COALESCE(xs_store_credit_role(actor)='super-admin' OR
  23. (xs_store_credit_role(actor)='ops-manager' AND EXISTS(SELECT 1 FROM "_User" u JOIN "StoreCreditGrant" g ON g.company=u.company AND g."userId"=u."objectId" WHERE u.company=co AND u."objectId"=actor)),false)
  24. $$;
  25. CREATE OR REPLACE FUNCTION xs_store_credit_grant(co text,actor text,target text,enabled boolean,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
  26. BEGIN
  27. IF xs_store_credit_role(actor) IS DISTINCT FROM 'super-admin' THEN RAISE EXCEPTION '只有超级管理员可以授权门店充值';END IF;
  28. IF length(trim(COALESCE(reason,'')))<2 OR length(reason)>500 OR enabled IS NULL THEN RAISE EXCEPTION '请填写授权原因';END IF;
  29. IF NOT EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=target) OR (enabled AND xs_store_credit_role(target) IS DISTINCT FROM 'ops-manager') THEN RAISE EXCEPTION '请选择本帐套有效的运营管理员';END IF;
  30. IF enabled THEN INSERT INTO "StoreCreditGrant" VALUES(co,target,actor,now()) ON CONFLICT(company,"userId") DO UPDATE SET "grantedBy"=actor,"updatedAt"=now();
  31. ELSE DELETE FROM "StoreCreditGrant" WHERE company=co AND "userId"=target;END IF;
  32. INSERT INTO "StoreCreditGrantAudit"(company,"userId",actor,enabled,reason) VALUES(co,target,actor,enabled,reason);
  33. RETURN jsonb_build_object('userId',target,'enabled',enabled);
  34. END $$;
  35. CREATE OR REPLACE FUNCTION xs_store_credit_plan(co text,actor text,target text,p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
  36. DECLARE st "_User"; src "CreditTransaction"; a text; n numeric; before_value numeric; held numeric; remaining numeric;
  37. lines jsonb='[]'; item jsonb; direction text=COALESCE(p->>'direction','add'); version text;
  38. BEGIN
  39. IF NOT xs_store_credit_allowed(co,actor) THEN RAISE EXCEPTION '当前账号未获门店额度充值权限';END IF;
  40. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  41. SELECT * INTO st FROM "_User" WHERE company=co AND "objectId"=target FOR UPDATE;
  42. IF NOT FOUND OR (st."identityType" IS DISTINCT FROM 'store' AND COALESCE(st."legacyGroupId",0)<>2) OR st."legacyUserId" IS NULL OR COALESCE(st."isDisabled",false) OR COALESCE((to_jsonb(st)->>'isDeleted')::boolean,false) THEN RAISE EXCEPTION '门店不存在或已停用';END IF;
  43. IF direction NOT IN ('add','reverse') OR length(trim(COALESCE(p->>'payMethod','')))=0 THEN RAISE EXCEPTION '请选择业务类型和记账方式';END IF;
  44. IF jsonb_typeof(p->'lines') IS DISTINCT FROM 'array' OR jsonb_array_length(p->'lines') NOT BETWEEN 1 AND 4 THEN RAISE EXCEPTION '请填写 1 至 4 项充值额度';END IF;
  45. IF (SELECT count(DISTINCT v->>'account') FROM jsonb_array_elements(p->'lines') v)<>jsonb_array_length(p->'lines') THEN RAISE EXCEPTION '账户不能重复';END IF;
  46. IF direction='reverse' THEN
  47. IF jsonb_array_length(p->'lines')<>1 THEN RAISE EXCEPTION '每次纠正一条原入账凭据';END IF;
  48. SELECT * INTO src FROM "CreditTransaction" WHERE company=co AND "objectId"=p->>'sourceTransactionId' AND kind='fund' AND "storeId"=st."legacyUserId";
  49. IF NOT FOUND THEN RAISE EXCEPTION '原总部入账凭据不存在或不属于该门店';END IF;
  50. remaining=src.amount-COALESCE((SELECT sum(amount) FROM "CreditTransaction" WHERE company=co AND "sourceTransactionId"=src."objectId" AND kind='adjust' AND "studentId" IS NULL),0);
  51. END IF;
  52. FOR item IN SELECT value FROM jsonb_array_elements(p->'lines') ORDER BY value->>'account' LOOP
  53. a=item->>'account';n=(item->>'amount')::numeric;
  54. IF a IS NULL OR a NOT IN ('Purse','SilverCoin','UserExp','UserPoint') OR n IS NULL OR n<=0 OR n>1000000000 OR n::text IN ('NaN','Infinity','-Infinity') OR round(n,2)<>n OR (a<>'UserPoint' AND trunc(n)<>n) THEN RAISE EXCEPTION '课时和体验次数必须为正整数,陪练小时最多两位小数';END IF;
  55. before_value=COALESCE((st."legacyUserData"->>a)::numeric,0);held=xs_reserved(co,st."legacyUserId"::numeric,a);
  56. IF direction='reverse' THEN
  57. IF a<>src.account OR n>remaining THEN RAISE EXCEPTION '超过原入账凭据可纠正数量';END IF;
  58. IF before_value-held<n THEN RAISE EXCEPTION '门店可用额度不足,已划拨或预留部分不能扣回';END IF;
  59. n=-n;
  60. END IF;
  61. lines=lines||jsonb_build_array(jsonb_build_object('account',a,'delta',n,'before',before_value,'after',before_value+n,'availableBefore',before_value-held,'availableAfter',before_value-held+n));
  62. END LOOP;
  63. version=md5(jsonb_build_object('balances',st."legacyUserData",'updated',st."updatedAt",'lines',lines,'source',src."objectId",'remaining',remaining)::text);
  64. RETURN jsonb_build_object('storeId',st."legacyUserId",'storeObjectId',target,'version',version,'lines',lines,'sourceTransactionId',src."objectId");
  65. END $$;
  66. CREATE OR REPLACE FUNCTION xs_store_credit_post(co text,actor text,target text,p jsonb,reason text,idem text,expected text) RETURNS jsonb LANGUAGE plpgsql AS $$
  67. DECLARE prior "StoreCreditOperation"; req jsonb; plan jsonb; line jsonb; receipt jsonb; receipts jsonb='[]'; oid text=xs_object_id(); tx text; delta numeric;
  68. BEGIN
  69. IF NOT xs_store_credit_allowed(co,actor) THEN RAISE EXCEPTION '当前账号未获门店额度充值权限';END IF;
  70. IF length(COALESCE(idem,''))<8 OR length(trim(COALESCE(reason,'')))<2 OR length(reason)>500 THEN RAISE EXCEPTION '请填写操作原因和有效请求编号';END IF;
  71. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  72. req=jsonb_build_object('actor',actor,'target',target,'payload',p,'reason',reason);
  73. SELECT * INTO prior FROM "StoreCreditOperation" WHERE company=co AND "idempotencyKey"=idem;
  74. IF FOUND THEN IF prior.request<>req THEN RAISE EXCEPTION '请求编号已用于其他充值';END IF;RETURN prior.result;END IF;
  75. plan=xs_store_credit_plan(co,actor,target,p);
  76. IF COALESCE(expected,'')='' OR expected<>plan->>'version' THEN RAISE EXCEPTION '余额已变化,请重新预览后提交';END IF;
  77. FOR line IN SELECT value FROM jsonb_array_elements(plan->'lines') LOOP
  78. delta=(line->>'delta')::numeric;
  79. IF delta>0 THEN
  80. receipt=xs_credit_post(co,actor,'fund',target,line->>'account',delta,'',reason,p->>'payMethod','store:'||idem||':'||(line->>'account'));
  81. UPDATE "CreditTransaction" SET "operationId"=oid WHERE "objectId"=receipt->>'objectId';
  82. ELSE
  83. tx=xs_object_id();PERFORM set_config('xiaoshu.credit_write','yes',true);
  84. UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[line->>'account'],to_jsonb((line->>'after')::numeric)),"updatedAt"=now() WHERE "objectId"=target AND company=co;
  85. PERFORM xs_ledger(co,(plan->>'storeId')::numeric,line->>'account',delta,(line->>'before')::numeric,tx,actor,reason,p->>'payMethod');
  86. receipt=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey','store:'||idem||':'||(line->>'account'),'request',req,'kind','adjust','account',line->>'account','amount',-delta,'storeId',plan->'storeId','sourceTransactionId',p->>'sourceTransactionId','operatorId',actor,'reason',reason,'payMethod',p->>'payMethod','operationId',oid,'balances',jsonb_build_object('storeBefore',line->'before','storeAfter',line->'after'));
  87. PERFORM xs_insert('CreditTransaction',receipt);
  88. PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','credit:'||tx,'createdAt',now(),'updatedAt',now(),'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','门店入账纠正','type1','operations-admin','type2','store-credit-reverse','type3','CreditTransaction','clevel',0,'detail',receipt::text,'content1',reason,'content2',actor));
  89. END IF;
  90. receipts=receipts||jsonb_build_array(receipt-'request');
  91. END LOOP;
  92. receipt=jsonb_build_object('operationId',oid,'storeId',plan->'storeId','lines',plan->'lines','transactions',receipts);
  93. INSERT INTO "StoreCreditOperation" VALUES(co,idem,req,receipt,now());RETURN receipt;
  94. END $$;