借款对账 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();