- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时, 最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过, 证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用 - 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、 回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须 当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、 人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改 - 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选 判断,核销之后才出现的新证据留待晚到结果对账流程处理 - 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认 - 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、 PC 浏览器完整登记复核重登流程;变异测试均变红 注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认 失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
84 行
5.4 KiB
PL/PgSQL
84 行
5.4 KiB
PL/PgSQL
-- Receipt-to-batch attribution for loan repayments. When another same-lender,
|
|
-- same-amount loan batch was ever exported or had receipts, a matched receipt
|
|
-- alone does not prove which batch it paid; settlement then requires an
|
|
-- independently reviewed attribution bound to the exact transaction fingerprint
|
|
-- and to the receipt evidence (verification and manual-proposal ids) reviewed.
|
|
|
|
-- CreateTable
|
|
CREATE TABLE "loan_receipt_attributions" (
|
|
"id" UUID NOT NULL,
|
|
"payment_batch_id" UUID NOT NULL,
|
|
"fingerprint" VARCHAR(64) NOT NULL,
|
|
"key_id" VARCHAR(64) NOT NULL,
|
|
"verification_ids" JSONB NOT NULL,
|
|
"manual_proposal_ids" JSONB NOT NULL,
|
|
"competitor_batch_ids" JSONB NOT NULL,
|
|
"basis_kind" VARCHAR(32) NOT NULL,
|
|
"basis_note" VARCHAR(1000) NOT NULL,
|
|
"exclusion_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_receipt_attributions_pkey" PRIMARY KEY ("id"),
|
|
CONSTRAINT "loan_receipt_attributions_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
|
|
CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN
|
|
('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved')),
|
|
CONSTRAINT "loan_receipt_attributions_notes_check" CHECK (
|
|
char_length(btrim("basis_note")) >= 10 AND char_length(btrim("exclusion_note")) >= 10),
|
|
CONSTRAINT "loan_receipt_attributions_evidence_check" CHECK (
|
|
jsonb_typeof("verification_ids") = 'array' AND jsonb_array_length("verification_ids") > 0
|
|
AND jsonb_typeof("manual_proposal_ids") = 'array'),
|
|
CONSTRAINT "loan_receipt_attributions_competitors_check" CHECK (
|
|
jsonb_typeof("competitor_batch_ids") = 'array' AND jsonb_array_length("competitor_batch_ids") > 0),
|
|
CONSTRAINT "loan_receipt_attributions_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_receipt_attributions_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
|
|
CONSTRAINT "loan_receipt_attributions_independent_check" CHECK (
|
|
"reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
|
|
);
|
|
|
|
-- CreateIndex
|
|
CREATE INDEX "loan_receipt_attributions_batch_fingerprint_idx" ON "loan_receipt_attributions"("payment_batch_id", "fingerprint");
|
|
|
|
-- At most one open or approved attribution per batch; rejected and superseded rows stay as history.
|
|
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch"
|
|
ON "loan_receipt_attributions"("payment_batch_id") WHERE "status" IN ('proposed', 'approved');
|
|
|
|
-- AddForeignKey
|
|
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
|
|
-- History is append-only: proposed -> approved/rejected/superseded, approved ->
|
|
-- superseded (when the receipt or competitor set changed); nothing else, no deletes.
|
|
CREATE 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" THEN
|
|
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER loan_receipt_attribution_history BEFORE UPDATE OR DELETE ON "loan_receipt_attributions"
|
|
FOR EACH ROW EXECUTE FUNCTION guard_loan_receipt_attribution_history();
|