借款对账 SR-A/B:补证恢复与复核后释放(09-17-company-loan-principal-repayment)

保留原银行终态及依据,以恢复轮次绑定材料、候选和独立性;最终取消重验并原子消费批准、释放占用和记录审计。新增增量迁移、HTTP 回归及数据库保护。

验证:78 个迁移、HTTP 72 组、既有浏览器 7 组、数据库约束 9 组通过;7 个变异均被抓住。服务端首跑 4470 通过、6 失败、1 跳过,补齐 BIFF8 测试依赖后相关 146 项通过,未重跑完整套件。

限制:inline 主会话自查不等于独立复核,独立复核待完成;界面未完成,不能单独部署;真实回单待验。新恢复界面留待 S8,S4 另批,整体借款功能尚未完成验收。本次仅本地提交,不推送、不部署、不查询生产。
这个提交包含在:
2026-10-07 20:07:57 +08:00
父节点 14aee82d5c
当前提交 2e00d30fd0
共修改 15 个文件,包含 960 行新增和 9 行删除
@@ -0,0 +1,142 @@
-- SR: append recovery decisions without reopening the original bank outcome.
CREATE TABLE loan_release_recoveries (
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
sequence INTEGER NOT NULL CHECK(sequence > 0), previous_id UUID REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT,
request_key UUID NOT NULL, original_state VARCHAR(32) NOT NULL CHECK(original_state IN ('confirmed_failed','confirmed_not_paid')),
original_ref TEXT NOT NULL CHECK(length(trim(original_ref)) > 0), reason VARCHAR(1000) NOT NULL CHECK(length(trim(reason))>=5),
started_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, started_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(16) NOT NULL DEFAULT 'collecting' CHECK(status IN ('collecting','proposed','approved','rejected','withdrawn','superseded','consumed')),
submitted_by UUID REFERENCES employees(id) ON DELETE RESTRICT, submitted_at TIMESTAMP(3), snapshot_json JSONB,
reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(500), ended_at TIMESTAMP(3),
cancel_request_key UUID, consumed_by UUID REFERENCES employees(id) ON DELETE RESTRICT,
UNIQUE(payment_batch_id,sequence), UNIQUE(payment_batch_id,request_key),
CHECK(submitted_by IS NULL OR submitted_by=started_by),
CHECK(reviewed_by IS NULL OR (reviewed_by<>started_by AND reviewed_by<>submitted_by)),
CHECK(status NOT IN ('proposed','approved','rejected','consumed') OR (snapshot_json IS NOT NULL AND submitted_by IS NOT NULL AND submitted_at IS NOT NULL)),
CHECK(status NOT IN ('approved','rejected','consumed') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>0)),
CHECK(status<>'consumed' OR (cancel_request_key IS NOT NULL AND consumed_by IS NOT NULL)),
CHECK(status NOT IN ('withdrawn','superseded','rejected','consumed') OR ended_at IS NOT NULL)
);
CREATE UNIQUE INDEX loan_release_recoveries_open ON loan_release_recoveries(payment_batch_id) WHERE status IN ('collecting','proposed','approved');
CREATE TABLE loan_release_recovery_events (
id UUID PRIMARY KEY, recovery_id UUID NOT NULL REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT,
action VARCHAR(32) NOT NULL, actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, note VARCHAR(1000) NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX loan_release_recovery_events_recovery_id_idx ON loan_release_recovery_events(recovery_id);
CREATE TABLE loan_release_material_revisions (
payment_batch_id UUID PRIMARY KEY REFERENCES payment_batches(id) ON DELETE RESTRICT,
revision BIGINT NOT NULL DEFAULT 0 CHECK(revision>=0)
);
CREATE FUNCTION guard_loan_release_recovery_history() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE b payment_batches; r loan_repayments;
BEGIN
IF TG_OP='DELETE' OR TG_TABLE_NAME='loan_release_recovery_events' THEN
RAISE EXCEPTION 'recovery history is immutable' USING ERRCODE='23514';
END IF;
IF TG_OP='INSERT' THEN
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
SELECT * INTO r FROM loan_repayments WHERE id=b.loan_repayment_id;
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') OR
NEW.original_state IS DISTINCT FROM r.bank_result_state OR NEW.original_ref IS DISTINCT FROM r.bank_result_ref OR
NEW.status<>'collecting' OR NEW.snapshot_json IS NOT NULL OR NEW.reviewed_by IS NOT NULL OR
NEW.submitted_by IS NOT NULL OR NEW.submitted_at IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR
NEW.ended_at IS NOT NULL OR NEW.cancel_request_key IS NOT NULL OR NEW.consumed_by IS NOT NULL OR
NOT EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id) OR
EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state<>'reserved') THEN
RAISE EXCEPTION 'invalid recovery source' USING ERRCODE='23514';
END IF;
IF NEW.sequence <> COALESCE((SELECT max(sequence)+1 FROM loan_release_recoveries WHERE payment_batch_id=b.id),1) OR
NEW.previous_id IS DISTINCT FROM (SELECT id FROM loan_release_recoveries WHERE payment_batch_id=b.id ORDER BY sequence DESC LIMIT 1) THEN
RAISE EXCEPTION 'invalid recovery predecessor' USING ERRCODE='23514';
END IF;
RETURN NEW;
END IF;
IF (to_jsonb(NEW)-ARRAY['status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by'])
IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by']) OR
NOT ((OLD.status='collecting' AND NEW.status IN ('proposed','withdrawn')) OR
(OLD.status='proposed' AND NEW.status IN ('approved','rejected','withdrawn','superseded')) OR
(OLD.status='approved' AND NEW.status IN ('superseded','consumed'))) THEN
RAISE EXCEPTION 'invalid recovery transition' USING ERRCODE='23514';
END IF;
IF NOT (OLD.status='collecting' AND NEW.status='proposed') AND
(NEW.submitted_by,NEW.submitted_at,NEW.snapshot_json) IS DISTINCT FROM (OLD.submitted_by,OLD.submitted_at,OLD.snapshot_json) THEN
RAISE EXCEPTION 'immutable recovery submission' USING ERRCODE='23514';
END IF;
IF NOT (OLD.status='proposed' AND NEW.status IN ('approved','rejected')) AND
(NEW.reviewed_by,NEW.reviewed_at,NEW.review_note) IS DISTINCT FROM (OLD.reviewed_by,OLD.reviewed_at,OLD.review_note) THEN
RAISE EXCEPTION 'immutable recovery decision' USING ERRCODE='23514';
END IF;
IF NEW.status<>'consumed' AND (NEW.cancel_request_key,NEW.consumed_by) IS DISTINCT FROM (OLD.cancel_request_key,OLD.consumed_by) THEN
RAISE EXCEPTION 'invalid recovery consumption' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_release_recovery_history BEFORE INSERT OR UPDATE OR DELETE ON loan_release_recoveries FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history();
CREATE TRIGGER loan_release_recovery_event_history BEFORE UPDATE OR DELETE ON loan_release_recovery_events FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history();
-- All receipt/terminal/list writers share the parent lock and monotonically
-- advance material revisions, including background OCR and void/reversal ABA.
CREATE FUNCTION touch_loan_release_material() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bid UUID; b payment_batches; lender UUID; data JSONB;
BEGIN
data := CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END;
bid := (data->>'payment_batch_id')::uuid;
IF bid IS NULL THEN
SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(data->>'attachment_id')::uuid;
END IF;
SELECT * INTO b FROM payment_batches WHERE id=bid FOR UPDATE;
IF b.entity_type='loan_repayment' THEN
IF EXISTS(SELECT 1 FROM loan_release_recoveries WHERE payment_batch_id=bid) AND b.status IN ('cancelled','executed') THEN
RAISE EXCEPTION 'closed recovery evidence is immutable' USING ERRCODE='23514';
END IF;
IF TG_OP='INSERT' AND TG_TABLE_NAME IN ('payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments') THEN
SELECT lender_id INTO lender FROM loan_repayments WHERE id=b.loan_repayment_id;
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
END IF;
INSERT INTO loan_release_material_revisions(payment_batch_id,revision) VALUES(bid,1)
ON CONFLICT(payment_batch_id) DO UPDATE SET revision=loan_release_material_revisions.revision+1;
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_order_exports FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submissions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submission_attestations FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_facts FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_receipt_attributions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_batch_receipt_attachments FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_verifications FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_manual_proposals FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
-- Deferred: consumption, allocations and batch cancellation are one commit.
CREATE FUNCTION check_loan_recovery_release() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bid UUID; latest loan_release_recoveries; b payment_batches; rid UUID;
BEGIN
IF TG_TABLE_NAME='loan_release_recoveries' THEN bid:=NEW.payment_batch_id;
ELSE SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; END IF;
SELECT * INTO latest FROM loan_release_recoveries WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
IF latest.id IS NULL THEN RETURN NEW; END IF;
SELECT * INTO b FROM payment_batches WHERE id=bid;
rid:=b.loan_repayment_id;
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state='released') AND
(latest.status<>'consumed' OR b.status<>'cancelled') THEN
RAISE EXCEPTION 'release requires latest recovery consumption' USING ERRCODE='23514';
END IF;
IF latest.status='consumed' AND (b.status<>'cancelled' OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state<>'released')) THEN
RAISE EXCEPTION 'recovery consumption requires atomic cancellation' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE CONSTRAINT TRIGGER loan_recovery_release_check AFTER INSERT OR UPDATE ON loan_release_recoveries DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release();
CREATE CONSTRAINT TRIGGER loan_recovery_allocation_check AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release();
CREATE FUNCTION guard_loan_material_revision() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' OR NEW.payment_batch_id IS DISTINCT FROM OLD.payment_batch_id OR NEW.revision<>OLD.revision+1 THEN
RAISE EXCEPTION 'material revision must increase once' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_material_revision_history BEFORE UPDATE OR DELETE ON loan_release_material_revisions FOR EACH ROW EXECUTE FUNCTION guard_loan_material_revision();
+64
查看文件
@@ -745,6 +745,12 @@ model Employee {
loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer")
loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer")
@@map("employees")
loan_recoveries_started LoanReleaseRecovery[] @relation("LoanReleaseStarter")
loan_recoveries_submitted LoanReleaseRecovery[] @relation("LoanReleaseSubmitter")
loan_recoveries_reviewed LoanReleaseRecovery[] @relation("LoanReleaseReviewer")
loan_recoveries_consumed LoanReleaseRecovery[] @relation("LoanReleaseConsumer")
loan_recovery_events LoanReleaseRecoveryEvent[]
}
model PasswordResetCode {
@@ -1992,6 +1998,9 @@ model PaymentBatch {
loan_terminal_facts LoanBankTerminalFact[]
loan_failure_evidence_uses LoanFailureEvidenceUse[]
@@map("payment_batches")
loan_release_recoveries LoanReleaseRecovery[]
loan_material_revision LoanReleaseMaterialRevision?
}
model PaymentBatchItem {
@@ -4180,3 +4189,58 @@ model LoanFailureEvidenceUse {
@@map("loan_failure_evidence_uses")
}
// SR: original bank conclusions remain immutable; recovery controls release only.
model LoanReleaseRecovery {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
sequence Int
previous_id String? @db.Uuid
request_key String @db.Uuid
original_state String @db.VarChar(32)
original_ref String
reason String @db.VarChar(1000)
started_by String @db.Uuid
started_at DateTime @default(now())
status String @default("collecting") @db.VarChar(16)
submitted_by String? @db.Uuid
submitted_at DateTime?
snapshot_json Json?
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String? @db.VarChar(500)
ended_at DateTime?
cancel_request_key String? @db.Uuid
consumed_by String? @db.Uuid
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
starter Employee @relation("LoanReleaseStarter", fields: [started_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
submitter Employee? @relation("LoanReleaseSubmitter", fields: [submitted_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
reviewer Employee? @relation("LoanReleaseReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
consumer Employee? @relation("LoanReleaseConsumer", fields: [consumed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
previous LoanReleaseRecovery? @relation("LoanRecoveryPrevious", fields: [previous_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
next LoanReleaseRecovery[] @relation("LoanRecoveryPrevious")
events LoanReleaseRecoveryEvent[]
@@unique([payment_batch_id, sequence])
@@unique([payment_batch_id, request_key])
@@map("loan_release_recoveries")
}
model LoanReleaseRecoveryEvent {
id String @id @default(uuid()) @db.Uuid
recovery_id String @db.Uuid
action String @db.VarChar(32)
actor_id String @db.Uuid
note String @db.VarChar(1000)
created_at DateTime @default(now())
recovery LoanReleaseRecovery @relation(fields: [recovery_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
actor Employee @relation(fields: [actor_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([recovery_id])
@@map("loan_release_recovery_events")
}
model LoanReleaseMaterialRevision {
payment_batch_id String @id @db.Uuid
revision BigInt @default(0)
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@map("loan_release_material_revisions")
}