完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -0,0 +1,339 @@
|
||||
-- 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();
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Additive only: existing identifiers and both old counters and drafts stay intact.
|
||||
CREATE TABLE "business_number_policies" (
|
||||
"version" INTEGER PRIMARY KEY CHECK ("version" = 2),
|
||||
"effective_at" TIMESTAMP(3) NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE "business_number_counters" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"current_number" BIGINT NOT NULL DEFAULT 0 CHECK ("current_number" >= 0),
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
ALTER TABLE "purchase_requests" ADD COLUMN "numbering_version" INTEGER NOT NULL DEFAULT 1
|
||||
CHECK ("numbering_version" IN (1, 2));
|
||||
-- No INSERT into policy; activation is a separate, explicitly authorized operation.
|
||||
|
||||
-- Keep the original eligibility and issued high-water durable across rollback or
|
||||
-- accidental administrative resets. A pause changes only policy.enabled.
|
||||
CREATE FUNCTION guard_business_numbering_history() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'business numbering history cannot be deleted' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'business_number_policies' THEN
|
||||
IF NEW.version <> OLD.version OR NEW.effective_at <> OLD.effective_at THEN
|
||||
RAISE EXCEPTION 'business numbering activation is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSIF TG_TABLE_NAME = 'business_number_counters' THEN
|
||||
IF NEW.key <> OLD.key OR NEW.current_number < OLD.current_number THEN
|
||||
RAISE EXCEPTION 'business numbering high-water cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER business_number_policy_history BEFORE UPDATE OR DELETE ON business_number_policies
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_business_numbering_history();
|
||||
CREATE TRIGGER business_number_counter_history BEFORE UPDATE OR DELETE ON business_number_counters
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_business_numbering_history();
|
||||
CREATE FUNCTION guard_purchase_numbering_version() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.numbering_version <> OLD.numbering_version THEN
|
||||
RAISE EXCEPTION 'purchase numbering eligibility is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER purchase_numbering_version BEFORE UPDATE OF numbering_version ON purchase_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_purchase_numbering_version();
|
||||
@@ -273,6 +273,7 @@ enum PaymentBatchStatus {
|
||||
enum PaymentBatchEntityType {
|
||||
reimbursement
|
||||
travel_order
|
||||
loan_repayment
|
||||
}
|
||||
|
||||
enum BudgetControlRuleEntityType {
|
||||
@@ -731,6 +732,9 @@ model Employee {
|
||||
@@index([legal_entity_id], map: "idx_employees_legal_entity")
|
||||
@@index([bank_institution_id], map: "idx_employees_bank_institution")
|
||||
@@index([direct_manager_id])
|
||||
loan_lender_identities LoanLender[]
|
||||
loan_evidence_uploads LoanEvidence[]
|
||||
|
||||
@@map("employees")
|
||||
}
|
||||
|
||||
@@ -949,6 +953,26 @@ model ReimbursementTemplate {
|
||||
@@map("reimbursement_templates")
|
||||
}
|
||||
|
||||
// Absent policy means legacy numbering. Migration never activates v2.
|
||||
model BusinessNumberPolicy {
|
||||
version Int @id
|
||||
effective_at DateTime
|
||||
enabled Boolean @default(false)
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String @db.Uuid
|
||||
reference String
|
||||
|
||||
@@map("business_number_policies")
|
||||
}
|
||||
|
||||
model BusinessNumberCounter {
|
||||
key String @id
|
||||
current_number BigInt @default(0)
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("business_number_counters")
|
||||
}
|
||||
|
||||
model DocumentSequence {
|
||||
document_type String @id
|
||||
prefix String
|
||||
@@ -997,6 +1021,8 @@ model LegalEntity {
|
||||
|
||||
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
||||
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
|
||||
loan_lenders LoanLender[]
|
||||
|
||||
@@map("legal_entities")
|
||||
}
|
||||
|
||||
@@ -1016,6 +1042,8 @@ model BankInstitution {
|
||||
payment_accounts PaymentAccount[]
|
||||
|
||||
@@index([enabled, archived_at], map: "idx_bank_institutions_status")
|
||||
loan_lenders LoanLender[]
|
||||
|
||||
@@map("bank_institutions")
|
||||
}
|
||||
|
||||
@@ -1168,6 +1196,7 @@ model Supplier {
|
||||
}
|
||||
|
||||
model PurchaseRequest {
|
||||
numbering_version Int @default(1)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
request_no String? @unique
|
||||
applicant_id String @db.Uuid
|
||||
@@ -1901,6 +1930,8 @@ model EntityRiskFlag {
|
||||
}
|
||||
|
||||
model PaymentBatch {
|
||||
loan_repayment_id String? @db.Uuid
|
||||
loan_repayment LoanRepayment? @relation(fields: [loan_repayment_id], references: [id], onDelete: Restrict)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
batch_no String @unique @db.VarChar(64)
|
||||
entity_type PaymentBatchEntityType
|
||||
@@ -3809,3 +3840,142 @@ model ApprovalTransfer {
|
||||
@@index([to_approver_id])
|
||||
@@map("approval_transfers")
|
||||
}
|
||||
|
||||
|
||||
model LoanLender {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
name String @db.VarChar(120)
|
||||
employee_id String? @db.Uuid
|
||||
identity_status String @default("unverified") @db.VarChar(16)
|
||||
identity_version Int @default(1)
|
||||
account_version Int @default(1)
|
||||
identity_confirmed_by String? @db.Uuid
|
||||
identity_confirmed_at DateTime?
|
||||
identity_confirmation String?
|
||||
bank_institution_id String @db.Uuid
|
||||
bank_name String @db.VarChar(200)
|
||||
bank_account_encrypted String
|
||||
account_name_encrypted String
|
||||
archived_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
created_by String @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String @db.Uuid
|
||||
request_key String @db.Uuid
|
||||
|
||||
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
|
||||
employee Employee? @relation(fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
|
||||
loans CompanyLoan[]
|
||||
repayments LoanRepayment[]
|
||||
|
||||
@@unique([legal_entity_id, id])
|
||||
@@unique([created_by, request_key])
|
||||
@@index([employee_id])
|
||||
@@map("loan_lenders")
|
||||
}
|
||||
|
||||
model CompanyLoan {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
loan_no String @unique @db.VarChar(64)
|
||||
origin_reference String @db.VarChar(200)
|
||||
legal_entity_id String @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
currency_code String @default("CNY") @db.VarChar(8)
|
||||
original_principal Decimal @db.Decimal(14, 2)
|
||||
opening_outstanding_principal Decimal @db.Decimal(14, 2)
|
||||
received_date DateTime @db.Date
|
||||
balance_as_of DateTime @db.Date
|
||||
historical_entry Boolean
|
||||
confirmation_status String @default("unconfirmed") @db.VarChar(16)
|
||||
version Int @default(1)
|
||||
confirmed_by String? @db.Uuid
|
||||
confirmed_at DateTime?
|
||||
accounting_confirmation_ref String? @db.VarChar(200)
|
||||
created_at DateTime @default(now())
|
||||
created_by String @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String @db.Uuid
|
||||
request_key String @db.Uuid
|
||||
|
||||
lender LoanLender @relation(fields: [legal_entity_id, lender_id], references: [legal_entity_id, id], onDelete: Restrict)
|
||||
allocations LoanRepaymentAllocation[]
|
||||
evidences LoanEvidence[]
|
||||
|
||||
@@unique([created_by, request_key])
|
||||
@@unique([legal_entity_id, lender_id, origin_reference])
|
||||
@@index([lender_id, confirmation_status, received_date])
|
||||
@@map("company_loans")
|
||||
}
|
||||
|
||||
model LoanRepayment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
repayment_no String @unique @db.VarChar(64)
|
||||
legal_entity_id String @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
currency_code String @default("CNY") @db.VarChar(8)
|
||||
reason String @db.VarChar(500)
|
||||
amount Decimal @db.Decimal(14, 2)
|
||||
version Int @default(1)
|
||||
submitted_by String @db.Uuid
|
||||
submitted_at DateTime @default(now())
|
||||
lender_identity_version Int
|
||||
lender_account_version Int
|
||||
payee_snapshot_json Json
|
||||
bank_result_state String @default("not_sent") @db.VarChar(24)
|
||||
bank_result_ref String? @db.VarChar(500)
|
||||
accounting_handoff_by String? @db.Uuid
|
||||
accounting_handoff_at DateTime?
|
||||
accounting_handoff_ref String? @db.VarChar(200)
|
||||
request_key String @db.Uuid
|
||||
|
||||
lender LoanLender @relation(fields: [legal_entity_id, lender_id], references: [legal_entity_id, id], onDelete: Restrict)
|
||||
allocations LoanRepaymentAllocation[]
|
||||
batches PaymentBatch[]
|
||||
|
||||
@@unique([submitted_by, request_key])
|
||||
@@index([lender_id, submitted_at])
|
||||
@@map("loan_repayments")
|
||||
}
|
||||
|
||||
model LoanRepaymentAllocation {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
repayment_id String @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
amount Decimal @db.Decimal(14, 2)
|
||||
loan_version Int
|
||||
reservation_state String @default("reserved") @db.VarChar(16)
|
||||
settled_at DateTime?
|
||||
released_at DateTime?
|
||||
release_reason String? @db.VarChar(500)
|
||||
|
||||
repayment LoanRepayment @relation(fields: [repayment_id], references: [id], onDelete: Restrict)
|
||||
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([repayment_id, loan_id])
|
||||
@@index([loan_id, reservation_state])
|
||||
@@map("loan_repayment_allocations")
|
||||
}
|
||||
|
||||
model LoanEvidence {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
kind String @db.VarChar(32)
|
||||
original_name String @db.VarChar(255)
|
||||
storage_path String
|
||||
mime_type String @db.VarChar(100)
|
||||
file_size Int
|
||||
file_sha256 String @db.VarChar(64)
|
||||
governance_status String @default("pending") @db.VarChar(16)
|
||||
uploaded_by String @db.Uuid
|
||||
uploaded_at DateTime @default(now())
|
||||
archived_at DateTime?
|
||||
archive_reason String? @db.VarChar(500)
|
||||
|
||||
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict)
|
||||
uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([loan_id, file_sha256, kind])
|
||||
@@map("loan_evidences")
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户