借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)

- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时,
  最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过,
  证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用
- 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、
  回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须
  当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、
  人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改
- 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选
  判断,核销之后才出现的新证据留待晚到结果对账流程处理
- 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认
- 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、
  PC 浏览器完整登记复核重登流程;变异测试均变红

注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认
失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-06 20:20:51 +08:00
共同撰写人 Claude Opus 5.5
父节点 b639422211
当前提交 b88790d396
共修改 13 个文件,包含 718 行新增和 31 行删除
@@ -0,0 +1,83 @@
-- 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();
+34
查看文件
@@ -735,6 +735,8 @@ model Employee {
loan_lender_identities LoanLender[]
loan_evidence_uploads LoanEvidence[]
loan_receipt_attributions_proposed LoanReceiptAttribution[] @relation("LoanReceiptAttributionProposer")
loan_receipt_attributions_reviewed LoanReceiptAttribution[] @relation("LoanReceiptAttributionReviewer")
@@map("employees")
}
@@ -1974,6 +1976,7 @@ model PaymentBatch {
@@index([legal_entity_id], map: "idx_payment_batches_legal_entity")
@@index([payment_account_id], map: "idx_payment_batches_payment_account")
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
loan_receipt_attributions LoanReceiptAttribution[]
@@map("payment_batches")
}
@@ -3979,3 +3982,34 @@ model LoanEvidence {
@@unique([loan_id, file_sha256, kind])
@@map("loan_evidences")
}
// Independently reviewed receipt-to-batch attribution for ambiguous loan
// repayments (same lender and amount as another exported/receipted batch).
// Partial unique index (one proposed/approved per batch) and history trigger
// live in migration 20261006010000_loan_receipt_attribution.
model LoanReceiptAttribution {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
fingerprint String @db.VarChar(64)
key_id String @db.VarChar(64)
verification_ids Json
manual_proposal_ids Json
competitor_batch_ids Json
basis_kind String @db.VarChar(32)
basis_note String @db.VarChar(1000)
exclusion_note String @db.VarChar(1000)
status String @default("proposed") @db.VarChar(16)
proposed_by String @db.Uuid
proposed_at DateTime @default(now())
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String? @db.VarChar(500)
superseded_at DateTime?
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
proposer Employee @relation("LoanReceiptAttributionProposer", fields: [proposed_by], references: [id], onDelete: Restrict)
reviewer Employee? @relation("LoanReceiptAttributionReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict)
@@index([payment_batch_id, fingerprint], map: "loan_receipt_attributions_batch_fingerprint_idx")
@@map("loan_receipt_attributions")
}