review-rewards.sql 22 KB

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