文件
hyb-oa/server/prisma/migrations/20260917010000_company_loan_principal/migration.sql
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

340 行
18 KiB
PL/PgSQL

-- AlterEnum
ALTER TYPE "PaymentBatchEntityType" ADD VALUE 'loan_repayment';
-- AlterTable
ALTER TABLE "payment_batches" ADD COLUMN "loan_repayment_id" UUID;
-- CreateTable
CREATE TABLE "loan_lenders" (
"id" UUID NOT NULL,
"legal_entity_id" UUID NOT NULL,
"name" VARCHAR(120) NOT NULL,
"employee_id" UUID,
"identity_status" VARCHAR(16) NOT NULL DEFAULT 'unverified',
"identity_version" INTEGER NOT NULL DEFAULT 1,
"account_version" INTEGER NOT NULL DEFAULT 1,
"identity_confirmed_by" UUID,
"identity_confirmed_at" TIMESTAMP(3),
"identity_confirmation" TEXT,
"bank_institution_id" UUID NOT NULL,
"bank_name" VARCHAR(200) NOT NULL,
"bank_account_encrypted" TEXT NOT NULL,
"account_name_encrypted" TEXT NOT NULL,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID NOT NULL,
"request_key" UUID NOT NULL,
CONSTRAINT "loan_lenders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "company_loans" (
"id" UUID NOT NULL,
"loan_no" VARCHAR(64) NOT NULL,
"origin_reference" VARCHAR(200) NOT NULL,
"legal_entity_id" UUID NOT NULL,
"lender_id" UUID NOT NULL,
"currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY',
"original_principal" DECIMAL(14,2) NOT NULL,
"opening_outstanding_principal" DECIMAL(14,2) NOT NULL,
"received_date" DATE NOT NULL,
"balance_as_of" DATE NOT NULL,
"historical_entry" BOOLEAN NOT NULL,
"confirmation_status" VARCHAR(16) NOT NULL DEFAULT 'unconfirmed',
"version" INTEGER NOT NULL DEFAULT 1,
"confirmed_by" UUID,
"confirmed_at" TIMESTAMP(3),
"accounting_confirmation_ref" VARCHAR(200),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID NOT NULL,
"request_key" UUID NOT NULL,
CONSTRAINT "company_loans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loan_repayments" (
"id" UUID NOT NULL,
"repayment_no" VARCHAR(64) NOT NULL,
"legal_entity_id" UUID NOT NULL,
"lender_id" UUID NOT NULL,
"currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY',
"reason" VARCHAR(500) NOT NULL,
"amount" DECIMAL(14,2) NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"submitted_by" UUID NOT NULL,
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lender_identity_version" INTEGER NOT NULL,
"lender_account_version" INTEGER NOT NULL,
"payee_snapshot_json" JSONB NOT NULL,
"bank_result_state" VARCHAR(24) NOT NULL DEFAULT 'not_sent',
"bank_result_ref" VARCHAR(500),
"accounting_handoff_by" UUID,
"accounting_handoff_at" TIMESTAMP(3),
"accounting_handoff_ref" VARCHAR(200),
"request_key" UUID NOT NULL,
CONSTRAINT "loan_repayments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loan_repayment_allocations" (
"id" UUID NOT NULL,
"repayment_id" UUID NOT NULL,
"loan_id" UUID NOT NULL,
"amount" DECIMAL(14,2) NOT NULL,
"loan_version" INTEGER NOT NULL,
"reservation_state" VARCHAR(16) NOT NULL DEFAULT 'reserved',
"settled_at" TIMESTAMP(3),
"released_at" TIMESTAMP(3),
"release_reason" VARCHAR(500),
CONSTRAINT "loan_repayment_allocations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loan_evidences" (
"id" UUID NOT NULL,
"loan_id" UUID NOT NULL,
"kind" VARCHAR(32) NOT NULL,
"original_name" VARCHAR(255) NOT NULL,
"storage_path" TEXT NOT NULL,
"mime_type" VARCHAR(100) NOT NULL,
"file_size" INTEGER NOT NULL,
"file_sha256" VARCHAR(64) NOT NULL,
"governance_status" VARCHAR(16) NOT NULL DEFAULT 'pending',
"uploaded_by" UUID NOT NULL,
"uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"archived_at" TIMESTAMP(3),
"archive_reason" VARCHAR(500),
CONSTRAINT "loan_evidences_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "loan_lenders_employee_id_idx" ON "loan_lenders"("employee_id");
-- CreateIndex
CREATE UNIQUE INDEX "loan_lenders_legal_entity_id_id_key" ON "loan_lenders"("legal_entity_id", "id");
-- CreateIndex
CREATE UNIQUE INDEX "loan_lenders_created_by_request_key_key" ON "loan_lenders"("created_by", "request_key");
-- CreateIndex
CREATE UNIQUE INDEX "company_loans_loan_no_key" ON "company_loans"("loan_no");
-- CreateIndex
CREATE INDEX "company_loans_lender_id_confirmation_status_received_date_idx" ON "company_loans"("lender_id", "confirmation_status", "received_date");
-- CreateIndex
CREATE UNIQUE INDEX "company_loans_created_by_request_key_key" ON "company_loans"("created_by", "request_key");
-- CreateIndex
CREATE UNIQUE INDEX "loan_repayments_repayment_no_key" ON "loan_repayments"("repayment_no");
-- CreateIndex
CREATE INDEX "loan_repayments_lender_id_submitted_at_idx" ON "loan_repayments"("lender_id", "submitted_at");
-- CreateIndex
CREATE UNIQUE INDEX "loan_repayments_submitted_by_request_key_key" ON "loan_repayments"("submitted_by", "request_key");
-- CreateIndex
CREATE INDEX "loan_repayment_allocations_loan_id_reservation_state_idx" ON "loan_repayment_allocations"("loan_id", "reservation_state");
-- CreateIndex
CREATE UNIQUE INDEX "loan_repayment_allocations_repayment_id_loan_id_key" ON "loan_repayment_allocations"("repayment_id", "loan_id");
-- CreateIndex
CREATE UNIQUE INDEX "loan_evidences_loan_id_file_sha256_kind_key" ON "loan_evidences"("loan_id", "file_sha256", "kind");
-- AddForeignKey
ALTER TABLE "payment_batches" ADD CONSTRAINT "payment_batches_loan_repayment_id_fkey" FOREIGN KEY ("loan_repayment_id") REFERENCES "loan_repayments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_bank_institution_id_fkey" FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "company_loans" ADD CONSTRAINT "company_loans_legal_entity_id_lender_id_fkey" FOREIGN KEY ("legal_entity_id", "lender_id") REFERENCES "loan_lenders"("legal_entity_id", "id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_repayments" ADD CONSTRAINT "loan_repayments_legal_entity_id_lender_id_fkey" FOREIGN KEY ("legal_entity_id", "lender_id") REFERENCES "loan_lenders"("legal_entity_id", "id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_repayment_allocations" ADD CONSTRAINT "loan_repayment_allocations_repayment_id_fkey" FOREIGN KEY ("repayment_id") REFERENCES "loan_repayments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_repayment_allocations" ADD CONSTRAINT "loan_repayment_allocations_loan_id_fkey" FOREIGN KEY ("loan_id") REFERENCES "company_loans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_evidences" ADD CONSTRAINT "loan_evidences_loan_id_fkey" FOREIGN KEY ("loan_id") REFERENCES "company_loans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loan_evidences" ADD CONSTRAINT "loan_evidences_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- A historical incoming reference may be registered only once for this lender.
CREATE UNIQUE INDEX "company_loans_legal_entity_id_lender_id_origin_reference_key"
ON company_loans(legal_entity_id, lender_id, origin_reference);
ALTER TABLE loan_lenders ADD CONSTRAINT loan_lenders_identity_check CHECK (
identity_status IN ('unverified','verified') AND identity_version > 0 AND account_version > 0
AND (identity_status <> 'verified' OR (identity_confirmed_by IS NOT NULL
AND identity_confirmed_at IS NOT NULL AND NULLIF(trim(identity_confirmation),'') IS NOT NULL))
);
ALTER TABLE company_loans ADD CONSTRAINT company_loans_balance_check CHECK (
original_principal > 0 AND opening_outstanding_principal >= 0
AND opening_outstanding_principal <= original_principal AND currency_code = 'CNY'
AND received_date <= balance_as_of AND version > 0
AND confirmation_status IN ('unconfirmed','confirmed')
AND (confirmation_status <> 'confirmed' OR (confirmed_by IS NOT NULL AND confirmed_at IS NOT NULL
AND NULLIF(trim(accounting_confirmation_ref),'') IS NOT NULL))
);
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_success')
AND (bank_result_state NOT IN ('confirmed_failed','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))
);
ALTER TABLE loan_repayment_allocations ADD CONSTRAINT loan_allocation_amount_state_check CHECK (
amount > 0 AND loan_version > 0 AND reservation_state IN ('reserved','settled','released')
AND (reservation_state <> 'settled' OR settled_at IS NOT NULL)
AND (reservation_state <> 'released' OR (released_at IS NOT NULL AND NULLIF(trim(release_reason),'') IS NOT NULL))
);
ALTER TABLE loan_evidences ADD CONSTRAINT loan_evidence_kind_check CHECK (
kind IN ('agreement','incoming','accounting_confirmation','adjustment') AND file_size > 0
AND governance_status IN ('pending','approved','blocked') AND file_sha256 ~ '^[0-9a-f]{64}$'
);
ALTER TABLE payment_batches ADD CONSTRAINT payment_batch_loan_source_check CHECK (
(entity_type::text = 'loan_repayment') = (loan_repayment_id IS NOT NULL)
);
CREATE UNIQUE INDEX payment_batch_one_active_loan_repayment ON payment_batches(loan_repayment_id)
WHERE loan_repayment_id IS NOT NULL AND status <> 'cancelled';
-- Confirmed opening balances are immutable; correcting history needs a separately
-- reviewed adjustment, not an UPDATE that silently erases old loan evidence.
CREATE FUNCTION guard_confirmed_loan_balance() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.confirmation_status = 'confirmed' AND (
NEW.legal_entity_id, NEW.lender_id, NEW.currency_code, NEW.original_principal,
NEW.opening_outstanding_principal, NEW.received_date, NEW.balance_as_of,
NEW.historical_entry, NEW.origin_reference, NEW.confirmation_status,
NEW.confirmed_by, NEW.confirmed_at, NEW.accounting_confirmation_ref
) IS DISTINCT FROM (
OLD.legal_entity_id, OLD.lender_id, OLD.currency_code, OLD.original_principal,
OLD.opening_outstanding_principal, OLD.received_date, OLD.balance_as_of,
OLD.historical_entry, OLD.origin_reference, OLD.confirmation_status,
OLD.confirmed_by, OLD.confirmed_at, OLD.accounting_confirmation_ref
) THEN RAISE EXCEPTION 'confirmed loan balance is immutable' USING ERRCODE='23514'; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER company_loan_confirmed_immutable BEFORE UPDATE ON company_loans
FOR EACH ROW EXECUTE FUNCTION guard_confirmed_loan_balance();
-- Deferred checks validate the complete atomic repayment/batch/allocation write.
-- Writers still follow the application lock order; the loan-row locks below also
-- serialize aggregate validation across concurrent transactions.
CREATE 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'))
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 FUNCTION check_loan_repayment_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE old_id uuid; new_id uuid;
BEGIN
IF TG_TABLE_NAME='loan_repayments' THEN
IF TG_OP<>'INSERT' THEN old_id=OLD.id; END IF;
IF TG_OP<>'DELETE' THEN new_id=NEW.id; END IF;
ELSIF TG_TABLE_NAME='loan_repayment_allocations' THEN
IF TG_OP<>'INSERT' THEN old_id=OLD.repayment_id; END IF;
IF TG_OP<>'DELETE' THEN new_id=NEW.repayment_id; END IF;
ELSIF TG_TABLE_NAME='payment_batches' THEN
IF TG_OP<>'INSERT' THEN old_id=OLD.loan_repayment_id; END IF;
IF TG_OP<>'DELETE' THEN new_id=NEW.loan_repayment_id; END IF;
ELSE
IF TG_OP<>'INSERT' THEN SELECT loan_repayment_id INTO old_id FROM payment_batches WHERE id=OLD.payment_batch_id; END IF;
IF TG_OP<>'DELETE' THEN SELECT loan_repayment_id INTO new_id FROM payment_batches WHERE id=NEW.payment_batch_id; END IF;
END IF;
IF old_id IS NOT NULL THEN PERFORM assert_loan_repayment_integrity(old_id); END IF;
IF new_id IS NOT NULL AND new_id IS DISTINCT FROM old_id THEN PERFORM assert_loan_repayment_integrity(new_id); END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER loan_repayment_integrity AFTER INSERT OR UPDATE OR DELETE ON loan_repayments
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity();
CREATE CONSTRAINT TRIGGER loan_allocation_integrity AFTER INSERT OR UPDATE OR DELETE ON loan_repayment_allocations
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity();
CREATE CONSTRAINT TRIGGER loan_batch_integrity AFTER INSERT OR UPDATE OR DELETE ON payment_batches
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity();
CREATE CONSTRAINT TRIGGER loan_payment_item_integrity AFTER INSERT OR UPDATE OR DELETE ON payment_batch_items
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity();
CREATE 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_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 $$;
CREATE TRIGGER loan_repayment_history BEFORE UPDATE OR DELETE ON loan_repayments
FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_history();
CREATE TRIGGER loan_allocation_history BEFORE UPDATE OR DELETE ON loan_repayment_allocations
FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_history();