借款对账 S1、S2:导出与银行提交分开登记、确认失败收紧及失败回单识别口径(09-17-company-loan-principal-repayment)
S1(迁移 20261007010000) - 借款导出逐次记录;存量批次保留已知导出次数为历史汇总,不视为真实导出,存在即拦截 - 银行提交登记(只能撤回一次)与提交清单确认;独立复核,复核后任何登记、撤回、导出即失效 - 确认失败只计未作废附件;作废附件无法证明为明确失败或确属他笔付款即阻断 - S3 前临时拦截:多次导出、多次提交、清单未复核或历史汇总均不能确认失败 - 确认失败改按付款后规则;确认人不得兼任导出、提交登记、清单确认 S2(迁移 20261007020000) - 存在同金额候选时须有已复核、覆盖提交清单的失败归属;结算与失败归属分槽 失败回单识别口径(用户同意并入) - 新增原始状态措辞字段(多字段原文不一致即为空);失败事实严格判定:单页完整、 仅“交易失败/转账失败/付款失败/支付失败”、唯一问题为交易失败、字段齐全; 已撤销、已冲正、已退汇不接受;顶层识别状态与成功匹配不变 - 测试改走真实识别与归一化链,仅替换云端 OCR 响应 验证:借款 HTTP 41 组、浏览器 7 组、76 迁移;server 4463;共享回单测试 237; 三轮同模型只读复核的阻断项均已修复。 注意:界面未完成,不能单独部署;真实回单待验。未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,162 @@
|
||||
-- S1 of the loan bank-result reconciliation design (design.md 3.1):
|
||||
-- payment-order exports and bank submissions are different facts. One export
|
||||
-- file may be submitted zero, one or several times; the reviewed submission
|
||||
-- list, not the export count, is what failure/termination evidence must cover.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment_order_exports" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"exported_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"exported_by" UUID NOT NULL,
|
||||
"file_sha256" VARCHAR(64) NOT NULL,
|
||||
"customer_serials" JSONB NOT NULL,
|
||||
"total_amount" DECIMAL(14,2) NOT NULL,
|
||||
|
||||
CONSTRAINT "payment_order_exports_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "payment_order_exports_sequence_check" CHECK ("sequence" > 0),
|
||||
CONSTRAINT "payment_order_exports_sha_check" CHECK ("file_sha256" ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT "payment_order_exports_serials_check" CHECK (
|
||||
jsonb_typeof("customer_serials") = 'array' AND jsonb_array_length("customer_serials") > 0)
|
||||
);
|
||||
CREATE UNIQUE INDEX "payment_order_exports_batch_sequence_key" ON "payment_order_exports"("payment_batch_id", "sequence");
|
||||
|
||||
-- Pre-existing loan batches only know how many times they were exported. The
|
||||
-- summary keeps that known count and states what is unknown; it is never a
|
||||
-- real export or bank submission and blocks failure confirmation and release.
|
||||
CREATE TABLE "loan_legacy_export_summaries" (
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"known_export_count" INTEGER NOT NULL,
|
||||
"last_exported_at" TIMESTAMP(3),
|
||||
"last_exported_by" UUID,
|
||||
"unknown_facts" VARCHAR(200) NOT NULL DEFAULT 'per_export_detail,file_digest,bank_submission_mapping',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "loan_legacy_export_summaries_pkey" PRIMARY KEY ("payment_batch_id"),
|
||||
CONSTRAINT "loan_legacy_export_summaries_count_check" CHECK ("known_export_count" > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE "loan_bank_submissions" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"export_id" UUID,
|
||||
"channel" VARCHAR(32) NOT NULL,
|
||||
"submitted_at" TIMESTAMP(3) NOT NULL,
|
||||
"bank_instruction_ref" VARCHAR(120) NOT NULL,
|
||||
"customer_serial" VARCHAR(64),
|
||||
"note" VARCHAR(1000) NOT NULL,
|
||||
"status" VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
"withdrawn_reason" VARCHAR(500),
|
||||
"withdrawn_at" TIMESTAMP(3),
|
||||
"registered_by" UUID NOT NULL,
|
||||
"registered_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "loan_bank_submissions_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_submissions_channel_check" CHECK ("channel" IN ('online_banking_import', 'manual_entry', 'other')),
|
||||
CONSTRAINT "loan_bank_submissions_import_export_check" CHECK ("channel" <> 'online_banking_import' OR "export_id" IS NOT NULL),
|
||||
CONSTRAINT "loan_bank_submissions_ref_check" CHECK (char_length(btrim("bank_instruction_ref")) >= 2 AND char_length(btrim("note")) >= 5),
|
||||
CONSTRAINT "loan_bank_submissions_status_check" CHECK (
|
||||
("status" = 'active' AND "withdrawn_at" IS NULL AND "withdrawn_reason" IS NULL)
|
||||
OR ("status" = 'withdrawn' AND "withdrawn_at" IS NOT NULL AND char_length(btrim("withdrawn_reason")) >= 2))
|
||||
);
|
||||
CREATE INDEX "loan_bank_submissions_batch_idx" ON "loan_bank_submissions"("payment_batch_id", "status");
|
||||
|
||||
-- An independently reviewed statement that the active submissions (and the
|
||||
-- export records) listed are the complete set for the batch. It also snapshots
|
||||
-- every submission row including withdrawn ones, so any registration or
|
||||
-- withdrawal after review invalidates the approval.
|
||||
CREATE TABLE "loan_bank_submission_attestations" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"submission_ids" JSONB NOT NULL,
|
||||
"all_submission_ids" JSONB NOT NULL,
|
||||
"export_ids" JSONB NOT NULL,
|
||||
"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_submission_attestations_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_submission_attestations_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
|
||||
CONSTRAINT "loan_bank_submission_attestations_ids_check" CHECK (
|
||||
jsonb_typeof("submission_ids") = 'array' AND jsonb_array_length("submission_ids") > 0
|
||||
AND jsonb_typeof("all_submission_ids") = 'array' AND jsonb_typeof("export_ids") = 'array'),
|
||||
CONSTRAINT "loan_bank_submission_attestations_note_check" CHECK (char_length(btrim("note")) >= 5),
|
||||
CONSTRAINT "loan_bank_submission_attestations_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_submission_attestations_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
|
||||
CONSTRAINT "loan_bank_submission_attestations_independent_check" CHECK ("reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
|
||||
);
|
||||
CREATE UNIQUE INDEX "loan_bank_submission_attestations_one_active_per_batch"
|
||||
ON "loan_bank_submission_attestations"("payment_batch_id") WHERE "status" IN ('proposed', 'approved');
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment_order_exports" ADD CONSTRAINT "payment_order_exports_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "payment_order_exports" ADD CONSTRAINT "payment_order_exports_exported_by_fkey" FOREIGN KEY ("exported_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_legacy_export_summaries" ADD CONSTRAINT "loan_legacy_export_summaries_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_export_id_fkey" FOREIGN KEY ("export_id") REFERENCES "payment_order_exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_registered_by_fkey" FOREIGN KEY ("registered_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: known export counts of existing loan batches become summaries.
|
||||
INSERT INTO "loan_legacy_export_summaries" ("payment_batch_id", "known_export_count", "last_exported_at", "last_exported_by")
|
||||
SELECT "id", "payment_order_export_count", "payment_order_last_exported_at", "payment_order_last_exported_by"
|
||||
FROM "payment_batches" WHERE "entity_type" = 'loan_repayment' AND "payment_order_export_count" > 0;
|
||||
|
||||
-- History guards: exports and summaries are append-only; submissions may only
|
||||
-- be withdrawn once; attestations follow proposed -> approved/rejected/superseded,
|
||||
-- approved -> superseded.
|
||||
CREATE FUNCTION guard_loan_reconciliation_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME;
|
||||
END $$;
|
||||
CREATE TRIGGER payment_order_exports_append_only BEFORE UPDATE OR DELETE ON "payment_order_exports"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE TRIGGER loan_legacy_export_summaries_append_only BEFORE UPDATE OR DELETE ON "loan_legacy_export_summaries"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
|
||||
CREATE FUNCTION guard_loan_bank_submission_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan bank submission history is immutable'; END IF;
|
||||
IF OLD."status" <> 'active' OR NEW."status" <> 'withdrawn'
|
||||
OR (NEW."payment_batch_id", NEW."export_id", NEW."channel", NEW."submitted_at", NEW."bank_instruction_ref",
|
||||
NEW."customer_serial", NEW."note", NEW."registered_by", NEW."registered_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."export_id", OLD."channel", OLD."submitted_at", OLD."bank_instruction_ref",
|
||||
OLD."customer_serial", OLD."note", OLD."registered_by", OLD."registered_at") THEN
|
||||
RAISE EXCEPTION 'loan bank submission can only be withdrawn once';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_submission_history BEFORE UPDATE OR DELETE ON "loan_bank_submissions"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_submission_history();
|
||||
|
||||
CREATE FUNCTION guard_loan_submission_attestation_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan submission attestation history is immutable'; END IF;
|
||||
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan submission attestation is immutable'; END IF;
|
||||
IF (NEW."payment_batch_id", NEW."submission_ids", NEW."all_submission_ids", NEW."export_ids", NEW."note", NEW."proposed_by", NEW."proposed_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."submission_ids", OLD."all_submission_ids", OLD."export_ids", OLD."note", OLD."proposed_by", OLD."proposed_at") THEN
|
||||
RAISE EXCEPTION 'loan submission attestation 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 submission attestation 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 submission attestation cannot carry review fields';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_submission_attestation_history BEFORE UPDATE OR DELETE ON "loan_bank_submission_attestations"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_submission_attestation_history();
|
||||
@@ -0,0 +1,45 @@
|
||||
-- S2 of the loan bank-result reconciliation design (design.md 3.1): a failed
|
||||
-- receipt on a batch with same-lender, same-amount competitors needs an
|
||||
-- independently reviewed attribution that names the bank submissions it covers.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "loan_receipt_attributions"
|
||||
ADD COLUMN "kind" VARCHAR(16) NOT NULL DEFAULT 'settlement',
|
||||
ADD COLUMN "covered_submission_ids" JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_kind_check" CHECK ("kind" IN ('settlement', 'failure'));
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_covered_check" CHECK (
|
||||
jsonb_typeof("covered_submission_ids") = 'array'
|
||||
AND ("kind" <> 'failure' OR (jsonb_array_length("covered_submission_ids") > 0 AND jsonb_array_length("verification_ids") = 1)));
|
||||
|
||||
ALTER TABLE "loan_receipt_attributions" DROP CONSTRAINT "loan_receipt_attributions_basis_kind_check";
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN
|
||||
('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved', 'bank_instruction_ref'));
|
||||
|
||||
-- One open/approved attribution per batch and kind.
|
||||
DROP INDEX "loan_receipt_attributions_one_active_per_batch";
|
||||
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch_kind"
|
||||
ON "loan_receipt_attributions"("payment_batch_id", "kind") WHERE "status" IN ('proposed', 'approved');
|
||||
|
||||
-- The proposal (now including kind and covered submissions) stays immutable.
|
||||
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" THEN
|
||||
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
在新工单中引用
屏蔽一个用户