借款对账 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]>
这个提交包含在:
2026-10-07 16:50:17 +08:00
共同撰写人 Claude Opus 5.5
父节点 db28706177
当前提交 14aee82d5c
共修改 12 个文件,包含 1299 行新增和 81 行删除
+79
查看文件
@@ -741,6 +741,9 @@ model Employee {
loan_bank_submissions_registered LoanBankSubmission[]
loan_submission_attestations_proposed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationProposer")
loan_submission_attestations_reviewed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationReviewer")
loan_terminal_evidences_uploaded LoanBankTerminalEvidence[]
loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer")
loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer")
@@map("employees")
}
@@ -1985,6 +1988,9 @@ model PaymentBatch {
loan_legacy_export_summary LoanLegacyExportSummary?
loan_bank_submissions LoanBankSubmission[]
loan_bank_submission_attestations LoanBankSubmissionAttestation[]
loan_terminal_evidences LoanBankTerminalEvidence[]
loan_terminal_facts LoanBankTerminalFact[]
loan_failure_evidence_uses LoanFailureEvidenceUse[]
@@map("payment_batches")
}
@@ -2043,6 +2049,7 @@ model PaymentBatchReceiptAttachment {
receipt_ocr_state String @default("legacy") @db.VarChar(32)
receipt_ocr_started_at DateTime?
loan_terminal_facts LoanBankTerminalFact[]
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
@@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256")
@@ -2066,6 +2073,7 @@ model PaymentReceiptVerification {
claims PaymentReceiptClaim[]
manual_proposals PaymentReceiptManualProposal[]
loan_terminal_facts LoanBankTerminalFact[]
@@index([attachment_id, created_at])
@@map("payment_receipt_verifications")
}
@@ -4004,6 +4012,7 @@ model LoanReceiptAttribution {
manual_proposal_ids Json
kind String @default("settlement") @db.VarChar(16)
covered_submission_ids Json @default("[]")
failure_scope String @default("batch") @db.VarChar(16)
competitor_batch_ids Json
basis_kind String @db.VarChar(32)
basis_note String @db.VarChar(1000)
@@ -4076,6 +4085,8 @@ model LoanBankSubmission {
export PaymentOrderExport? @relation(fields: [export_id], references: [id], onDelete: Restrict)
registrar Employee @relation(fields: [registered_by], references: [id], onDelete: Restrict)
terminal_facts LoanBankTerminalFact[]
@@index([payment_batch_id, status], map: "loan_bank_submissions_batch_idx")
@@map("loan_bank_submissions")
}
@@ -4101,3 +4112,71 @@ model LoanBankSubmissionAttestation {
@@map("loan_bank_submission_attestations")
}
// Loan bank-result reconciliation S3 (migration 20261008010000_loan_bank_terminal_facts).
// Evidence is stored apart from payment receipts; history triggers live in the migration.
model LoanBankTerminalEvidence {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
storage_path String
original_name String @db.VarChar(255)
mime_type String @db.VarChar(100)
file_size Int
file_sha256 String @db.VarChar(64)
uploaded_by String @db.Uuid
uploaded_at DateTime @default(now())
voided_by String? @db.Uuid
voided_at DateTime?
void_reason String? @db.VarChar(500)
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict)
facts LoanBankTerminalFact[]
@@index([payment_batch_id], map: "loan_bank_terminal_evidences_batch_idx")
@@map("loan_bank_terminal_evidences")
}
model LoanBankTerminalFact {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
submission_id String @db.Uuid
kind String @db.VarChar(32)
terminal_status String @db.VarChar(16)
evidence_id String? @db.Uuid
receipt_attachment_id String? @db.Uuid
verification_id String? @db.Uuid
resolves_attachment_ids Json @default("[]")
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)
submission LoanBankSubmission @relation(fields: [submission_id], references: [id], onDelete: Restrict)
evidence LoanBankTerminalEvidence? @relation(fields: [evidence_id], references: [id], onDelete: Restrict)
receipt_attachment PaymentBatchReceiptAttachment? @relation(fields: [receipt_attachment_id], references: [id], onDelete: Restrict)
verification PaymentReceiptVerification? @relation(fields: [verification_id], references: [id], onDelete: Restrict)
proposer Employee @relation("LoanTerminalFactProposer", fields: [proposed_by], references: [id], onDelete: Restrict)
reviewer Employee? @relation("LoanTerminalFactReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict)
@@index([payment_batch_id, status], map: "loan_bank_terminal_facts_batch_idx")
@@map("loan_bank_terminal_facts")
}
// One failed bank transaction backs the release of at most one batch.
model LoanFailureEvidenceUse {
fingerprint String @id @db.VarChar(64)
key_id String @db.VarChar(64)
payment_batch_id String @db.Uuid
source String @db.VarChar(32)
created_at DateTime @default(now())
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
@@map("loan_failure_evidence_uses")
}