文件
hyb-oa/server/prisma/migrations/20261007010000_loan_bank_submissions/migration.sql
T
wangsli和Claude Opus 5.5 db28706177 借款对账 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]>
2026-10-07 11:47:24 +08:00

163 行
11 KiB
PL/PgSQL

-- 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();