|
|
@@ -16,9 +16,43 @@ CREATE TABLE IF NOT EXISTS "ReviewReward" (
|
|
|
"plannedDate" text NOT NULL DEFAULT '', "completedAt" timestamptz, "incomeMonth" text NOT NULL DEFAULT '',
|
|
|
"amount" numeric(12,2) NOT NULL DEFAULT 0, "status" text NOT NULL,
|
|
|
"exception" text NOT NULL DEFAULT '', "createdAt" timestamptz NOT NULL DEFAULT now(),
|
|
|
- UNIQUE ("company","taskId"), CHECK ("status" IN ('earned','historical','exception')),
|
|
|
+ UNIQUE ("company","taskId"), CHECK ("status" IN ('earned','historical','exception','ineligible')),
|
|
|
CHECK (("status"='earned' AND "amount"=1 AND "completedAt" IS NOT NULL AND "coachId">0) OR ("status"<>'earned' AND "amount"=0))
|
|
|
);
|
|
|
+-- Upgrade the original auto-named constraint without rewriting immutable receipts.
|
|
|
+ALTER TABLE "ReviewReward" ADD COLUMN IF NOT EXISTS "ineligibleReason" text NOT NULL DEFAULT '';
|
|
|
+ALTER TABLE "ReviewReward" DROP CONSTRAINT IF EXISTS "ReviewReward_status_check";
|
|
|
+ALTER TABLE "ReviewReward" ADD CONSTRAINT "ReviewReward_status_check" CHECK (status IN ('earned','historical','exception','ineligible'));
|
|
|
+CREATE OR REPLACE FUNCTION xiaoshu_review_eligibility(planned text, finished timestamptz) RETURNS text
|
|
|
+LANGUAGE plpgsql IMMUTABLE AS $$
|
|
|
+DECLARE day date; actual date := (finished AT TIME ZONE 'Asia/Shanghai')::date;
|
|
|
+BEGIN
|
|
|
+ IF planned IS NULL OR planned !~ '^\d{4}-\d{2}-\d{2}$' OR finished IS NULL THEN RETURN 'invalid_date'; END IF;
|
|
|
+ BEGIN day := planned::date; EXCEPTION WHEN OTHERS THEN RETURN 'invalid_date'; END;
|
|
|
+ RETURN CASE WHEN day=actual THEN 'eligible' WHEN day<actual THEN 'overdue' ELSE 'future' END;
|
|
|
+END $$;
|
|
|
+-- Append-only correction evidence. Receipts and locked payroll documents remain intact.
|
|
|
+CREATE TABLE IF NOT EXISTS "ReviewRewardCorrection" (
|
|
|
+ "objectId" text PRIMARY KEY, company text NOT NULL, "rewardId" text NOT NULL,
|
|
|
+ "amount" numeric(12,2) NOT NULL CHECK(amount=-1), reason text NOT NULL CHECK(length(trim(reason))>=2),
|
|
|
+ actor text NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(),
|
|
|
+ "sourceBatchId" text, "adjustmentBatchId" text, "adjustmentRequestId" text,
|
|
|
+ UNIQUE(company,"rewardId"), FOREIGN KEY("rewardId") REFERENCES "ReviewReward"("objectId")
|
|
|
+);
|
|
|
+CREATE OR REPLACE VIEW "ReviewRewardIncome" AS
|
|
|
+ SELECT projected.*,r.amount AS "originalAmount",c."objectId" AS "correctionId",c.reason AS "correctionReason",c.amount AS "correctionAmount",c."adjustmentBatchId"
|
|
|
+ FROM "ReviewReward" r LEFT JOIN "ReviewRewardCorrection" c ON c.company=r.company AND c."rewardId"=r."objectId"
|
|
|
+ CROSS JOIN LATERAL jsonb_populate_record(NULL::"ReviewReward",to_jsonb(r)||jsonb_build_object(
|
|
|
+ 'amount',r.amount+COALESCE(c.amount,0),'status',CASE WHEN c."objectId" IS NULL THEN r.status ELSE 'ineligible' END,
|
|
|
+ 'ineligibleReason',CASE WHEN c."objectId" IS NULL THEN r."ineligibleReason" ELSE 'corrected_overdue' END)) projected;
|
|
|
+CREATE OR REPLACE FUNCTION xs_review_correction_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
|
+BEGIN
|
|
|
+ IF TG_OP<>'INSERT' OR current_setting('xiaoshu.review_correction',true) IS DISTINCT FROM 'yes' THEN RAISE EXCEPTION '工资纠正凭据只能由核对事务新增,不能改写或删除'; END IF;
|
|
|
+ RETURN NEW;
|
|
|
+END $$;
|
|
|
+DROP TRIGGER IF EXISTS review_correction_immutable ON "ReviewRewardCorrection";
|
|
|
+CREATE TRIGGER review_correction_immutable BEFORE INSERT OR UPDATE OR DELETE ON "ReviewRewardCorrection" FOR EACH ROW EXECUTE FUNCTION xs_review_correction_guard();
|
|
|
+REVOKE ALL ON "ReviewRewardCorrection" FROM PUBLIC;
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS review_reward_once_per_plan ON "ReviewReward" ("company","taskKey") WHERE "status"='earned';
|
|
|
CREATE INDEX IF NOT EXISTS review_reward_coach_month ON "ReviewReward" ("company","coachId","incomeMonth");
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS payroll_company_month_once ON "PayrollBatch" ("company","month");
|
|
|
@@ -43,7 +77,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS memory_review_plan_once ON "MemoryPracticeReco
|
|
|
|
|
|
CREATE OR REPLACE FUNCTION xiaoshu_record_review_reward(task "MemoryPracticeRecord", historical boolean) RETURNS void
|
|
|
LANGUAGE plpgsql AS $$
|
|
|
-DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text;
|
|
|
+DECLARE coach record; student record; store record; course text; problem text := ''; finished timestamptz; reward_status text; eligibility text;
|
|
|
BEGIN
|
|
|
IF task."company" IS NULL THEN RAISE EXCEPTION '复习任务缺少帐套'; END IF;
|
|
|
IF EXISTS (SELECT 1 FROM "ReviewReward" WHERE "company"=task."company" AND "taskId"=task."objectId") THEN RETURN; END IF;
|
|
|
@@ -52,15 +86,18 @@ BEGIN
|
|
|
SELECT * INTO store FROM "_User" WHERE "company"=task."company" AND COALESCE("legacyUserId"::text,"legacyUserData"->>'UserID')=coach."legacyUserData"->>'ParentUserID' LIMIT 1;
|
|
|
SELECT "nodeName" INTO course FROM "Node" WHERE "company"=task."company" AND "nodeId"::text=task."kcid"::text LIMIT 1;
|
|
|
BEGIN
|
|
|
- finished := CASE WHEN historical THEN NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai' ELSE statement_timestamp() END;
|
|
|
+ -- The BEFORE trigger overwrites client time on every first live completion.
|
|
|
+ finished := NULLIF(task."wcsj"::text,'')::timestamp AT TIME ZONE 'Asia/Shanghai';
|
|
|
EXCEPTION WHEN OTHERS THEN finished := NULL; END;
|
|
|
IF task."rewardDuplicate" THEN problem := '来源复习任务重复';
|
|
|
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 := '缺少有效来源学习记录或计划日期';
|
|
|
ELSIF coach."objectId" IS NULL OR COALESCE(task."plid"::text,'0') !~ '^[1-9][0-9]*$' THEN problem := '缺少有效复习老师';
|
|
|
ELSIF student."objectId" IS NULL THEN problem := '缺少有效学员';
|
|
|
ELSIF finished IS NULL THEN problem := '缺少有效完成时间'; END IF;
|
|
|
- reward_status := CASE WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' ELSE 'earned' END;
|
|
|
- INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception")
|
|
|
+ eligibility := xiaoshu_review_eligibility(left(task."kywrq"::text,10),finished);
|
|
|
+ IF problem='' AND eligibility='invalid_date' THEN problem := '缺少有效计划日期'; END IF;
|
|
|
+ reward_status := CASE WHEN problem<>'' THEN 'exception' WHEN historical THEN 'historical' WHEN eligibility<>'eligible' THEN 'ineligible' ELSE 'earned' END;
|
|
|
+ INSERT INTO "ReviewReward" ("objectId","company","taskId","taskKey","coachId","coachName","studentId","studentName","courseName","storeId","storeName","plannedDate","completedAt","incomeMonth","amount","status","exception","ineligibleReason")
|
|
|
VALUES (md5(task."company"||':review:'||task."objectId"),task."company",task."objectId",task."rewardTaskKey",
|
|
|
CASE WHEN task."plid"::text ~ '^[1-9][0-9]*$' THEN task."plid"::double precision ELSE 0 END,
|
|
|
COALESCE(NULLIF(coach."realName",''),NULLIF(coach."nickname",''),coach."legacyUserData"->>'HoneyName',coach."username",'未识别老师'),
|
|
|
@@ -68,7 +105,7 @@ BEGIN
|
|
|
COALESCE(NULLIF(student."realName",''),NULLIF(student."nickname",''),student."legacyUserData"->>'HoneyName',student."username",'未识别学员'),COALESCE(course,''),
|
|
|
CASE WHEN coach."legacyUserData"->>'ParentUserID' ~ '^[1-9][0-9]*$' THEN (coach."legacyUserData"->>'ParentUserID')::double precision ELSE 0 END,
|
|
|
COALESCE(NULLIF(store."realName",''),NULLIF(store."nickname",''),store."legacyUserData"->>'HoneyName',store."username",'未绑定门店'),
|
|
|
- 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)
|
|
|
+ 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)
|
|
|
ON CONFLICT ("company","taskId") DO NOTHING;
|
|
|
END $$;
|
|
|
-- Backfill creates zero-value, non-payable evidence; rerunning migration cannot award it.
|
|
|
@@ -145,7 +182,7 @@ BEGIN
|
|
|
RETURN NEW;
|
|
|
END IF;
|
|
|
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
|
|
|
- NEW."wcsj" := to_char(statement_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
|
|
|
+ NEW."wcsj" := to_char(clock_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
|
|
|
END IF;
|
|
|
ELSE
|
|
|
NEW."rewardTaskKey" := xiaoshu_review_key(NEW."yhid"::text,NEW."xxjlid"::text,left(NEW."kywrq"::text,10)); NEW."rewardDuplicate" := false;
|
|
|
@@ -167,11 +204,16 @@ CREATE TRIGGER review_reward_after_write AFTER INSERT OR UPDATE ON "MemoryPracti
|
|
|
|
|
|
-- The compatibility endpoint calls this, so an unapplied migration fails closed.
|
|
|
CREATE OR REPLACE FUNCTION xiaoshu_complete_review(tenant text, task_id text) RETURNS void LANGUAGE plpgsql AS $$
|
|
|
-DECLARE previous text:=COALESCE(current_setting('xiaoshu.review_completion',true),'');
|
|
|
+DECLARE previous text:=COALESCE(current_setting('xiaoshu.review_completion',true),''); task record; eligibility text;
|
|
|
BEGIN
|
|
|
+ SELECT * INTO task FROM "MemoryPracticeRecord" WHERE company=tenant AND "objectId"=task_id FOR UPDATE;
|
|
|
+ IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
|
|
|
+ IF task.fxzt::text='1' THEN RETURN; END IF;
|
|
|
+ eligibility := xiaoshu_review_eligibility(left(task.kywrq::text,10),clock_timestamp());
|
|
|
+ IF eligibility='future' THEN RAISE EXCEPTION '复习任务未到期,不能提前完成'; END IF;
|
|
|
-- Only this authenticated completion path earns a reward for an imported pending task.
|
|
|
PERFORM set_config('xiaoshu.review_completion',tenant||':'||task_id,true);
|
|
|
- UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=statement_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
|
|
|
+ UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"updatedAt"=clock_timestamp() WHERE "company"=tenant AND "objectId"=task_id;
|
|
|
IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
|
|
|
PERFORM set_config('xiaoshu.review_completion',previous,true);
|
|
|
EXCEPTION WHEN OTHERS THEN
|