payroll-integrity.sql 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. -- Requires member-enrollment.sql and review-rewards.sql. One atomic installation.
  2. BEGIN;
  3. SELECT set_config('xiaoshu.payroll_write','yes',true);
  4. LOCK TABLE "PayrollBatch", "PayrollLine" IN SHARE ROW EXCLUSIVE MODE;
  5. CREATE INDEX IF NOT EXISTS payroll_common_item ON "CommonModel" ("company","modelId","itemId","objectId");
  6. CREATE INDEX IF NOT EXISTS payroll_common_general_text ON "CommonModel" ("company","modelId",("generalId"::text));
  7. CREATE INDEX IF NOT EXISTS payroll_appointment_item ON "CourseAppointment" ("company","id");
  8. ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "standardLessonKey" text;
  9. ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "coachObjectId" text;
  10. ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "coachMobile" text;
  11. ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 0;
  12. ALTER TABLE "PayrollBatch" ALTER COLUMN "version" SET DEFAULT 0;
  13. UPDATE "PayrollBatch" SET "version"=0 WHERE "version" IS NULL;
  14. ALTER TABLE "PayrollBatch" ALTER COLUMN "version" SET NOT NULL;
  15. ALTER TABLE "TeachingCompensationRule" ADD COLUMN IF NOT EXISTS "revision" integer NOT NULL DEFAULT 1;
  16. ALTER TABLE "TeachingCompensationRule" ADD COLUMN IF NOT EXISTS "supersedesId" text;
  17. ALTER TABLE "TeachingCompensationRule" ALTER COLUMN "revision" SET DEFAULT 1;
  18. ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "categoryEvidence" text;
  19. CREATE TABLE IF NOT EXISTS "PayrollCommand" (
  20. "company" text NOT NULL,"requestId" text NOT NULL,"request" jsonb NOT NULL,"result" jsonb NOT NULL,
  21. "createdAt" timestamptz NOT NULL DEFAULT now(),PRIMARY KEY("company","requestId")
  22. );
  23. CREATE TABLE IF NOT EXISTS "PayrollReconciliation" (
  24. "company" text NOT NULL,"appointmentId" text NOT NULL,"amount" numeric(12,2) NOT NULL,
  25. "transactionId" text NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now(),PRIMARY KEY("company","appointmentId")
  26. );
  27. CREATE OR REPLACE FUNCTION xs_payroll_time(v text) RETURNS timestamptz LANGUAGE plpgsql IMMUTABLE AS $$
  28. BEGIN
  29. IF NULLIF(trim(v),'') IS NULL THEN RETURN NULL; END IF;
  30. IF v ~ '(Z|[+-][0-9]{2}(:?[0-9]{2})?)$' AND v ~ '[ T]' THEN RETURN v::timestamptz; END IF;
  31. RETURN v::timestamp AT TIME ZONE 'Asia/Shanghai';
  32. EXCEPTION WHEN OTHERS THEN RETURN NULL;
  33. END $$;
  34. -- Canonical completion records are authoritative, including tombstones. Facts only
  35. -- supplement records absent from the canonical table; a deletion cannot reappear.
  36. CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$
  37. WITH canonical AS (
  38. SELECT (to_jsonb(l)-'kzsj')||jsonb_build_object(
  39. 'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
  40. 'standardLessonKey',CASE WHEN COALESCE(c."generalId",l."legacyGeneralId",0)>0 THEN 'legacy:'||COALESCE(c."generalId",l."legacyGeneralId")::text ELSE 'lesson:'||l."objectId" END,
  41. 'source','canonical','commonStatus',COALESCE(c."status",99),
  42. 'lessonAt',COALESCE(l."lessonAt",xs_payroll_time(l."plpjsj"),xs_payroll_time(a."jssj"),l."sourceCreatedAt",c."createdAt"),
  43. 'courseCategoryKey',COALESCE(NULLIF(l."courseCategoryKey",''),a."courseCategoryKey"),
  44. 'courseCategoryName',COALESCE(NULLIF(l."courseCategoryName",''),a."courseCategoryName"),
  45. 'teacherPaySnapshot',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),
  46. 'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(l."jffs",''),a."deliveryMode",a."jffs"),
  47. 'teachingRuleId',a."teachingRuleId",'studentName',COALESCE(NULLIF(l."studentName",''),c."title"),
  48. 'courseName',COALESCE(NULLIF(l."courseName",''),NULLIF(l."kcmc",''),c."subtitle")
  49. ) AS row_data
  50. FROM "LessonRecord" l
  51. LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c."company"=co AND c."modelId"=59 AND c."itemId"=l."id" ORDER BY c."objectId" LIMIT 1) c ON true
  52. LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=l."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true
  53. WHERE l."company"=co
  54. ), fallback AS (
  55. SELECT (to_jsonb(f)-'kzsj')||jsonb_build_object('lessonObjectId',f."objectId",'source','historical-fact','commonStatus',99,'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'teacherPaySnapshot',a."teacherPaySnapshot",'durationMinutes',a."durationMinutes",'deliveryMode',COALESCE(NULLIF(f."jffs",''),a."deliveryMode",a."jffs"),'teachingRuleId',a."teachingRuleId",
  56. 'standardLessonKey',CASE WHEN COALESCE(f."legacyGeneralId",0)>0 THEN 'legacy:'||f."legacyGeneralId"::text ELSE 'fact:'||f."objectId" END) AS row_data
  57. FROM "LessonRecordFact" f LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=f."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true WHERE f."company"=co AND NOT EXISTS(SELECT 1 FROM canonical c WHERE c.row_data->>'legacyGeneralId'=f."legacyGeneralId"::text AND COALESCE(f."legacyGeneralId",0)>0)
  58. ) SELECT row_data FROM canonical UNION ALL SELECT row_data FROM fallback
  59. $$;
  60. CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql STABLE AS $$
  61. SELECT md5(COALESCE((SELECT string_agg(s::text,'|' ORDER BY s::text) FROM xs_payroll_sources(co) s),'')||
  62. COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
  63. COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||
  64. COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CoachPayRate" p WHERE p."company"=co),''))
  65. $$;
  66. CREATE OR REPLACE FUNCTION xs_payroll_audit(co text,actor text,action text,target text,reason text,before_data jsonb,after_data jsonb) RETURNS void LANGUAGE plpgsql AS $$
  67. BEGIN
  68. PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),
  69. 'sourceKey','payroll-audit:'||xs_object_id(),'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','工资结算',
  70. 'type1','operations-admin','type2',action,'type3','PayrollBatch','clevel',0,'cadminId',(SELECT "legacyUserId" FROM "_User" WHERE "objectId"=actor),
  71. 'detail',jsonb_build_object('action',action,'target',target,'actor',actor,'reason',reason,'before',before_data,'after',after_data)::text,'content1',reason,'content2',actor));
  72. END $$;
  73. DROP TRIGGER IF EXISTS payroll_reward_guard ON "PayrollLine";
  74. UPDATE "PayrollLine" SET "standardLessonKey"=CASE WHEN COALESCE("legacyGeneralId",0)>0 THEN 'legacy:'||"legacyGeneralId"::text ELSE 'lesson:'||"lessonId" END
  75. WHERE "kind"='lesson' AND "standardLessonKey" IS NULL;
  76. -- Deliberately fails on conflicting history. Never erase or silently exclude paid lines.
  77. CREATE UNIQUE INDEX IF NOT EXISTS payroll_lesson_once ON "PayrollLine" ("company","standardLessonKey") WHERE "kind"='lesson' AND COALESCE("excluded",false)=false;
  78. CREATE OR REPLACE FUNCTION xiaoshu_payroll_reward_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  79. DECLARE b "PayrollBatch"; r "ReviewReward";
  80. BEGIN
  81. IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资明细只能通过工资业务事务修改'; END IF;
  82. IF TG_OP='DELETE' THEN SELECT * INTO b FROM "PayrollBatch" WHERE "company"=OLD."company" AND "objectId"=OLD."batchId" FOR UPDATE;
  83. ELSE SELECT * INTO b FROM "PayrollBatch" WHERE "company"=NEW."company" AND "objectId"=NEW."batchId" FOR UPDATE; END IF;
  84. IF b."objectId" IS NULL OR b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定或不存在'; END IF;
  85. IF TG_OP='DELETE' THEN RETURN OLD; END IF;
  86. IF TG_OP='UPDATE' AND ROW(OLD."company",OLD."batchId",OLD."kind",OLD."reviewRewardId",OLD."standardLessonKey") IS DISTINCT FROM ROW(NEW."company",NEW."batchId",NEW."kind",NEW."reviewRewardId",NEW."standardLessonKey") THEN RAISE EXCEPTION '不能修改工资明细归属'; END IF;
  87. IF NEW."kind" IS NULL OR NEW."kind" NOT IN ('lesson','review_reward','adjustment') OR NEW."amount" IS NULL OR NEW."amount"::text IN ('NaN','Infinity','-Infinity') OR round(NEW."amount"::numeric,2)<>NEW."amount"::numeric THEN RAISE EXCEPTION '工资类型或金额无效'; END IF;
  88. IF NEW."kind"='lesson' AND NULLIF(NEW."standardLessonKey",'') IS NULL THEN RAISE EXCEPTION '课次缺少标准标识'; END IF;
  89. NEW."settlementMonth":=b."month";
  90. IF NEW."kind"='review_reward' THEN
  91. SELECT * INTO r FROM "ReviewReward" WHERE "company"=b."company" AND "objectId"=NEW."reviewRewardId";
  92. IF r."status" IS DISTINCT FROM 'earned' OR r."incomeMonth">b."month" OR NEW."excluded" THEN RAISE EXCEPTION '抗遗忘工资不可结算或排除'; END IF;
  93. NEW."coachId":=r."coachId";NEW."coachName":=r."coachName";NEW."studentName":=r."studentName";NEW."courseName":=r."courseName";
  94. NEW."storeId":=r."storeId";NEW."storeName":=r."storeName";NEW."reviewTaskId":=r."taskId";NEW."plannedDate":=r."plannedDate";
  95. NEW."amount":=r."amount";NEW."rateAmount":=r."amount";NEW."durationHours":=0;NEW."originalMonth":=r."incomeMonth";
  96. NEW."lessonAt":=r."completedAt";NEW."exception":='';NEW."classType":=0;NEW."courseCategoryName":='抗遗忘工资';
  97. END IF;
  98. RETURN NEW;
  99. END $$;
  100. CREATE TRIGGER payroll_reward_guard BEFORE INSERT OR UPDATE OR DELETE ON "PayrollLine" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_reward_guard();
  101. CREATE OR REPLACE FUNCTION xs_payroll_batch_write_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  102. BEGIN
  103. IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资批次只能通过工资业务事务修改'; END IF;
  104. IF TG_OP='INSERT' AND NEW."status" IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION '工资批次必须从草稿开始'; END IF;
  105. IF TG_OP='DELETE' THEN RETURN OLD;END IF;RETURN NEW;
  106. END $$;
  107. DROP TRIGGER IF EXISTS payroll_batch_write_guard ON "PayrollBatch";
  108. CREATE TRIGGER payroll_batch_write_guard BEFORE INSERT OR UPDATE OR DELETE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xs_payroll_batch_write_guard();
  109. CREATE OR REPLACE FUNCTION xiaoshu_payroll_totals_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  110. BEGIN
  111. IF current_setting('xiaoshu.payroll_write',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资批次只能通过工资业务事务修改'; END IF;
  112. IF NEW."company" IS DISTINCT FROM OLD."company" OR NEW."month" IS DISTINCT FROM OLD."month" THEN RAISE EXCEPTION '不能修改工资批次归属'; END IF;
  113. IF OLD."status" IN ('reviewed','paid') THEN
  114. IF NOT (NEW."status"=OLD."status" OR OLD."status"='reviewed' AND NEW."status"='paid') THEN RAISE EXCEPTION '已审核工资不可重新打开'; END IF;
  115. IF ROW(NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."payableAmount",NEW."lessonCount",NEW."reviewCount",NEW."coachCount",NEW."exceptionCount",NEW."totalHours") IS DISTINCT FROM ROW(OLD."baseAmount",OLD."reviewAmount",OLD."adjustmentAmount",OLD."payableAmount",OLD."lessonCount",OLD."reviewCount",OLD."coachCount",OLD."exceptionCount",OLD."totalHours") THEN RAISE EXCEPTION '已审核工资金额不可修改'; END IF;
  116. ELSE
  117. IF NEW."status" NOT IN ('draft','reviewed') THEN RAISE EXCEPTION '工资状态转换无效'; END IF;
  118. SELECT COALESCE(sum("amount"::numeric) FILTER(WHERE "kind"='lesson'),0),COALESCE(sum("amount"::numeric) FILTER(WHERE "kind"='review_reward'),0),COALESCE(sum("amount"::numeric) FILTER(WHERE "kind"='adjustment'),0),count(*) FILTER(WHERE "kind"='lesson'),count(*) FILTER(WHERE "kind"='review_reward'),count(DISTINCT "coachId"),COALESCE(sum("durationHours") FILTER(WHERE "kind"='lesson'),0)
  119. INTO NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."lessonCount",NEW."reviewCount",NEW."coachCount",NEW."totalHours"
  120. FROM "PayrollLine" WHERE "company"=NEW."company" AND "batchId"=NEW."objectId" AND NOT COALESCE("excluded",false) AND COALESCE("exception",'')='';
  121. SELECT count(*) INTO NEW."exceptionCount" FROM "PayrollLine" WHERE "company"=NEW."company" AND "batchId"=NEW."objectId" AND NOT COALESCE("excluded",false) AND COALESCE("exception",'')<>'';
  122. NEW."payableAmount":=NEW."baseAmount"+NEW."reviewAmount"+NEW."adjustmentAmount";
  123. IF NEW."status"='reviewed' AND NEW."exceptionCount">0 THEN RAISE EXCEPTION '仍有异常明细,不能审核'; END IF;
  124. END IF;
  125. IF NEW."status"='paid' AND (NULLIF(trim(NEW."paymentReference"),'') IS NULL OR NULLIF(trim(NEW."paymentMethod"),'') IS NULL) THEN RAISE EXCEPTION '发放方式和凭证编号必填'; END IF;
  126. NEW."version":=COALESCE(OLD."version",0)+1;NEW."updatedAt":=clock_timestamp();
  127. RETURN NEW;
  128. END $$;
  129. CREATE OR REPLACE FUNCTION xs_payroll_command(co text,actor text,op text,target text,req jsonb,idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
  130. DECLARE b "PayrollBatch"; previous jsonb; saved "PayrollCommand"; line jsonb; candidate "PayrollLine"; key text; result jsonb; u "_User"; st "_User";
  131. BEGIN
  132. IF co IS NULL OR length(COALESCE(idem,''))<8 OR length(trim(COALESCE(req->>'reason','')))<2 THEN RAISE EXCEPTION '缺少帐套、操作原因或请求编号'; END IF;
  133. PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
  134. PERFORM set_config('xiaoshu.payroll_write','yes',true);
  135. req:=req||jsonb_build_object('actor',actor,'operation',op,'target',target);
  136. SELECT * INTO saved FROM "PayrollCommand" WHERE "company"=co AND "requestId"=idem;
  137. IF FOUND THEN IF saved."request" IS DISTINCT FROM (req-'lines'-'sourceVersion') THEN RAISE EXCEPTION '请求编号已用于不同操作'; END IF;RETURN saved."result";END IF;
  138. IF op='create' THEN
  139. IF COALESCE(req->>'month','') !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
  140. INSERT INTO "PayrollBatch" ("objectId","company","sourceKey","month","status","createdAt","updatedAt","createdBy") VALUES(xs_object_id(),co,'payroll:'||co||':'||(req->>'month'),req->>'month','draft',now(),now(),actor) RETURNING * INTO b;
  141. ELSE
  142. SELECT * INTO b FROM "PayrollBatch" WHERE "company"=co AND "objectId"=target FOR UPDATE;
  143. IF NOT FOUND THEN RAISE EXCEPTION '工资单不存在'; END IF;
  144. IF req->>'version' IS NULL OR (req->>'version')::int<>b."version" THEN RAISE EXCEPTION '工资单已更新,请刷新后重试'; END IF;
  145. END IF;
  146. previous:=to_jsonb(b);
  147. IF op IN ('create','refresh','review') THEN
  148. IF b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定'; END IF;
  149. IF req->>'sourceVersion' IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '计薪来源已更新,请重新核对'; END IF;
  150. -- Only automated lines are rebuilt; explicit exclusions and adjustments survive.
  151. DELETE FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "kind" IN ('lesson','review_reward') AND NOT COALESCE("excluded",false);
  152. FOR line IN SELECT value FROM jsonb_array_elements(COALESCE(req->'lines','[]')) LOOP
  153. IF line->>'kind' NOT IN ('lesson','review_reward') THEN RAISE EXCEPTION '自动明细类型无效'; END IF;
  154. IF line->>'kind'='lesson' AND EXISTS(SELECT 1 FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "standardLessonKey"=line->>'standardLessonKey' AND COALESCE("excluded",false)) THEN CONTINUE; END IF;
  155. line:=line||jsonb_build_object('objectId',xs_object_id(),'company',co,'batchId',b."objectId",'settlementMonth',b."month",'createdAt',now(),'updatedAt',now(),'excluded',false,'sourceKey','payroll-line:'||xs_object_id());
  156. candidate:=jsonb_populate_record(NULL::"PayrollLine",line); INSERT INTO "PayrollLine" SELECT candidate.*;
  157. END LOOP;
  158. IF op='review' THEN b."status":='reviewed'; END IF;
  159. ELSIF op='adjustment' THEN
  160. IF b."status"<>'draft' THEN RAISE EXCEPTION '只有草稿可以调整'; END IF;
  161. IF (req->>'amount')::numeric=0 OR abs((req->>'amount')::numeric)>1000000000 OR round((req->>'amount')::numeric,2)<>(req->>'amount')::numeric THEN RAISE EXCEPTION '调整金额无效,最多两位小数'; END IF;
  162. SELECT * INTO u FROM "_User" WHERE "company"=co AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=req->>'coachId' AND ("identityType"='coach' OR "identityType" IS NULL AND COALESCE("legacyGroupId"::text,"legacyUserData"->>'GroupID')='3');
  163. IF NOT FOUND THEN RAISE EXCEPTION '陪练不存在'; END IF;
  164. SELECT * INTO st FROM "_User" WHERE "company"=co AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=u."legacyUserData"->>'ParentUserID' LIMIT 1;
  165. INSERT INTO "PayrollLine" ("objectId","company","batchId","sourceKey","kind","coachId","coachObjectId","coachName","storeId","storeName","amount","rateAmount","durationHours","originalMonth","settlementMonth","description","exception","createdAt","updatedAt")
  166. VALUES(xs_object_id(),co,b."objectId",'adjustment:'||idem,'adjustment',(req->>'coachId')::numeric,u."objectId",COALESCE(NULLIF(u."realName",''),NULLIF(u."nickname",''),u."username"),COALESCE(st."legacyUserId",0),COALESCE(NULLIF(st."realName",''),NULLIF(st."nickname",''),st."username",'未绑定门店'),(req->>'amount')::numeric,0,0,b."month",b."month",req->>'reason','',now(),now());
  167. ELSIF op='exclude' THEN
  168. UPDATE "PayrollLine" SET "excluded"=true,"exclusionReason"=req->>'reason',"updatedAt"=now() WHERE "company"=co AND "batchId"=b."objectId" AND "objectId"=req->>'lineId' AND "kind"='lesson' AND COALESCE("exception",'')<>'';
  169. IF NOT FOUND THEN RAISE EXCEPTION '只能排除异常课次'; END IF;
  170. ELSIF op='mark-paid' THEN
  171. IF b."status"<>'reviewed' THEN RAISE EXCEPTION '只有已审核工资单可登记发放'; END IF;b."status":='paid';
  172. ELSE RAISE EXCEPTION '工资操作无效'; END IF;
  173. UPDATE "PayrollBatch" SET "status"=b."status","sourceRefreshedAt"=CASE WHEN op IN ('create','refresh','review') THEN now() ELSE "sourceRefreshedAt" END,
  174. "reviewedAt"=CASE WHEN op='review' THEN now() ELSE "reviewedAt" END,"reviewedBy"=CASE WHEN op='review' THEN actor ELSE "reviewedBy" END,
  175. "paidAt"=CASE WHEN op='mark-paid' THEN now() ELSE "paidAt" END,"paidBy"=CASE WHEN op='mark-paid' THEN actor ELSE "paidBy" END,
  176. "paymentMethod"=CASE WHEN op='mark-paid' THEN req->>'paymentMethod' ELSE "paymentMethod" END,"paymentReference"=CASE WHEN op='mark-paid' THEN req->>'paymentReference' ELSE "paymentReference" END
  177. WHERE "company"=co AND "objectId"=b."objectId" RETURNING to_jsonb("PayrollBatch".*) INTO result;
  178. PERFORM xs_payroll_audit(co,actor,op,b."objectId",req->>'reason',previous,result);
  179. INSERT INTO "PayrollCommand" ("company","requestId","request","result") VALUES(co,idem,req-'lines'-'sourceVersion',result);
  180. RETURN result;
  181. END $$;
  182. CREATE OR REPLACE FUNCTION xs_payroll_rule(co text,actor text,req jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
  183. DECLARE r "TeachingCompensationRule"; old "TeachingCompensationRule"; rev integer; result jsonb;
  184. BEGIN
  185. PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
  186. SELECT * INTO old FROM "TeachingCompensationRule" WHERE "company"=co AND "categoryKey"=req->>'categoryKey' AND "durationMinutes"=(req->>'durationMinutes')::numeric AND "deliveryMode"=req->>'deliveryMode' AND "effectiveFrom"=req->>'effectiveFrom' ORDER BY "revision" DESC,"createdAt" DESC LIMIT 1;
  187. IF COALESCE(old."objectId",'')<>COALESCE(req->>'supersedesId','') THEN RAISE EXCEPTION '工资规则已有新版本,请刷新'; END IF;
  188. rev:=COALESCE(old."revision",0)+1;
  189. r:=jsonb_populate_record(NULL::"TeachingCompensationRule",req||jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','payroll-rule:'||xs_object_id(),'createdAt',now(),'updatedAt',now(),'revision',rev,'supersedesId',old."objectId"));
  190. INSERT INTO "TeachingCompensationRule" SELECT r.*;
  191. result:=to_jsonb(r);PERFORM xs_payroll_audit(co,actor,'save-rule',r."objectId",req->>'reason',to_jsonb(old),result);RETURN result;
  192. END $$;
  193. CREATE OR REPLACE FUNCTION xs_payroll_rule_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
  194. BEGIN RAISE EXCEPTION '工资规则版本不可改写,请保存新版本';END $$;
  195. DROP TRIGGER IF EXISTS payroll_rule_immutable ON "TeachingCompensationRule";
  196. CREATE TRIGGER payroll_rule_immutable BEFORE UPDATE OR DELETE ON "TeachingCompensationRule" FOR EACH ROW EXECUTE FUNCTION xs_payroll_rule_immutable();
  197. -- Keep the legacy class-type endpoint compatible without allowing old revisions
  198. -- to be overwritten or a successful write to escape the transaction audit.
  199. CREATE OR REPLACE FUNCTION xs_payroll_legacy_rate(co text,actor text,req jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
  200. DECLARE r "CoachPayRate"; old "CoachPayRate";
  201. BEGIN
  202. PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
  203. SELECT * INTO old FROM "CoachPayRate" WHERE "company"=co AND "classType"=(req->>'classType')::numeric AND "effectiveFrom"=req->>'effectiveFrom' ORDER BY "createdAt" DESC,"objectId" DESC LIMIT 1;
  204. r:=jsonb_populate_record(NULL::"CoachPayRate",req||jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','payroll-legacy-rate:'||xs_object_id(),'createdAt',clock_timestamp(),'updatedAt',clock_timestamp()));
  205. INSERT INTO "CoachPayRate" SELECT r.*;
  206. PERFORM xs_payroll_audit(co,actor,'save-legacy-rate',r."objectId",req->>'reason',to_jsonb(old),to_jsonb(r));RETURN to_jsonb(r);
  207. END $$;
  208. DROP TRIGGER IF EXISTS payroll_legacy_rate_immutable ON "CoachPayRate";
  209. CREATE TRIGGER payroll_legacy_rate_immutable BEFORE UPDATE OR DELETE ON "CoachPayRate" FOR EACH ROW EXECUTE FUNCTION xs_payroll_rule_immutable();
  210. CREATE OR REPLACE FUNCTION xs_payroll_reservations(co text,actor text,items jsonb,reason text,source_version text) RETURNS jsonb LANGUAGE plpgsql AS $$
  211. DECLARE item jsonb;c "CommonModel";a "CourseAppointment";u "_User";cost jsonb;reserved integer=0;held integer=0;status text;why text;costs jsonb;existing "CourseCreditReservation";
  212. BEGIN
  213. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  214. IF source_version IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '工资或课程规则已更新,请重新核对'; END IF;
  215. IF jsonb_array_length(items)=0 THEN RAISE EXCEPTION '请选择预约';END IF;
  216. FOR item IN SELECT value FROM jsonb_array_elements(items) LOOP
  217. SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=item->>'appointmentId' AND "modelId"=54 FOR UPDATE;
  218. IF NOT FOUND OR c."status"=-2 THEN RAISE EXCEPTION '预约已取消或不存在,请重新核对';END IF;
  219. SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"::text=c."itemId"::text FOR UPDATE;
  220. IF a."updatedAt" IS DISTINCT FROM (item->>'expectedUpdatedAt')::timestamptz OR a."dszt" NOT IN ('0','10') THEN RAISE EXCEPTION '预约已更新,请重新核对';END IF;
  221. IF a."dszt"='0' AND xs_payroll_time(a."yysj")<now() THEN RAISE EXCEPTION '预约已经到期,请重新核对';END IF;
  222. SELECT * INTO existing FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=c."objectId" AND "state" IN ('reserved','consumed') LIMIT 1;
  223. IF FOUND THEN CONTINUE;END IF;
  224. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  225. costs:=COALESCE(item->'creditCosts','[]');status:=item->>'status';why:=item->>'reason';
  226. IF status IN ('ready','insufficient') THEN
  227. status:='ready';why:='';
  228. IF u."objectId" IS NULL THEN status:='missing_student';why:='学员不存在'; END IF;
  229. FOR cost IN SELECT value FROM jsonb_array_elements(costs) LOOP
  230. IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId",cost->>'account')<(cost->>'amount')::numeric THEN status:='insufficient';why:='可用课时不足,请补充课时后重新核对';END IF;
  231. END LOOP;
  232. END IF;
  233. UPDATE "CourseAppointment" SET "creditHoldStatus"=status,"creditHoldReason"=why,
  234. "courseCategoryKey"=COALESCE(NULLIF(item->>'categoryKey',''),"courseCategoryKey"),"courseCategoryName"=COALESCE(NULLIF(item->>'categoryName',''),"courseCategoryName"),
  235. "teachingRuleId"=COALESCE(NULLIF(item->>'teachingRuleId',''),"teachingRuleId"),"teacherPaySnapshot"=COALESCE("teacherPaySnapshot",(item->>'teacherPay')::numeric),
  236. "creditCostsSnapshot"=CASE WHEN jsonb_array_length(costs)>0 THEN costs ELSE "creditCostsSnapshot" END,"durationMinutes"=(item->>'durationMinutes')::numeric,"deliveryMode"=item->>'deliveryMode',"updatedAt"=now() WHERE "objectId"=a."objectId";
  237. IF status='ready' THEN
  238. PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','payroll-reservation:'||xs_object_id(),'createdAt',now(),'updatedAt',now(),'appointmentId',c."objectId",'appointmentGeneralId',c."generalId",'studentId',u."legacyUserId",'ruleId',item->>'teachingRuleId','creditCosts',costs,'state','reserved','reservedAt',now()));reserved:=reserved+1;
  239. ELSE held:=held+1;END IF;
  240. PERFORM xs_payroll_audit(co,actor,'reserve-history',c."objectId",reason,to_jsonb(a),jsonb_build_object('status',status,'costs',costs));
  241. END LOOP;
  242. RETURN jsonb_build_object('reserved',reserved,'held',held);
  243. END $$;
  244. CREATE OR REPLACE FUNCTION xs_payroll_refund_rows(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$
  245. WITH rows AS (
  246. SELECT c."objectId" AS aid,c."generalId",a."szyh",a."yysj",COALESCE(a."durationMinutes",60) AS duration,
  247. COALESCE(NULLIF((SELECT sum((v->>'amount')::numeric) FROM jsonb_array_elements(COALESCE(a."creditCostsSnapshot"::jsonb,'[]')) v WHERE v->>'account'='UserPoint'),0),CASE WHEN a."durationMinutes"=30 THEN .5 ELSE 1 END) AS expected,
  248. COALESCE((SELECT sum(-l."score") FROM "UserUserPoint" l WHERE l."company"=co AND l."score"<0 AND (l."sourceKey" IN ('cloud:e_order_update:'||c."generalId"::text||':period','cloud:e_order_update:'||c."generalId"::text||':configured:UserPoint','cloud:ops-complete:'||c."objectId"||':point') OR EXISTS(SELECT 1 FROM "LessonRecord" lr WHERE lr."company"=co AND lr."yyds"::text=c."generalId"::text AND lr."sourceKey"=l."transactionId"))),0) AS charged,
  249. COALESCE((SELECT sum(l."score") FROM "UserUserPoint" l WHERE l."company"=co AND l."score">0 AND l."sourceKey"='cloud:credit-reconciliation:'||co||':'||c."generalId"::text),0)+COALESCE((SELECT sum(r."amount") FROM "PayrollReconciliation" r WHERE r."company"=co AND r."appointmentId"=c."objectId"),0) AS refunded
  250. FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=co AND a."id"::text=c."itemId"::text
  251. WHERE c."company"=co AND c."modelId"=54 AND c."status"<>-2 AND a."courseCategoryKey"='review' AND left(COALESCE(a."yysj",a."bxrq"),10)>='2026-09-01' AND a."dszt" IN ('11','20','30','40'))
  252. SELECT jsonb_build_object('appointmentId',aid,'appointmentGeneralId',"generalId",'studentId',"szyh",'lessonAt',"yysj",'durationMinutes',duration,'expected',expected,'charged',charged,'refunded',refunded,'adjustment',GREATEST(0,charged-expected-refunded),'status',CASE WHEN refunded>0 THEN '已处理' WHEN charged>expected THEN '待退回' ELSE '无差异' END) FROM rows
  253. $$;
  254. CREATE OR REPLACE FUNCTION xs_payroll_refunds(co text,actor text,ids jsonb,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
  255. DECLARE item jsonb;u "_User";delta numeric;before_value numeric;tx text;count integer=0;total numeric=0;
  256. BEGIN
  257. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));PERFORM set_config('xiaoshu.credit_write','yes',true);
  258. FOR item IN SELECT s FROM xs_payroll_refund_rows(co) s WHERE ids ? (s->>'appointmentId') LOOP
  259. delta:=(item->>'adjustment')::numeric;
  260. IF delta<=0 OR EXISTS(SELECT 1 FROM "PayrollReconciliation" WHERE "company"=co AND "appointmentId"=item->>'appointmentId') THEN CONTINUE; END IF;
  261. SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=item->>'studentId' FOR UPDATE;
  262. IF NOT FOUND THEN RAISE EXCEPTION '学员不存在';END IF;
  263. before_value:=COALESCE((u."legacyUserData"->>'UserPoint')::numeric,0);tx:=xs_object_id();
  264. PERFORM xs_ledger(co,u."legacyUserId",'UserPoint',delta,before_value,tx,actor,reason);
  265. UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),'{UserPoint}',to_jsonb(before_value+delta)),"updatedAt"=now() WHERE "objectId"=u."objectId";
  266. INSERT INTO "CreditTransaction" ("objectId","company","createdAt","updatedAt","idempotencyKey","request","kind","account","amount","studentId","operatorId","reason","balances") VALUES(tx,co,now(),now(),'reconcile:'||(item->>'appointmentId'),item,'reconciliation','UserPoint',delta,u."legacyUserId",actor,reason,jsonb_build_object('before',before_value,'after',before_value+delta));
  267. INSERT INTO "PayrollReconciliation" ("company","appointmentId","amount","transactionId") VALUES(co,item->>'appointmentId',delta,tx);
  268. PERFORM xs_payroll_audit(co,actor,'credit-reconciliation',item->>'appointmentId',reason,item,jsonb_build_object('refund',delta,'transactionId',tx));count:=count+1;total:=total+delta;
  269. END LOOP;
  270. RETURN jsonb_build_object('committed',count,'refundTotal',total);
  271. END $$;
  272. CREATE OR REPLACE FUNCTION xs_payroll_writing_mapping(co text,actor text,aid text,category text,evidence text,expected timestamptz,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
  273. DECLARE c "CommonModel";a "CourseAppointment";label text;
  274. BEGIN
  275. PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  276. SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54 FOR UPDATE;
  277. SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"::text=c."itemId"::text FOR UPDATE;
  278. label:=CASE category WHEN 'primary_writing' THEN '小学语法写作课' WHEN 'middle_writing' THEN '中学语法写作课' WHEN 'high_writing' THEN '高中语法写作课' END;
  279. IF c."objectId" IS NULL OR c."status"=-2 OR label IS NULL OR c."subtitle" !~ '阅读|写作|语法|作文|强化' OR length(trim(evidence))<2 THEN RAISE EXCEPTION '预约、语法写作分类或学段证据无效';END IF;
  280. IF a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请重新核对';END IF;
  281. IF COALESCE(a."courseCategoryKey",'')<>'' AND a."courseCategoryKey"<>category AND (a."teacherPaySnapshot" IS NOT NULL OR a."dszt" NOT IN ('0','10')) THEN RAISE EXCEPTION '已保存计薪快照或已完课,请通过差异调整处理';END IF;
  282. UPDATE "CourseAppointment" SET "courseCategoryKey"=category,"courseCategoryName"=label,"categoryEvidence"=evidence,"updatedAt"=now() WHERE "objectId"=a."objectId";
  283. PERFORM xs_payroll_audit(co,actor,'student-writing-mapping',aid,reason,to_jsonb(a),jsonb_build_object('categoryKey',category,'evidence',evidence));RETURN jsonb_build_object('updated',true);
  284. END $$;
  285. COMMIT;