完善借款、编号、付款与预算并发及验收(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
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
@@ -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();
+170
查看文件
@@ -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")
}