新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
147 行
11 KiB
PL/PgSQL
147 行
11 KiB
PL/PgSQL
-- S4: separately reviewed manual no-submission exception, never bank finality.
|
|
ALTER TABLE loan_bank_terminal_evidences ADD COLUMN evidence_kind VARCHAR(32) NOT NULL DEFAULT 'bank_terminal'
|
|
CHECK(evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement'));
|
|
CREATE FUNCTION guard_loan_evidence_kind() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF NEW.evidence_kind IS DISTINCT FROM OLD.evidence_kind THEN RAISE EXCEPTION 'evidence category immutable' USING ERRCODE='23514'; END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER loan_evidence_kind_guard BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION guard_loan_evidence_kind();
|
|
CREATE TABLE loan_not_submitted_proposals (
|
|
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
|
sequence INTEGER NOT NULL CHECK(sequence>0), status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','rejected','superseded')),
|
|
proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
payload_json JSONB NOT NULL, snapshot_json JSONB NOT NULL, UNIQUE(payment_batch_id,sequence)
|
|
);
|
|
CREATE UNIQUE INDEX loan_not_submitted_open ON loan_not_submitted_proposals(payment_batch_id) WHERE status IN ('proposed','approved');
|
|
CREATE TABLE loan_not_submitted_reviews (
|
|
id UUID PRIMARY KEY, proposal_id UUID NOT NULL REFERENCES loan_not_submitted_proposals(id) ON DELETE RESTRICT,
|
|
reviewed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
|
decision VARCHAR(16) NOT NULL CHECK(decision IN ('approve','reject')), reason VARCHAR(500) NOT NULL CHECK(length(trim(reason))>=2),
|
|
reviewed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(proposal_id,reviewed_by)
|
|
);
|
|
CREATE FUNCTION guard_loan_not_submitted() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE p loan_not_submitted_proposals; b payment_batches;
|
|
BEGIN
|
|
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'manual fact history immutable' USING ERRCODE='23514'; END IF;
|
|
IF TG_TABLE_NAME='loan_not_submitted_reviews' THEN
|
|
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'manual review immutable' USING ERRCODE='23514'; END IF;
|
|
SELECT * INTO p FROM loan_not_submitted_proposals WHERE id=NEW.proposal_id;
|
|
PERFORM id FROM payment_batches WHERE id=p.payment_batch_id FOR UPDATE;
|
|
IF p.status<>'proposed' OR NEW.reviewed_by=p.proposed_by OR
|
|
(SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id)>=2 THEN
|
|
RAISE EXCEPTION 'invalid manual independent review' USING ERRCODE='23514';
|
|
END IF;
|
|
ELSE
|
|
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
|
|
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') THEN RAISE EXCEPTION 'manual fact batch unavailable' USING ERRCODE='23514'; END IF;
|
|
IF TG_OP='INSERT' THEN
|
|
IF NEW.status<>'proposed' OR NEW.sequence<>COALESCE((SELECT max(sequence)+1 FROM loan_not_submitted_proposals WHERE payment_batch_id=b.id),1) THEN
|
|
RAISE EXCEPTION 'manual fact initial state invalid' USING ERRCODE='23514'; END IF;
|
|
ELSE
|
|
IF (to_jsonb(NEW)-'status') IS DISTINCT FROM (to_jsonb(OLD)-'status') OR
|
|
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','rejected','superseded')) OR (OLD.status='approved' AND NEW.status='superseded')) THEN
|
|
RAISE EXCEPTION 'manual fact history immutable' USING ERRCODE='23514'; END IF;
|
|
IF NEW.status='approved' AND (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=NEW.id AND decision='approve')<>2 THEN
|
|
RAISE EXCEPTION 'two independent approvals required' USING ERRCODE='23514'; END IF;
|
|
IF NEW.status='rejected' AND NOT EXISTS(SELECT 1 FROM loan_not_submitted_reviews WHERE proposal_id=NEW.id AND decision='reject') THEN
|
|
RAISE EXCEPTION 'rejection required' USING ERRCODE='23514'; END IF;
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER loan_not_submitted_history BEFORE INSERT OR UPDATE OR DELETE ON loan_not_submitted_proposals FOR EACH ROW EXECUTE FUNCTION guard_loan_not_submitted();
|
|
CREATE TRIGGER loan_not_submitted_review_history BEFORE INSERT OR UPDATE OR DELETE ON loan_not_submitted_reviews FOR EACH ROW EXECUTE FUNCTION guard_loan_not_submitted();
|
|
ALTER TABLE loan_repayments DROP CONSTRAINT loan_repayments_amount_state_check;
|
|
ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK (
|
|
amount > 0 AND currency_code = 'CNY' AND version > 0
|
|
AND lender_identity_version > 0 AND lender_account_version > 0
|
|
AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success')
|
|
AND (bank_result_state NOT IN ('confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL)
|
|
AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL)
|
|
OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL))
|
|
);
|
|
|
|
-- Integrity and history functions re-created with confirmed_not_paid as a
|
|
-- cancellable terminal state (otherwise identical to 20260917010000).
|
|
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
|
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
|
BEGIN
|
|
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
|
IF NOT FOUND THEN RETURN; END IF;
|
|
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
|
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
|
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
|
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
|
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
|
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
|
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
|
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
|
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
|
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
|
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
|
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
|
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
|
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
|
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
|
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
|
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
|
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid','confirmed_not_submitted'))
|
|
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
|
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
|
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
|
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
|
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) >
|
|
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
|
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
|
END LOOP;
|
|
END $$;
|
|
|
|
CREATE OR REPLACE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF TG_TABLE_NAME='loan_repayment_allocations' THEN
|
|
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF;
|
|
IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM
|
|
(OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version)
|
|
OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD)
|
|
THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF;
|
|
ELSE
|
|
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF;
|
|
IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount,
|
|
NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key)
|
|
IS DISTINCT FROM
|
|
(OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount,
|
|
OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key)
|
|
OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success') AND
|
|
(NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref))
|
|
OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent')
|
|
OR (OLD.accounting_handoff_at IS NOT NULL AND
|
|
(NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM
|
|
(OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref))
|
|
THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
|
|
|
|
-- Even a direct SQL state change requires two distinct, recorded approvals.
|
|
CREATE FUNCTION check_loan_manual_conclusion() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals;
|
|
BEGIN
|
|
SELECT * INTO r FROM loan_repayments WHERE id=NEW.id;
|
|
IF r.bank_result_state<>'confirmed_not_submitted' THEN RETURN NEW; END IF;
|
|
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id;
|
|
SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
|
IF p.id IS NULL OR p.status<>'approved' OR (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN
|
|
RAISE EXCEPTION 'manual conclusion requires two approvals' USING ERRCODE='23514'; END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE CONSTRAINT TRIGGER loan_manual_conclusion AFTER INSERT OR UPDATE ON loan_repayments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_conclusion();
|
|
|
|
-- The Q6 exception attests an empty submission list, with actual exports.
|
|
ALTER TABLE loan_bank_submission_attestations DROP CONSTRAINT loan_bank_submission_attestations_ids_check;
|
|
ALTER TABLE loan_bank_submission_attestations ADD CONSTRAINT loan_bank_submission_attestations_ids_check CHECK (
|
|
jsonb_typeof(submission_ids)='array' AND jsonb_typeof(all_submission_ids)='array' AND jsonb_typeof(export_ids)='array'
|
|
AND (jsonb_array_length(submission_ids)>0 OR (jsonb_array_length(all_submission_ids)=0 AND jsonb_array_length(export_ids)>0))
|
|
);
|