借款对账 S3:银行终态确认与释放(09-17-company-loan-principal-repayment)
迁移 20261008010000 - 银行终态证据单独成表(不进 OCR、不改付款状态、不计回单),只能作废一次, 作废即令依赖事实失效;复核人可凭理由查看(审计、SHA-256 校验) - 每次银行提交一条终态事实:书面终态证据,或流水号等于指令号的严格失败回单; 同一指令的终态可了结该指令的处理中回单,其他提交不能代替 - 新终态 confirmed_not_paid;释放须清单已复核未变、每次提交有成立的终态事实、 导出均关联提交、回单均有解释、等待期满、无历史汇总 - 最终取消在同一事务内重验(与新提交、晚到回单串行);两条释放路径都检查等待期 - 防挪用:指令号跨批次唯一(含撤回)、失败回单绑定本批次指令、一笔失败交易 只支撑一个批次、同一书面证据不能用于两个批次、登记提交即离开“未发送” 验证:借款 HTTP 57 组、浏览器 7 组、77 迁移;server 4472;四轮同模型只读复核 阻断项均已修复,资金守卫变异测试均变红。 注意:界面未完成,不能单独部署;真实回单待验;补证后的恢复缺口尚未设计。 未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,244 @@
|
||||
-- S3 of the loan bank-result reconciliation design (design.md 3.2): a
|
||||
-- repayment is released as "not paid" only when every reviewed bank submission
|
||||
-- has an independently reviewed terminal fact bound to its instruction reference.
|
||||
|
||||
-- Bank termination evidence (bank letters, official instruction-query PDFs) is
|
||||
-- stored apart from payment receipts: never OCR'd, never counted as a receipt,
|
||||
-- never changes the payment state. It can only be voided once.
|
||||
CREATE TABLE "loan_bank_terminal_evidences" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"original_name" VARCHAR(255) NOT NULL,
|
||||
"mime_type" VARCHAR(100) NOT NULL,
|
||||
"file_size" INTEGER NOT NULL,
|
||||
"file_sha256" VARCHAR(64) NOT NULL,
|
||||
"uploaded_by" UUID NOT NULL,
|
||||
"uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"voided_by" UUID,
|
||||
"voided_at" TIMESTAMP(3),
|
||||
"void_reason" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "loan_bank_terminal_evidences_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_terminal_evidences_void_check" CHECK (
|
||||
("voided_at" IS NULL AND "voided_by" IS NULL AND "void_reason" IS NULL)
|
||||
OR ("voided_at" IS NOT NULL AND "voided_by" IS NOT NULL AND char_length(btrim("void_reason")) >= 2))
|
||||
);
|
||||
CREATE INDEX "loan_bank_terminal_evidences_batch_idx" ON "loan_bank_terminal_evidences"("payment_batch_id");
|
||||
CREATE INDEX "loan_bank_terminal_evidences_sha_idx" ON "loan_bank_terminal_evidences"("file_sha256");
|
||||
|
||||
-- One terminal fact per bank submission (proposed/approved at a time).
|
||||
-- kind failure_receipt: a strictly clear failed slip whose reference equals the
|
||||
-- submission's instruction reference. kind termination_evidence: bank evidence
|
||||
-- naming that instruction and its terminal status. resolves_attachment_ids lists
|
||||
-- pending slips of the same instruction that this terminal fact settles.
|
||||
CREATE TABLE "loan_bank_terminal_facts" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"submission_id" UUID NOT NULL,
|
||||
"kind" VARCHAR(32) NOT NULL,
|
||||
"terminal_status" VARCHAR(16) NOT NULL,
|
||||
"evidence_id" UUID,
|
||||
"receipt_attachment_id" UUID,
|
||||
"verification_id" UUID,
|
||||
"resolves_attachment_ids" JSONB NOT NULL DEFAULT '[]',
|
||||
"note" VARCHAR(1000) NOT NULL,
|
||||
"status" VARCHAR(16) NOT NULL DEFAULT 'proposed',
|
||||
"proposed_by" UUID NOT NULL,
|
||||
"proposed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewed_by" UUID,
|
||||
"reviewed_at" TIMESTAMP(3),
|
||||
"review_note" VARCHAR(500),
|
||||
"superseded_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "loan_bank_terminal_facts_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_terminal_facts_kind_check" CHECK (
|
||||
("kind" = 'termination_evidence' AND "evidence_id" IS NOT NULL AND "receipt_attachment_id" IS NULL AND "verification_id" IS NULL
|
||||
AND "terminal_status" IN ('rejected', 'revoked', 'failed'))
|
||||
OR ("kind" = 'failure_receipt' AND "evidence_id" IS NULL AND "receipt_attachment_id" IS NOT NULL AND "verification_id" IS NOT NULL
|
||||
AND "terminal_status" = 'failed')),
|
||||
CONSTRAINT "loan_bank_terminal_facts_resolves_check" CHECK (jsonb_typeof("resolves_attachment_ids") = 'array'),
|
||||
CONSTRAINT "loan_bank_terminal_facts_note_check" CHECK (char_length(btrim("note")) >= 5),
|
||||
CONSTRAINT "loan_bank_terminal_facts_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
|
||||
CONSTRAINT "loan_bank_terminal_facts_review_check" CHECK (
|
||||
("status" = 'proposed' AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL)
|
||||
OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL)
|
||||
OR "status" = 'superseded'),
|
||||
CONSTRAINT "loan_bank_terminal_facts_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
|
||||
CONSTRAINT "loan_bank_terminal_facts_independent_check" CHECK ("reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
|
||||
);
|
||||
CREATE UNIQUE INDEX "loan_bank_terminal_facts_one_active_per_submission"
|
||||
ON "loan_bank_terminal_facts"("submission_id") WHERE "status" IN ('proposed', 'approved');
|
||||
CREATE INDEX "loan_bank_terminal_facts_batch_idx" ON "loan_bank_terminal_facts"("payment_batch_id", "status");
|
||||
|
||||
ALTER TABLE "loan_bank_terminal_evidences" ADD CONSTRAINT "loan_bank_terminal_evidences_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_evidences" ADD CONSTRAINT "loan_bank_terminal_evidences_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_submission_id_fkey" FOREIGN KEY ("submission_id") REFERENCES "loan_bank_submissions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_evidence_id_fkey" FOREIGN KEY ("evidence_id") REFERENCES "loan_bank_terminal_evidences"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_receipt_attachment_id_fkey" FOREIGN KEY ("receipt_attachment_id") REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_verification_id_fkey" FOREIGN KEY ("verification_id") REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
CREATE FUNCTION guard_loan_terminal_evidence_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan terminal evidence history is immutable'; END IF;
|
||||
IF OLD."voided_at" IS NOT NULL OR NEW."voided_at" IS NULL
|
||||
OR (NEW."payment_batch_id", NEW."storage_path", NEW."original_name", NEW."mime_type", NEW."file_size", NEW."file_sha256", NEW."uploaded_by", NEW."uploaded_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."storage_path", OLD."original_name", OLD."mime_type", OLD."file_size", OLD."file_sha256", OLD."uploaded_by", OLD."uploaded_at") THEN
|
||||
RAISE EXCEPTION 'loan terminal evidence can only be voided once';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_terminal_evidence_history BEFORE UPDATE OR DELETE ON "loan_bank_terminal_evidences"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_terminal_evidence_history();
|
||||
|
||||
CREATE FUNCTION guard_loan_terminal_fact_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan terminal fact history is immutable'; END IF;
|
||||
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan terminal fact is immutable'; END IF;
|
||||
IF (NEW."payment_batch_id", NEW."submission_id", NEW."kind", NEW."terminal_status", NEW."evidence_id", NEW."receipt_attachment_id",
|
||||
NEW."verification_id", NEW."resolves_attachment_ids", NEW."note", NEW."proposed_by", NEW."proposed_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."submission_id", OLD."kind", OLD."terminal_status", OLD."evidence_id", OLD."receipt_attachment_id",
|
||||
OLD."verification_id", OLD."resolves_attachment_ids", OLD."note", OLD."proposed_by", OLD."proposed_at") THEN
|
||||
RAISE EXCEPTION 'loan terminal fact proposal is immutable';
|
||||
END IF;
|
||||
IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded'
|
||||
OR (NEW."reviewed_by", NEW."reviewed_at", NEW."review_note") IS DISTINCT FROM (OLD."reviewed_by", OLD."reviewed_at", OLD."review_note")) THEN
|
||||
RAISE EXCEPTION 'approved loan terminal fact can only be superseded';
|
||||
END IF;
|
||||
IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded')
|
||||
AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'unreviewed loan terminal fact cannot carry review fields';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_terminal_fact_history BEFORE UPDATE OR DELETE ON "loan_bank_terminal_facts"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_terminal_fact_history();
|
||||
|
||||
-- New terminal repayment state confirmed_not_paid: like confirmed_failed it is
|
||||
-- terminal, needs a reference and allows the batch to be cancelled.
|
||||
ALTER TABLE loan_repayments DROP CONSTRAINT loan_repayments_amount_state_check;
|
||||
ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK (
|
||||
amount > 0 AND currency_code = 'CNY' AND version > 0
|
||||
AND lender_identity_version > 0 AND lender_account_version > 0
|
||||
AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_not_paid','confirmed_success')
|
||||
AND (bank_result_state NOT IN ('confirmed_failed','confirmed_not_paid','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL)
|
||||
AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL)
|
||||
OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL))
|
||||
);
|
||||
|
||||
-- Integrity and history functions re-created with confirmed_not_paid as a
|
||||
-- cancellable terminal state (otherwise identical to 20260917010000).
|
||||
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
||||
IF NOT FOUND THEN RETURN; END IF;
|
||||
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
||||
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
||||
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
||||
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
||||
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
||||
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
||||
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
||||
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
||||
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
||||
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
||||
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
||||
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
||||
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid'))
|
||||
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
||||
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
||||
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
||||
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) >
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
||||
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_repayment_allocations' THEN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM
|
||||
(OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version)
|
||||
OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD)
|
||||
THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount,
|
||||
NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key)
|
||||
IS DISTINCT FROM
|
||||
(OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount,
|
||||
OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key)
|
||||
OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_not_paid','confirmed_success') AND
|
||||
(NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref))
|
||||
OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent')
|
||||
OR (OLD.accounting_handoff_at IS NOT NULL AND
|
||||
(NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM
|
||||
(OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref))
|
||||
THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- One failed bank transaction can back the release of only one batch, whichever
|
||||
-- path relied on it (single-submission confirmation or a per-submission terminal
|
||||
-- fact). Append-only registry keyed by the transaction fingerprint.
|
||||
CREATE TABLE "loan_failure_evidence_uses" (
|
||||
"fingerprint" VARCHAR(64) NOT NULL,
|
||||
"key_id" VARCHAR(64) NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"source" VARCHAR(32) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "loan_failure_evidence_uses_pkey" PRIMARY KEY ("fingerprint"),
|
||||
CONSTRAINT "loan_failure_evidence_uses_source_check" CHECK ("source" IN ('failure_confirmation', 'terminal_fact'))
|
||||
);
|
||||
ALTER TABLE "loan_failure_evidence_uses" ADD CONSTRAINT "loan_failure_evidence_uses_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
CREATE TRIGGER loan_failure_evidence_uses_append_only BEFORE UPDATE OR DELETE ON "loan_failure_evidence_uses"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
|
||||
-- Failure attributions now state their scope: the single-submission batch path
|
||||
-- (S2) or one named submission bound by instruction reference (S3).
|
||||
ALTER TABLE "loan_receipt_attributions" ADD COLUMN "failure_scope" VARCHAR(16) NOT NULL DEFAULT 'batch';
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_failure_scope_check" CHECK ("failure_scope" IN ('batch', 'submission'));
|
||||
CREATE OR REPLACE FUNCTION guard_loan_receipt_attribution_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan receipt attribution history is immutable'; END IF;
|
||||
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan receipt attribution is immutable'; END IF;
|
||||
IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded' OR NEW."reviewed_by" IS DISTINCT FROM OLD."reviewed_by"
|
||||
OR NEW."reviewed_at" IS DISTINCT FROM OLD."reviewed_at" OR NEW."review_note" IS DISTINCT FROM OLD."review_note") THEN
|
||||
RAISE EXCEPTION 'approved loan receipt attribution can only be superseded';
|
||||
END IF;
|
||||
IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded')
|
||||
AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'unreviewed loan receipt attribution cannot carry review fields';
|
||||
END IF;
|
||||
IF NEW."payment_batch_id" <> OLD."payment_batch_id" OR NEW."fingerprint" <> OLD."fingerprint" OR NEW."key_id" <> OLD."key_id"
|
||||
OR NEW."verification_ids" <> OLD."verification_ids" OR NEW."manual_proposal_ids" <> OLD."manual_proposal_ids"
|
||||
OR NEW."competitor_batch_ids" <> OLD."competitor_batch_ids" OR NEW."basis_kind" <> OLD."basis_kind"
|
||||
OR NEW."basis_note" <> OLD."basis_note" OR NEW."exclusion_note" <> OLD."exclusion_note"
|
||||
OR NEW."proposed_by" <> OLD."proposed_by" OR NEW."proposed_at" <> OLD."proposed_at"
|
||||
OR NEW."kind" <> OLD."kind" OR NEW."covered_submission_ids" <> OLD."covered_submission_ids"
|
||||
OR NEW."failure_scope" <> OLD."failure_scope" THEN
|
||||
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- Per-submission failure attributions: at most one open/approved per batch,
|
||||
-- kind and covered submissions (settlement keeps covered = [] → one per batch).
|
||||
DROP INDEX "loan_receipt_attributions_one_active_per_batch_kind";
|
||||
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_scope"
|
||||
ON "loan_receipt_attributions"("payment_batch_id", "kind", "failure_scope", "covered_submission_ids") WHERE "status" IN ('proposed', 'approved');
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_submission_scope_check" CHECK (
|
||||
"failure_scope" <> 'submission' OR ("kind" = 'failure' AND jsonb_array_length("covered_submission_ids") = 1));
|
||||
在新工单中引用
屏蔽一个用户