review-rewards.sql 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. -- Applied as one transaction before publishing the app/operations gateways.
  2. BEGIN;
  3. LOCK TABLE "MemoryPracticeRecord" IN SHARE ROW EXCLUSIVE MODE;
  4. ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "rewardTaskKey" text;
  5. ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "rewardDuplicate" boolean;
  6. ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "reviewRewardId" text;
  7. ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "reviewTaskId" text;
  8. ALTER TABLE "PayrollLine" ADD COLUMN IF NOT EXISTS "plannedDate" text;
  9. ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "reviewCount" double precision;
  10. ALTER TABLE "PayrollBatch" ADD COLUMN IF NOT EXISTS "reviewAmount" double precision;
  11. CREATE TABLE IF NOT EXISTS "ReviewReward" (
  12. "objectId" text PRIMARY KEY, "company" text NOT NULL, "taskId" text NOT NULL,
  13. "taskKey" text, "coachId" double precision NOT NULL DEFAULT 0, "coachName" text NOT NULL DEFAULT '',
  14. "studentId" double precision NOT NULL DEFAULT 0, "studentName" text NOT NULL DEFAULT '',
  15. "courseName" text NOT NULL DEFAULT '', "storeId" double precision NOT NULL DEFAULT 0, "storeName" text NOT NULL DEFAULT '',
  16. "plannedDate" text NOT NULL DEFAULT '', "completedAt" timestamptz, "incomeMonth" text NOT NULL DEFAULT '',
  17. "amount" numeric(12,2) NOT NULL DEFAULT 0, "status" text NOT NULL,
  18. "exception" text NOT NULL DEFAULT '', "createdAt" timestamptz NOT NULL DEFAULT now(),
  19. UNIQUE ("company","taskId"), CHECK ("status" IN ('earned','historical','exception','ineligible')),
  20. CHECK (("status"='earned' AND "amount"=1 AND "completedAt" IS NOT NULL AND "coachId">0) OR ("status"<>'earned' AND "amount"=0))
  21. );
  22. -- Upgrade the original auto-named constraint without rewriting immutable receipts.
  23. ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "ineligibleReason" text NOT NULL DEFAULT '';
  24. ALTER TABLE "ReviewReward" DROP CONSTRAINT IF EXISTS "ReviewReward_status_check";
  25. ALTER TABLE "ReviewReward" ADD CONSTRAINT "ReviewReward_status_check" CHECK (status IN ('earned','historical','exception','ineligible'));
  26. CREATE OR REPLACE FUNCTION xiaoshu_review_eligibility(planned text, finished timestamptz) RETURNS text
  27. LANGUAGE plpgsql IMMUTABLE AS $$
  28. DECLARE day date; actual date := (finished AT TIME ZONE 'Asia/Shanghai')::date;
  29. BEGIN
  30. IF planned IS NULL OR planned !~ '^\d{4}-\d{2}-\d{2}$' OR finished IS NULL THEN RETURN 'invalid_date'; END IF;
  31. BEGIN day := planned::date; EXCEPTION WHEN OTHERS THEN RETURN 'invalid_date'; END;
  32. RETURN CASE WHEN day=actual THEN 'eligible' WHEN day<actual THEN 'overdue' ELSE 'future' END;
  33. END $$;
  34. -- Append-only correction evidence. Receipts and locked payroll documents remain intact.
  35. CREATE TABLE IF NOT EXISTS "ReviewRewardCorrection" (
  36. "objectId" text PRIMARY KEY, company text NOT NULL, "rewardId" text NOT NULL,
  37. "amount" numeric(12,2) NOT NULL CHECK(amount=-1), reason text NOT NULL CHECK(length(trim(reason))>=2),
  38. actor text NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(),
  39. "sourceBatchId" text, "adjustmentBatchId" text, "adjustmentRequestId" text,
  40. UNIQUE(company,"rewardId"), FOREIGN KEY("rewardId") REFERENCES "ReviewReward"("objectId")
  41. );
  42. CREATE OR REPLACE VIEW "ReviewRewardIncome" AS
  43. SELECT projected.*,r.amount AS "originalAmount",c."objectId" AS "correctionId",c.reason AS "correctionReason",c.amount AS "correctionAmount",c."adjustmentBatchId"
  44. FROM "ReviewReward" r LEFT JOIN "ReviewRewardCorrection" c ON c.company=r.company AND c."rewardId"=r."objectId"
  45. CROSS JOIN LATERAL jsonb_populate_record(NULL::"ReviewReward",to_jsonb(r)||jsonb_build_object(
  46. 'amount',r.amount+COALESCE(c.amount,0),'status',CASE WHEN c."objectId" IS NULL THEN r.status ELSE 'ineligible' END,
  47. 'ineligibleReason',CASE WHEN c."objectId" IS NULL THEN r."ineligibleReason" ELSE 'corrected_overdue' END)) projected;
  48. CREATE OR REPLACE FUNCTION xs_review_correction_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  49. BEGIN
  50. IF TG_OP<>'INSERT' OR current_setting('xiaoshu.review_correction',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资纠正凭据只能由核对事务新增,不能改写或删除'; END IF;
  51. RETURN NEW;
  52. END $$;
  53. DROP TRIGGER IF EXISTS review_correction_immutable ON "ReviewRewardCorrection";
  54. CREATE TRIGGER review_correction_immutable BEFORE INSERT OR UPDATE OR DELETE ON "ReviewRewardCorrection" FOR EACH ROW EXECUTE FUNCTION xs_review_correction_guard();
  55. REVOKE ALL ON "ReviewRewardCorrection" FROM PUBLIC;
  56. CREATE UNIQUE INDEX IF NOT EXISTS review_reward_once_per_plan ON "ReviewReward" ("company","taskKey") WHERE "status"='earned';
  57. CREATE INDEX IF NOT EXISTS review_reward_coach_month ON "ReviewReward" ("company","coachId","incomeMonth");
  58. CREATE UNIQUE INDEX IF NOT EXISTS payroll_company_month_once ON "PayrollBatch" ("company","month");
  59. CREATE UNIQUE INDEX IF NOT EXISTS payroll_review_reward_once ON "PayrollLine" ("company","reviewRewardId") WHERE "kind"='review_reward' AND COALESCE("excluded",false)=false;
  60. CREATE OR REPLACE FUNCTION xiaoshu_review_key(student text, study text, day text) RETURNS text
  61. LANGUAGE sql IMMUTABLE AS $$
  62. SELECT CASE WHEN student ~ '^[1-9][0-9]*$' AND study ~ '^[1-9][0-9]*$' AND day ~ '^\d{4}-\d{2}-\d{2}$'
  63. THEN student||':'||study||':'||day ELSE NULL END
  64. $$;
  65. -- Preserve duplicate legacy rows, reserve their identity, and flag all copies for review.
  66. WITH ranked AS (
  67. SELECT "objectId", xiaoshu_review_key("yhid"::text,"xxjlid"::text,left("kywrq"::text,10)) k,
  68. row_number() OVER (PARTITION BY "company","yhid","xxjlid",left("kywrq"::text,10) ORDER BY "objectId") rn,
  69. count(*) OVER (PARTITION BY "company","yhid","xxjlid",left("kywrq"::text,10)) copies
  70. FROM "MemoryPracticeRecord"
  71. )
  72. UPDATE "MemoryPracticeRecord" m SET "rewardTaskKey"=CASE WHEN r.rn=1 THEN r.k END,
  73. "rewardDuplicate"=(r.copies>1 AND r.k IS NOT NULL)
  74. FROM ranked r WHERE m."objectId"=r."objectId" AND m."rewardDuplicate" IS NULL;
  75. CREATE UNIQUE INDEX IF NOT EXISTS memory_review_plan_once ON "MemoryPracticeRecord" ("company","rewardTaskKey") WHERE "rewardTaskKey" IS NOT NULL;
  76. CREATE OR REPLACE FUNCTION xiaoshu_record_review_reward(task "MemoryPracticeRecord", historical boolean) RETURNS void
  77. LANGUAGE plpgsql AS $$
  78. DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text; eligibility text;
  79. BEGIN
  80. IF task."company" IS NULL THEN RAISE EXCEPTION '复习任务缺少帐套'; END IF;
  81. IF EXISTS (SELECT 1 FROM "ReviewReward" WHERE "company"=task."company" AND "taskId"=task."objectId") THEN RETURN; END IF;
  82. SELECT * INTO coach FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=task."plid"::text LIMIT 1;
  83. SELECT * INTO student FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=task."yhid"::text LIMIT 1;
  84. SELECT * INTO store FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=coach."legacyUserData"->>'ParentUserID' LIMIT 1;
  85. SELECT "nodeName" INTO course FROM "Node" WHERE "company"=task."company" AND "nodeId"::text=task."kcid"::text LIMIT 1;
  86. BEGIN
  87. -- The BEFORE trigger overwrites client time on every first live completion.
  88. finished := NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai';
  89. EXCEPTION WHEN OTHERS THEN finished := NULL; END;
  90. IF task."rewardDuplicate" THEN problem := '来源复习任务重复';
  91. ELSIF task."rewardTaskKey" IS NULL OR NOT EXISTS (SELECT 1 FROM "CommonModel" WHERE "company"=task."company" AND "modelId"::text='56' AND "generalId"::text=task."xxjlid"::text) THEN problem := '缺少有效来源学习记录或计划日期';
  92. ELSIF coach."objectId" IS NULL OR COALESCE(task."plid"::text,'0') !~ '^[1-9][0-9]*$' THEN problem := '缺少有效复习老师';
  93. ELSIF student."objectId" IS NULL THEN problem := '缺少有效学员';
  94. ELSIF finished IS NULL THEN problem := '缺少有效完成时间'; END IF;
  95. eligibility := xiaoshu_review_eligibility(left(task."kywrq"::text,10),finished);
  96. IF problem='' AND eligibility='invalid_date' THEN problem := '缺少有效计划日期'; END IF;
  97. reward_status := CASE WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' WHEN eligibility<>'eligible' THEN 'ineligible' ELSE 'earned' END;
  98. INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception","ineligibleReason")
  99. VALUES (md5(task."company"||':review:'||task."objectId"),task."company",task."objectId",task."rewardTaskKey",
  100. CASE WHEN task."plid"::text ~ '^[1-9][0-9]*$' THEN task."plid"::double precision ELSE 0 END,
  101. COALESCE(NULLIF(coach."realName",''),NULLIF(coach."nickname",''),coach."legacyUserData"->>'HoneyName',coach."username",'未识别老师'),
  102. CASE WHEN task."yhid"::text ~ '^[1-9][0-9]*$' THEN task."yhid"::double precision ELSE 0 END,
  103. COALESCE(NULLIF(student."realName",''),NULLIF(student."nickname",''),student."legacyUserData"->>'HoneyName',student."username",'未识别学员'),COALESCE(course,''),
  104. CASE WHEN coach."legacyUserData"->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach."legacyUserData"->>'ParentUserID')::double precision ELSE 0 END,
  105. COALESCE(NULLIF(store."realName",''),NULLIF(store."nickname",''),store."legacyUserData"->>'HoneyName',store."username",'未绑定门店'),
  106. COALESCE(left(task."kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),CASE WHEN reward_status='earned' THEN 1 ELSE 0 END,reward_status,problem,CASE WHEN reward_status='ineligible' THEN eligibility ELSE '' END)
  107. ON CONFLICT ("company","taskId") DO NOTHING;
  108. END $$;
  109. -- Backfill creates zero-value, non-payable evidence; rerunning migration cannot award it.
  110. -- Use one set-based statement so the full history is scanned once instead of issuing
  111. -- several lookup queries for every completed task.
  112. CREATE OR REPLACE FUNCTION xiaoshu_safe_shanghai_timestamp(value text) RETURNS timestamptz
  113. LANGUAGE plpgsql IMMUTABLE AS $$
  114. BEGIN
  115. RETURN NULLIF(value,'')::timestamp AT TIME ZONE 'Asia/Shanghai';
  116. EXCEPTION WHEN OTHERS THEN RETURN NULL;
  117. END $$;
  118. WITH users AS MATERIALIZED (
  119. SELECT DISTINCT ON ("company",COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID'))
  120. "company",COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID') AS legacy_id,
  121. "objectId","realName","nickname","username","legacyUserData"
  122. FROM "_User"
  123. WHERE COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID') IS NOT NULL
  124. ORDER BY "company",COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID'),"objectId"
  125. ), studies AS MATERIALIZED (
  126. SELECT DISTINCT "company","generalId"::text AS general_id
  127. FROM "CommonModel" WHERE "modelId"::text='56'
  128. ), courses AS MATERIALIZED (
  129. SELECT DISTINCT ON ("company","nodeId"::text) "company","nodeId"::text AS node_id,"nodeName"
  130. FROM "Node" ORDER BY "company","nodeId"::text,"nodeName"
  131. ), facts AS (
  132. SELECT task.*,xiaoshu_safe_shanghai_timestamp(task."wcsj"::text) AS finished,
  133. coach."objectId" AS coach_object_id,coach."realName" AS coach_real_name,coach."nickname" AS coach_nickname,
  134. coach."username" AS coach_username,coach."legacyUserData" AS coach_legacy,
  135. student."objectId" AS student_object_id,student."realName" AS student_real_name,student."nickname" AS student_nickname,
  136. student."username" AS student_username,student."legacyUserData" AS student_legacy,
  137. store."realName" AS store_real_name,store."nickname" AS store_nickname,store."username" AS store_username,
  138. store."legacyUserData" AS store_legacy,study.general_id AS study_general_id,course."nodeName" AS course_name
  139. FROM "MemoryPracticeRecord" task
  140. LEFT JOIN users coach ON coach."company"=task."company" AND coach.legacy_id=task."plid"::text
  141. LEFT JOIN users student ON student."company"=task."company" AND student.legacy_id=task."yhid"::text
  142. LEFT JOIN users store ON store."company"=task."company" AND store.legacy_id=coach."legacyUserData"->>'ParentUserID'
  143. LEFT JOIN studies study ON study."company"=task."company" AND study.general_id=task."xxjlid"::text
  144. LEFT JOIN courses course ON course."company"=task."company" AND course.node_id=task."kcid"::text
  145. WHERE task."fxzt"::text='1' AND task."company" IS NOT NULL
  146. ), prepared AS (
  147. SELECT facts.*,
  148. CASE WHEN "rewardDuplicate" THEN '来源复习任务重复'
  149. WHEN "rewardTaskKey" IS NULL OR study_general_id IS NULL THEN '缺少有效来源学习记录或计划日期'
  150. WHEN coach_object_id IS NULL OR COALESCE("plid"::text,'0') !~ '^[1-9][0-9]*$' THEN '缺少有效复习老师'
  151. WHEN student_object_id IS NULL THEN '缺少有效学员'
  152. WHEN finished IS NULL THEN '缺少有效完成时间' ELSE '' END AS problem
  153. FROM facts
  154. )
  155. INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception")
  156. SELECT md5("company"||':review:'||"objectId"),"company","objectId","rewardTaskKey",
  157. CASE WHEN "plid"::text ~ '^[1-9][0-9]*$' THEN "plid"::double precision ELSE 0 END,
  158. COALESCE(NULLIF(coach_real_name,''),NULLIF(coach_nickname,''),coach_legacy->>'HoneyName',coach_username,'未识别老师'),
  159. CASE WHEN "yhid"::text ~ '^[1-9][0-9]*$' THEN "yhid"::double precision ELSE 0 END,
  160. COALESCE(NULLIF(student_real_name,''),NULLIF(student_nickname,''),student_legacy->>'HoneyName',student_username,'未识别学员'),COALESCE(course_name,''),
  161. CASE WHEN coach_legacy->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach_legacy->>'ParentUserID')::double precision ELSE 0 END,
  162. COALESCE(NULLIF(store_real_name,''),NULLIF(store_nickname,''),store_legacy->>'HoneyName',store_username,'未绑定门店'),
  163. COALESCE(left("kywrq"::text,10),''),finished,COALESCE(to_char(finished AT TIME ZONE 'Asia/Shanghai','YYYY-MM'),''),0,
  164. CASE WHEN problem='' THEN 'historical' ELSE 'exception' END,problem
  165. FROM prepared
  166. ON CONFLICT ("company","taskId") DO NOTHING;
  167. CREATE OR REPLACE FUNCTION xiaoshu_review_before_write() RETURNS trigger LANGUAGE plpgsql AS $$
  168. BEGIN
  169. IF TG_OP='UPDATE' THEN
  170. IF OLD."company" IS DISTINCT FROM NEW."company" OR OLD."objectId" IS DISTINCT FROM NEW."objectId" THEN RAISE EXCEPTION '不能修改复习任务身份'; END IF;
  171. IF OLD."rewardTaskKey" IS NOT NULL OR OLD."rewardDuplicate" OR OLD."fxzt"::text='1' THEN
  172. IF ROW(OLD."yhid",OLD."xxjlid",OLD."kywrq") IS DISTINCT FROM ROW(NEW."yhid",NEW."xxjlid",NEW."kywrq") THEN RAISE EXCEPTION '不能修改复习任务来源'; END IF;
  173. NEW."rewardTaskKey" := OLD."rewardTaskKey"; NEW."rewardDuplicate" := OLD."rewardDuplicate";
  174. ELSE
  175. NEW."rewardTaskKey" := xiaoshu_review_key(NEW."yhid"::text,NEW."xxjlid"::text,left(NEW."kywrq"::text,10)); NEW."rewardDuplicate" := false;
  176. END IF;
  177. IF OLD."fxzt"::text='1' THEN
  178. NEW."fxzt" := OLD."fxzt"; NEW."wcsj" := OLD."wcsj"; NEW."plid" := OLD."plid";
  179. RETURN NEW;
  180. END IF;
  181. IF NEW."fxzt"::text='1' AND (current_setting('xiaoshu.review_completion',true)=NEW."company"||':'||NEW."objectId" OR NOT ((COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%') AND NULLIF(NEW."wcsj"::text,'') IS NOT NULL)) THEN
  182. NEW."wcsj" := to_char(clock_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
  183. END IF;
  184. ELSE
  185. NEW."rewardTaskKey" := xiaoshu_review_key(NEW."yhid"::text,NEW."xxjlid"::text,left(NEW."kywrq"::text,10)); NEW."rewardDuplicate" := false;
  186. END IF;
  187. RETURN NEW;
  188. END $$;
  189. CREATE OR REPLACE FUNCTION xiaoshu_review_after_write() RETURNS trigger LANGUAGE plpgsql AS $$
  190. BEGIN
  191. IF NEW."fxzt"::text='1' THEN
  192. IF TG_OP='INSERT' THEN PERFORM xiaoshu_record_review_reward(NEW,true);
  193. ELSE PERFORM xiaoshu_record_review_reward(NEW,OLD."fxzt"::text='1' OR (COALESCE(current_setting('xiaoshu.review_completion',true),'')<>NEW."company"||':'||NEW."objectId" AND (COALESCE(NEW."sourceKey",'') LIKE 'legacy:model:60:%' OR COALESCE(NEW."sourceKey",'') LIKE 'legacy-sync:memory-records:addon:%'))); END IF;
  194. END IF;
  195. RETURN NEW;
  196. END $$;
  197. DROP TRIGGER IF EXISTS review_reward_before_write ON "MemoryPracticeRecord";
  198. CREATE TRIGGER review_reward_before_write BEFORE INSERT OR UPDATE ON "MemoryPracticeRecord" FOR EACH ROW EXECUTE FUNCTION xiaoshu_review_before_write();
  199. DROP TRIGGER IF EXISTS review_reward_after_write ON "MemoryPracticeRecord";
  200. CREATE TRIGGER review_reward_after_write AFTER INSERT OR UPDATE ON "MemoryPracticeRecord" FOR EACH ROW EXECUTE FUNCTION xiaoshu_review_after_write();
  201. -- The compatibility endpoint calls this, so an unapplied migration fails closed.
  202. CREATE OR REPLACE FUNCTION xiaoshu_complete_review(tenant text, task_id text) RETURNS void LANGUAGE plpgsql AS $$
  203. DECLARE previous text:=COALESCE(current_setting('xiaoshu.review_completion',true),''); task record; eligibility text;
  204. BEGIN
  205. SELECT * INTO task FROM "MemoryPracticeRecord" WHERE company=tenant AND "objectId"=task_id FOR UPDATE;
  206. IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
  207. IF task.fxzt::text='1' THEN RETURN; END IF;
  208. eligibility := xiaoshu_review_eligibility(left(task.kywrq::text,10),clock_timestamp());
  209. IF eligibility='future' THEN RAISE EXCEPTION '复习任务未到期,不能提前完成'; END IF;
  210. -- Only this authenticated completion path earns a reward for an imported pending task.
  211. PERFORM set_config('xiaoshu.review_completion',tenant||':'||task_id,true);
  212. UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=clock_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
  213. IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
  214. PERFORM set_config('xiaoshu.review_completion',previous,true);
  215. EXCEPTION WHEN OTHERS THEN
  216. PERFORM set_config('xiaoshu.review_completion',previous,true);
  217. RAISE;
  218. END $$;
  219. CREATE OR REPLACE FUNCTION xiaoshu_reward_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
  220. BEGIN
  221. IF TG_OP='UPDATE' AND current_setting('xiaoshu.review_reward_confirm',true)='yes' AND OLD."status"='historical' AND NEW."status"='earned' AND NEW."amount"=1 AND (to_jsonb(NEW)-'status'-'amount'-'updatedAt')=(to_jsonb(OLD)-'status'-'amount'-'updatedAt') THEN RETURN NEW; END IF;
  222. RAISE EXCEPTION '抗遗忘工资凭据不可改写或删除,请使用工资调整';
  223. END $$;
  224. DROP TRIGGER IF EXISTS review_reward_immutable ON "ReviewReward";
  225. CREATE TRIGGER review_reward_immutable BEFORE UPDATE OR DELETE ON "ReviewReward" FOR EACH ROW EXECUTE FUNCTION xiaoshu_reward_immutable();
  226. CREATE OR REPLACE FUNCTION xiaoshu_payroll_reward_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  227. DECLARE batch record; reward record; tenant text; batch_id text;
  228. BEGIN
  229. tenant := CASE WHEN TG_OP='DELETE' THEN OLD."company" ELSE NEW."company" END;
  230. batch_id := CASE WHEN TG_OP='DELETE' THEN OLD."batchId" ELSE NEW."batchId" END;
  231. IF TG_OP='UPDATE' AND ROW(OLD."company",OLD."batchId",OLD."kind",OLD."reviewRewardId") IS DISTINCT FROM ROW(NEW."company",NEW."batchId",NEW."kind",NEW."reviewRewardId") THEN RAISE EXCEPTION '不能修改工资明细归属'; END IF;
  232. SELECT * INTO batch FROM "PayrollBatch" WHERE "company"=tenant AND "objectId"=batch_id FOR UPDATE;
  233. IF batch."objectId" IS NULL OR batch."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定或不存在'; END IF;
  234. IF TG_OP='DELETE' THEN RETURN OLD; END IF;
  235. IF NEW."kind"='review_reward' THEN
  236. SELECT * INTO reward FROM "ReviewReward" WHERE "company"=tenant AND "objectId"=NEW."reviewRewardId";
  237. IF reward."status" IS DISTINCT FROM 'earned' OR reward."incomeMonth">batch."month" THEN RAISE EXCEPTION '抗遗忘工资不可结算'; END IF;
  238. IF NEW."excluded" THEN RAISE EXCEPTION '有效抗遗忘工资不能排除,请使用工资调整'; END IF;
  239. NEW."coachId" := reward."coachId"; NEW."coachName" := reward."coachName";
  240. NEW."studentName" := reward."studentName"; NEW."courseName" := reward."courseName";
  241. NEW."storeId" := reward."storeId"; NEW."storeName" := reward."storeName";
  242. NEW."reviewTaskId" := reward."taskId"; NEW."plannedDate" := reward."plannedDate";
  243. NEW."amount" := reward."amount"; NEW."rateAmount" := reward."amount"; NEW."durationHours" := 0;
  244. NEW."originalMonth" := reward."incomeMonth"; NEW."settlementMonth" := batch."month"; NEW."lessonAt" := reward."completedAt";
  245. NEW."exception" := ''; NEW."classType" := 0; NEW."courseCategoryName" := '抗遗忘工资';
  246. END IF;
  247. RETURN NEW;
  248. END $$;
  249. DROP TRIGGER IF EXISTS payroll_reward_guard ON "PayrollLine";
  250. CREATE TRIGGER payroll_reward_guard BEFORE INSERT OR UPDATE OR DELETE ON "PayrollLine" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_reward_guard();
  251. CREATE OR REPLACE FUNCTION xiaoshu_payroll_totals_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  252. BEGIN
  253. IF OLD."status" IN ('reviewed','paid') THEN
  254. IF NEW."status" IS DISTINCT FROM OLD."status" AND NOT (OLD."status"='reviewed' AND NEW."status"='paid') THEN RAISE EXCEPTION '已审核工资不可重新打开'; END IF;
  255. IF ROW(NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."payableAmount",NEW."month") IS DISTINCT FROM ROW(OLD."baseAmount",OLD."reviewAmount",OLD."adjustmentAmount",OLD."payableAmount",OLD."month") THEN RAISE EXCEPTION '已审核工资金额不可修改'; END IF;
  256. RETURN NEW;
  257. END IF;
  258. SELECT COALESCE(sum("amount") FILTER (WHERE "kind"='lesson'),0),COALESCE(sum("amount") FILTER (WHERE "kind"='review_reward'),0),COALESCE(sum("amount") FILTER (WHERE "kind"='adjustment'),0),count(*) FILTER (WHERE "kind"='review_reward'),count(*) FILTER (WHERE "kind"='lesson'),COALESCE(sum("durationHours") FILTER (WHERE "kind"='lesson'),0)
  259. INTO NEW."baseAmount",NEW."reviewAmount",NEW."adjustmentAmount",NEW."reviewCount",NEW."lessonCount",NEW."totalHours"
  260. FROM "PayrollLine" WHERE "company"=NEW."company" AND "batchId"=NEW."objectId" AND COALESCE("excluded",false)=false AND COALESCE("exception",'')='';
  261. NEW."payableAmount" := NEW."baseAmount"+NEW."reviewAmount"+NEW."adjustmentAmount";
  262. RETURN NEW;
  263. END $$;
  264. DROP TRIGGER IF EXISTS payroll_totals_guard ON "PayrollBatch";
  265. CREATE TRIGGER payroll_totals_guard BEFORE UPDATE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_totals_guard();
  266. CREATE OR REPLACE FUNCTION xiaoshu_payroll_delete_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  267. BEGIN
  268. IF OLD."status" IN ('reviewed','paid') OR EXISTS (SELECT 1 FROM "PayrollLine" WHERE "company"=OLD."company" AND "batchId"=OLD."objectId") THEN RAISE EXCEPTION '工资批次已锁定或仍有明细,不能删除'; END IF;
  269. RETURN OLD;
  270. END $$;
  271. DROP TRIGGER IF EXISTS payroll_delete_guard ON "PayrollBatch";
  272. CREATE TRIGGER payroll_delete_guard BEFORE DELETE ON "PayrollBatch" FOR EACH ROW EXECUTE FUNCTION xiaoshu_payroll_delete_guard();
  273. COMMIT;