借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
@@ -0,0 +1,146 @@
|
||||
-- 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))
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- S5: each cause closes separately. No administrator unfreeze switch.
|
||||
CREATE TABLE loan_freeze_reasons (
|
||||
id UUID PRIMARY KEY, lender_id UUID NOT NULL REFERENCES loan_lenders(id) ON DELETE RESTRICT,
|
||||
payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
kind VARCHAR(32) NOT NULL CHECK(kind IN ('late_success','reservation_conflict','receivable')),
|
||||
source_key VARCHAR(160) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK(status IN ('active','closed')),
|
||||
created_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, closed_at TIMESTAMP(3), closure_basis JSONB,
|
||||
UNIQUE(kind,source_key), CHECK((status='closed')=(closed_by IS NOT NULL AND closed_at IS NOT NULL AND closure_basis IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX loan_freeze_reasons_lender_id_status_idx ON loan_freeze_reasons(lender_id,status);
|
||||
CREATE FUNCTION guard_loan_freeze_reason() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE lender UUID;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'freeze history retained' USING ERRCODE='23514'; END IF;
|
||||
SELECT r.lender_id INTO lender FROM payment_batches b JOIN loan_repayments r ON r.id=b.loan_repayment_id WHERE b.id=NEW.payment_batch_id;
|
||||
IF lender IS DISTINCT FROM NEW.lender_id THEN RAISE EXCEPTION 'freeze source mismatch' USING ERRCODE='23514'; END IF;
|
||||
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'active' OR NEW.closed_by IS NOT NULL OR NEW.closed_at IS NOT NULL OR NEW.closure_basis IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'new freeze must be active' USING ERRCODE='23514'; END IF;
|
||||
ELSIF OLD.status<>'active' OR NEW.status<>'closed' OR
|
||||
(to_jsonb(NEW)-ARRAY['status','closed_by','closed_at','closure_basis']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','closed_by','closed_at','closure_basis']) THEN
|
||||
RAISE EXCEPTION 'freeze history immutable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_freeze_history BEFORE INSERT OR UPDATE OR DELETE ON loan_freeze_reasons FOR EACH ROW EXECUTE FUNCTION guard_loan_freeze_reason();
|
||||
@@ -0,0 +1,224 @@
|
||||
-- S6/S7: append actual bank payments and returns, never issue a payment batch.
|
||||
CREATE TABLE loan_bank_result_events (
|
||||
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
kind VARCHAR(16) NOT NULL CHECK(kind IN ('late_success','return')), parent_id UUID REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
evidence_id UUID NOT NULL REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT,
|
||||
request_key UUID NOT NULL, proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, payload_encrypted TEXT NOT NULL,
|
||||
fingerprint VARCHAR(64) NOT NULL, key_id VARCHAR(64) NOT NULL, amount DECIMAL(14,2) NOT NULL CHECK(amount>0), snapshot_json JSONB NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','booked','dismissed','rejected','superseded')),
|
||||
reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(1000), correction_evidence_id UUID REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT,
|
||||
UNIQUE(payment_batch_id,request_key), CHECK((kind='return')=(parent_id IS NOT NULL)),
|
||||
CHECK(reviewed_by IS NULL OR reviewed_by<>proposed_by),
|
||||
CHECK(status IN ('proposed','superseded') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>=5))
|
||||
);
|
||||
CREATE INDEX loan_bank_result_events_payment_batch_id_kind_idx ON loan_bank_result_events(payment_batch_id,kind);
|
||||
CREATE TABLE loan_supplemental_settlements (
|
||||
id UUID PRIMARY KEY, event_id UUID NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
|
||||
bank_amount DECIMAL(14,2) NOT NULL CHECK(bank_amount>0), settled_amount DECIMAL(14,2) NOT NULL CHECK(settled_amount>=0),
|
||||
receivable_amount DECIMAL(14,2) NOT NULL CHECK(receivable_amount>=0), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id,loan_id), CHECK(bank_amount=settled_amount+receivable_amount)
|
||||
);
|
||||
CREATE TABLE loan_bank_event_claims (
|
||||
fingerprint VARCHAR(64) PRIMARY KEY, key_id VARCHAR(64) NOT NULL,
|
||||
event_id UUID UNIQUE NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TRIGGER loan_supplemental_history BEFORE UPDATE OR DELETE ON loan_supplemental_settlements FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE TRIGGER loan_bank_event_claim_history BEFORE UPDATE OR DELETE ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE FUNCTION guard_loan_bank_event_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'bank event history retained' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'proposed' OR NEW.reviewed_by IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR NEW.correction_evidence_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'bank event initial state invalid' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF (to_jsonb(NEW)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) IS DISTINCT FROM
|
||||
(to_jsonb(OLD)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) OR
|
||||
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','dismissed','rejected','superseded')) OR (OLD.status='approved' AND NEW.status IN ('booked','superseded'))) THEN
|
||||
RAISE EXCEPTION 'bank event immutable' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.status='approved' AND (NEW.reviewed_by,NEW.reviewed_at,NEW.review_note,NEW.correction_evidence_id) IS DISTINCT FROM
|
||||
(OLD.reviewed_by,OLD.reviewed_at,OLD.review_note,OLD.correction_evidence_id) THEN RAISE EXCEPTION 'bank event review immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_event_history BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_result_events FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_event_history();
|
||||
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 coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=loan_uuid) >
|
||||
(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 FUNCTION check_loan_supplemental_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE e loan_bank_result_events; lid UUID; total NUMERIC;
|
||||
BEGIN
|
||||
SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.event_id;
|
||||
IF e.kind<>'late_success' OR e.status<>'booked' OR
|
||||
(SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN
|
||||
RAISE EXCEPTION 'supplemental event totals invalid' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_supplemental_settlements s WHERE s.event_id=e.id AND NOT EXISTS (
|
||||
SELECT 1 FROM loan_repayment_allocations a JOIN payment_batches b ON b.loan_repayment_id=a.repayment_id
|
||||
WHERE b.id=e.payment_batch_id AND a.loan_id=s.loan_id AND a.amount=s.bank_amount AND a.reservation_state='released')) THEN
|
||||
RAISE EXCEPTION 'supplemental source allocation invalid' USING ERRCODE='23514'; END IF;
|
||||
FOR lid IN SELECT loan_id FROM loan_supplemental_settlements WHERE event_id=e.id ORDER BY loan_id LOOP
|
||||
PERFORM id FROM company_loans WHERE id=lid FOR UPDATE;
|
||||
SELECT coalesce(sum(amount),0) INTO total FROM loan_repayment_allocations WHERE loan_id=lid AND reservation_state IN ('reserved','settled');
|
||||
IF total+(SELECT coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=lid)>
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=lid) THEN RAISE EXCEPTION 'supplemental principal exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_supplemental_integrity AFTER INSERT ON loan_supplemental_settlements DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_supplemental_integrity();
|
||||
CREATE OR REPLACE 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 OR EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') 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 FUNCTION guard_loan_bank_transaction_identity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(1002,1);
|
||||
IF TG_TABLE_NAME='loan_bank_event_claims' THEN
|
||||
IF EXISTS(SELECT 1 FROM payment_receipt_claims WHERE fingerprint=NEW.fingerprint) OR
|
||||
EXISTS(SELECT 1 FROM payment_receipt_transaction_aliases WHERE fingerprint=NEW.fingerprint) THEN
|
||||
RAISE EXCEPTION 'bank transaction already used' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE fingerprint=NEW.fingerprint) THEN
|
||||
RAISE EXCEPTION 'bank transaction already supplemented' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_transaction_aliases FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
|
||||
ALTER TABLE loan_bank_terminal_evidences DROP CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check;
|
||||
ALTER TABLE loan_bank_terminal_evidences ADD CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check CHECK(
|
||||
evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement','late_success','incoming_return'));
|
||||
CREATE OR REPLACE 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') AND NOT (TG_OP='INSERT' AND TG_TABLE_NAME='loan_bank_terminal_evidences') 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 $$;
|
||||
|
||||
-- Business application cannot bypass atomic fact/claim/ledger consistency.
|
||||
CREATE FUNCTION check_loan_bank_event_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE e loan_bank_result_events; parent loan_bank_result_events; total NUMERIC; expected NUMERIC;
|
||||
BEGIN
|
||||
SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.id;
|
||||
IF e.status='booked' AND NOT EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE event_id=e.id AND fingerprint=e.fingerprint AND key_id=e.key_id) THEN
|
||||
RAISE EXCEPTION 'bank event requires unique claim' USING ERRCODE='23514'; END IF;
|
||||
IF e.kind='late_success' THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM loan_freeze_reasons WHERE payment_batch_id=e.payment_batch_id AND kind='late_success' AND source_key=e.id::text) THEN
|
||||
RAISE EXCEPTION 'late event requires recorded freeze' USING ERRCODE='23514'; END IF;
|
||||
IF e.status='booked' AND (SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN
|
||||
RAISE EXCEPTION 'late event requires complete supplemental allocation' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO parent FROM loan_bank_result_events WHERE id=e.parent_id;
|
||||
IF parent.kind<>'late_success' OR parent.payment_batch_id<>e.payment_batch_id OR parent.status<>'booked' THEN
|
||||
RAISE EXCEPTION 'return parent invalid' USING ERRCODE='23514'; END IF;
|
||||
SELECT coalesce(sum(receivable_amount),0) INTO expected FROM loan_supplemental_settlements WHERE event_id=e.parent_id;
|
||||
SELECT coalesce(sum(amount),0) INTO total FROM loan_bank_result_events WHERE parent_id=e.parent_id AND kind='return' AND status='booked';
|
||||
IF total>expected THEN RAISE EXCEPTION 'return exceeds receivable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_bank_event_integrity AFTER INSERT OR UPDATE ON loan_bank_result_events DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_bank_event_integrity();
|
||||
|
||||
CREATE FUNCTION check_loan_manual_release() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals; revision BIGINT;
|
||||
BEGIN
|
||||
IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF;
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=NEW.repayment_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;
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
SELECT m.revision INTO revision FROM loan_release_material_revisions m WHERE payment_batch_id=bid;
|
||||
IF p.id IS NULL OR p.status<>'approved' OR (p.snapshot_json->>'revision')::bigint IS DISTINCT FROM COALESCE(revision,0) OR
|
||||
(SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'manual release requires current double review' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_manual_release AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_release();
|
||||
|
||||
CREATE FUNCTION protect_reviewed_bank_event_evidence() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE (evidence_id=OLD.id AND status IN ('approved','booked')) OR (correction_evidence_id=OLD.id AND status='dismissed')) THEN
|
||||
RAISE EXCEPTION 'reviewed bank event evidence retained' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER reviewed_bank_event_evidence BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION protect_reviewed_bank_event_evidence();
|
||||
|
||||
CREATE FUNCTION check_loan_release_against_late_fact() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID;
|
||||
BEGIN
|
||||
IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id;
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status IN ('proposed','approved'))
|
||||
AND NOT EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN
|
||||
RAISE EXCEPTION 'late success blocks original release conclusion' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_release_late_fact AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_release_against_late_fact();
|
||||
@@ -750,6 +750,12 @@ model Employee {
|
||||
loan_recoveries_reviewed LoanReleaseRecovery[] @relation("LoanReleaseReviewer")
|
||||
loan_recoveries_consumed LoanReleaseRecovery[] @relation("LoanReleaseConsumer")
|
||||
loan_recovery_events LoanReleaseRecoveryEvent[]
|
||||
loan_not_submitted_proposals LoanNotSubmittedProposal[]
|
||||
loan_not_submitted_reviews LoanNotSubmittedReview[]
|
||||
loan_freezes_created LoanFreezeReason[] @relation("LoanFreezeCreator")
|
||||
loan_freezes_closed LoanFreezeReason[] @relation("LoanFreezeCloser")
|
||||
loan_bank_events_proposed LoanBankResultEvent[] @relation("LoanBankEventProposer")
|
||||
loan_bank_events_reviewed LoanBankResultEvent[] @relation("LoanBankEventReviewer")
|
||||
|
||||
}
|
||||
|
||||
@@ -1999,6 +2005,9 @@ model PaymentBatch {
|
||||
loan_failure_evidence_uses LoanFailureEvidenceUse[]
|
||||
@@map("payment_batches")
|
||||
loan_release_recoveries LoanReleaseRecovery[]
|
||||
loan_freeze_reasons LoanFreezeReason[]
|
||||
loan_bank_result_events LoanBankResultEvent[]
|
||||
loan_not_submitted_proposals LoanNotSubmittedProposal[]
|
||||
loan_material_revision LoanReleaseMaterialRevision?
|
||||
|
||||
}
|
||||
@@ -3897,6 +3906,7 @@ model LoanLender {
|
||||
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
|
||||
loans CompanyLoan[]
|
||||
repayments LoanRepayment[]
|
||||
freeze_reasons LoanFreezeReason[]
|
||||
|
||||
@@unique([legal_entity_id, id])
|
||||
@@unique([created_by, request_key])
|
||||
@@ -3931,6 +3941,8 @@ model CompanyLoan {
|
||||
allocations LoanRepaymentAllocation[]
|
||||
evidences LoanEvidence[]
|
||||
|
||||
supplemental_settlements LoanSupplementalSettlement[]
|
||||
|
||||
@@unique([created_by, request_key])
|
||||
@@unique([legal_entity_id, lender_id, origin_reference])
|
||||
@@index([lender_id, confirmation_status, received_date])
|
||||
@@ -4125,6 +4137,7 @@ model LoanBankSubmissionAttestation {
|
||||
// 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 {
|
||||
evidence_kind String @default("bank_terminal") @db.VarChar(32)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
storage_path String
|
||||
@@ -4141,6 +4154,8 @@ model LoanBankTerminalEvidence {
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
facts LoanBankTerminalFact[]
|
||||
bank_result_events LoanBankResultEvent[] @relation("LoanBankEventEvidence")
|
||||
bank_result_corrections LoanBankResultEvent[] @relation("LoanBankEventCorrection")
|
||||
|
||||
@@index([payment_batch_id], map: "loan_bank_terminal_evidences_batch_idx")
|
||||
@@map("loan_bank_terminal_evidences")
|
||||
@@ -4244,3 +4259,107 @@ model LoanReleaseMaterialRevision {
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@map("loan_release_material_revisions")
|
||||
}
|
||||
|
||||
model LoanNotSubmittedProposal {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
sequence Int
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
payload_json Json
|
||||
snapshot_json Json
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
proposer Employee @relation(fields: [proposed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviews LoanNotSubmittedReview[]
|
||||
@@unique([payment_batch_id, sequence])
|
||||
@@map("loan_not_submitted_proposals")
|
||||
}
|
||||
|
||||
model LoanNotSubmittedReview {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
proposal_id String @db.Uuid
|
||||
reviewed_by String @db.Uuid
|
||||
decision String @db.VarChar(16)
|
||||
reason String @db.VarChar(500)
|
||||
reviewed_at DateTime @default(now())
|
||||
proposal LoanNotSubmittedProposal @relation(fields: [proposal_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviewer Employee @relation(fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([proposal_id, reviewed_by])
|
||||
@@map("loan_not_submitted_reviews")
|
||||
}
|
||||
|
||||
model LoanFreezeReason {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(32)
|
||||
source_key String @db.VarChar(160)
|
||||
status String @default("active") @db.VarChar(16)
|
||||
created_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
closed_by String? @db.Uuid
|
||||
closed_at DateTime?
|
||||
closure_basis Json?
|
||||
lender LoanLender @relation(fields: [lender_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
creator Employee @relation("LoanFreezeCreator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
closer Employee? @relation("LoanFreezeCloser", fields: [closed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([kind, source_key])
|
||||
@@index([lender_id, status])
|
||||
@@map("loan_freeze_reasons")
|
||||
}
|
||||
|
||||
model LoanBankResultEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(16)
|
||||
parent_id String? @db.Uuid
|
||||
evidence_id String @db.Uuid
|
||||
request_key String @db.Uuid
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
payload_encrypted String
|
||||
fingerprint String @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
amount Decimal @db.Decimal(14,2)
|
||||
snapshot_json Json
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String? @db.VarChar(1000)
|
||||
correction_evidence_id String? @db.Uuid
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
evidence LoanBankTerminalEvidence @relation("LoanBankEventEvidence", fields: [evidence_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
correction_evidence LoanBankTerminalEvidence? @relation("LoanBankEventCorrection", fields: [correction_evidence_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
proposer Employee @relation("LoanBankEventProposer", fields: [proposed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviewer Employee? @relation("LoanBankEventReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
parent LoanBankResultEvent? @relation("LoanBankEventParent", fields: [parent_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children LoanBankResultEvent[] @relation("LoanBankEventParent")
|
||||
settlements LoanSupplementalSettlement[]
|
||||
claim LoanBankEventClaim?
|
||||
@@unique([payment_batch_id, request_key])
|
||||
@@index([payment_batch_id, kind])
|
||||
@@map("loan_bank_result_events")
|
||||
}
|
||||
model LoanSupplementalSettlement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
event_id String @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
bank_amount Decimal @db.Decimal(14,2)
|
||||
settled_amount Decimal @db.Decimal(14,2)
|
||||
receivable_amount Decimal @db.Decimal(14,2)
|
||||
created_at DateTime @default(now())
|
||||
event LoanBankResultEvent @relation(fields: [event_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([event_id, loan_id])
|
||||
@@map("loan_supplemental_settlements")
|
||||
}
|
||||
model LoanBankEventClaim {
|
||||
fingerprint String @id @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
event_id String @unique @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
event LoanBankResultEvent @relation(fields: [event_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@map("loan_bank_event_claims")
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户