实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
+13
@@ -41,6 +41,7 @@
|
||||
"prom-client": "^15.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
@@ -9350,6 +9351,18 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"prom-client": "^15.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Create bank institution master data.
|
||||
CREATE TABLE "bank_institutions" (
|
||||
"id" UUID NOT NULL,
|
||||
"code" VARCHAR(16) NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
CONSTRAINT "bank_institutions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "bank_institutions_code_key" ON "bank_institutions"("code");
|
||||
CREATE INDEX "idx_bank_institutions_status" ON "bank_institutions"("enabled", "archived_at");
|
||||
|
||||
-- Create legal-entity-owned company payment accounts.
|
||||
CREATE TABLE "payment_accounts" (
|
||||
"id" UUID NOT NULL,
|
||||
"legal_entity_id" UUID NOT NULL,
|
||||
"bank_institution_id" UUID NOT NULL,
|
||||
"account_name" VARCHAR(120) NOT NULL,
|
||||
"account_number_encrypted" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
CONSTRAINT "payment_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_payment_accounts_legal_entity_status" ON "payment_accounts"("legal_entity_id", "enabled", "archived_at");
|
||||
CREATE INDEX "idx_payment_accounts_bank_institution" ON "payment_accounts"("bank_institution_id");
|
||||
CREATE UNIQUE INDEX "payment_accounts_one_active_per_legal_entity_ux"
|
||||
ON "payment_accounts"("legal_entity_id")
|
||||
WHERE "enabled" = true AND "archived_at" IS NULL;
|
||||
|
||||
ALTER TABLE "payment_accounts"
|
||||
ADD CONSTRAINT "payment_accounts_legal_entity_id_fkey"
|
||||
FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "payment_accounts"
|
||||
ADD CONSTRAINT "payment_accounts_bank_institution_id_fkey"
|
||||
FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Employee bank identity is intentionally nullable; existing free text is not guessed.
|
||||
ALTER TABLE "employees" ADD COLUMN "bank_institution_id" UUID;
|
||||
CREATE INDEX "idx_employees_bank_institution" ON "employees"("bank_institution_id");
|
||||
ALTER TABLE "employees"
|
||||
ADD CONSTRAINT "employees_bank_institution_id_fkey"
|
||||
FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Historical payment batches remain nullable and cannot be order-exported.
|
||||
ALTER TABLE "payment_batches"
|
||||
ADD COLUMN "legal_entity_id" UUID,
|
||||
ADD COLUMN "payment_account_id" UUID,
|
||||
ADD COLUMN "payment_account_snapshot_json" JSONB,
|
||||
ADD COLUMN "payment_order_export_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "payment_order_last_exported_at" TIMESTAMP(3),
|
||||
ADD COLUMN "payment_order_last_exported_by" UUID;
|
||||
|
||||
CREATE INDEX "idx_payment_batches_legal_entity" ON "payment_batches"("legal_entity_id");
|
||||
CREATE INDEX "idx_payment_batches_payment_account" ON "payment_batches"("payment_account_id");
|
||||
CREATE INDEX "idx_payment_batches_order_exported_by" ON "payment_batches"("payment_order_last_exported_by");
|
||||
|
||||
ALTER TABLE "payment_batches"
|
||||
ADD CONSTRAINT "payment_batches_legal_entity_id_fkey"
|
||||
FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "payment_batches"
|
||||
ADD CONSTRAINT "payment_batches_payment_account_id_fkey"
|
||||
FOREIGN KEY ("payment_account_id") REFERENCES "payment_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "payment_batches"
|
||||
ADD CONSTRAINT "payment_batches_payment_order_last_exported_by_fkey"
|
||||
FOREIGN KEY ("payment_order_last_exported_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -598,6 +598,7 @@ model Employee {
|
||||
|
||||
bank_account_encrypted String
|
||||
bank_name String
|
||||
bank_institution_id String? @db.Uuid
|
||||
id_number_encrypted String?
|
||||
|
||||
password_hash String
|
||||
@@ -625,6 +626,7 @@ model Employee {
|
||||
|
||||
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
|
||||
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
|
||||
bank_institution BankInstitution? @relation(fields: [bank_institution_id], references: [id], onDelete: SetNull)
|
||||
direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict)
|
||||
subordinates Employee[] @relation("EmployeeToManager")
|
||||
|
||||
@@ -642,6 +644,7 @@ model Employee {
|
||||
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
||||
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
|
||||
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
|
||||
payment_batches_last_exported PaymentBatch[] @relation("PaymentOrderLastExportedBy")
|
||||
leave_requests LeaveRequest[]
|
||||
leave_ledgers LeaveLedger[]
|
||||
wecom_checkin_records WecomCheckinRecord[]
|
||||
@@ -724,6 +727,7 @@ model Employee {
|
||||
|
||||
@@index([department_id])
|
||||
@@index([legal_entity_id], map: "idx_employees_legal_entity")
|
||||
@@index([bank_institution_id], map: "idx_employees_bank_institution")
|
||||
@@index([direct_manager_id])
|
||||
@@map("employees")
|
||||
}
|
||||
@@ -985,12 +989,56 @@ model LegalEntity {
|
||||
card_authorizations CardAuthorization[]
|
||||
asset_cards AssetCard[]
|
||||
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
|
||||
payment_accounts PaymentAccount[]
|
||||
payment_batches PaymentBatch[]
|
||||
|
||||
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
||||
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
|
||||
@@map("legal_entities")
|
||||
}
|
||||
|
||||
model BankInstitution {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
code String @unique @db.VarChar(16)
|
||||
name String @db.VarChar(100)
|
||||
enabled Boolean @default(true)
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employees Employee[]
|
||||
payment_accounts PaymentAccount[]
|
||||
|
||||
@@index([enabled, archived_at], map: "idx_bank_institutions_status")
|
||||
@@map("bank_institutions")
|
||||
}
|
||||
|
||||
model PaymentAccount {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
bank_institution_id String @db.Uuid
|
||||
account_name String @db.VarChar(120)
|
||||
account_number_encrypted String
|
||||
enabled Boolean @default(true)
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
|
||||
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
|
||||
payment_batches PaymentBatch[]
|
||||
|
||||
@@index([legal_entity_id, enabled, archived_at], map: "idx_payment_accounts_legal_entity_status")
|
||||
@@index([bank_institution_id], map: "idx_payment_accounts_bank_institution")
|
||||
@@map("payment_accounts")
|
||||
}
|
||||
|
||||
model CostCenter {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
@@ -1856,6 +1904,12 @@ model PaymentBatch {
|
||||
review_comment String?
|
||||
secondary_review_comment String?
|
||||
execution_summary_json Json?
|
||||
legal_entity_id String? @db.Uuid
|
||||
payment_account_id String? @db.Uuid
|
||||
payment_account_snapshot_json Json?
|
||||
payment_order_export_count Int @default(0)
|
||||
payment_order_last_exported_at DateTime?
|
||||
payment_order_last_exported_by String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
@@ -1873,8 +1927,14 @@ model PaymentBatch {
|
||||
|
||||
items PaymentBatchItem[]
|
||||
receipt_attachments PaymentBatchReceiptAttachment[]
|
||||
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
|
||||
payment_account PaymentAccount? @relation(fields: [payment_account_id], references: [id], onDelete: SetNull)
|
||||
payment_order_last_exported_by_employee Employee? @relation("PaymentOrderLastExportedBy", fields: [payment_order_last_exported_by], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created")
|
||||
@@index([legal_entity_id], map: "idx_payment_batches_legal_entity")
|
||||
@@index([payment_account_id], map: "idx_payment_batches_payment_account")
|
||||
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
|
||||
@@map("payment_batches")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const { encryptStringOrThrow } = require("../src/services/crypto");
|
||||
const { generatePaymentOrderXls } = require("../src/services/paymentOrderXls");
|
||||
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-xls-"));
|
||||
const outputPath = path.join(temporaryDirectory, "synthetic-order.xls");
|
||||
try {
|
||||
const fieldEncryptionKey = Buffer.alloc(32, 7).toString("base64");
|
||||
const config = { fieldEncryptionKey };
|
||||
const encrypt = (value) => encryptStringOrThrow(config, value);
|
||||
const generated = generatePaymentOrderXls(config, {
|
||||
batch_no: "PB-20260802-001",
|
||||
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", accountNumberEncrypted: encrypt("6217000000000012345") },
|
||||
items: [{ id: "1", entity_code: "BX-20260802-0001", amount: "1586.00", snapshot_json: { payee: { name: "合成员工", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } } }],
|
||||
});
|
||||
fs.writeFileSync(outputPath, generated.buffer);
|
||||
const check = spawnSync("python3", ["-c", [
|
||||
"import sys,xlrd",
|
||||
"book=xlrd.open_workbook(sys.argv[1])",
|
||||
"assert book.sheet_names()==['Sheet1','Sheet2','Sheet3']",
|
||||
"assert [(s.nrows,s.ncols) for s in book.sheets()]==[(2,15),(0,0),(0,0)]",
|
||||
"sheet=book.sheet_by_index(0)",
|
||||
"assert sheet.cell_type(1,1)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,1)=='6217000000000012345'",
|
||||
"assert sheet.cell_type(1,3)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,3)=='01'",
|
||||
"assert sheet.cell_type(1,4)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,4)=='6222000000000056789'",
|
||||
"assert sheet.cell_type(1,8)==xlrd.XL_CELL_TEXT",
|
||||
"assert sheet.cell_type(1,9)==xlrd.XL_CELL_NUMBER and sheet.cell_value(1,9)==1586.0",
|
||||
"assert sheet.cell_value(1,10)=='报销款' and sheet.cell_value(1,5)=='合成员工'",
|
||||
].join(";"), outputPath], { encoding: "utf8" });
|
||||
if (check.status !== 0) {
|
||||
process.stderr.write(check.stderr || check.stdout || "xlrd roundtrip failed\n");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write("[payment-order-xls][PASS] xlrd BIFF8 roundtrip verified\n");
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -49,6 +49,7 @@ const budgetControlRulesRoutes = require("./routes/budgetControlRules");
|
||||
const feeControlRoutes = require("./routes/feeControl");
|
||||
const riskFlagsRoutes = require("./routes/riskFlags");
|
||||
const paymentBatchesRoutes = require("./routes/paymentBatches");
|
||||
const paymentAccountsRoutes = require("./routes/paymentAccounts");
|
||||
const apOperationsRoutes = require("./routes/apOperations");
|
||||
const portalRoutes = require("./routes/portal");
|
||||
const searchRoutes = require("./routes/search");
|
||||
@@ -373,6 +374,7 @@ async function buildApp(dependencies = {}) {
|
||||
await app.register(feeControlRoutes, { prefix: "/api" });
|
||||
await app.register(riskFlagsRoutes, { prefix: "/api" });
|
||||
await app.register(paymentBatchesRoutes, { prefix: "/api" });
|
||||
await app.register(paymentAccountsRoutes, { prefix: "/api" });
|
||||
await app.register(apOperationsRoutes, { prefix: "/api" });
|
||||
await app.register(portalRoutes, { prefix: "/api" });
|
||||
await app.register(searchRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -189,6 +189,9 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.approve",
|
||||
"expense.finance.pay",
|
||||
"payment.batch.manage",
|
||||
"payment.account.read",
|
||||
"payment.account.read_full",
|
||||
"payment.order.export",
|
||||
"expense.view_all",
|
||||
"procurement.view_all",
|
||||
"asset.view_all",
|
||||
@@ -210,6 +213,10 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.approve",
|
||||
"expense.finance.pay",
|
||||
"payment.batch.manage",
|
||||
"payment.account.manage",
|
||||
"payment.account.read",
|
||||
"payment.account.read_full",
|
||||
"payment.order.export",
|
||||
"expense.view_all",
|
||||
"expense.finance.override_duplicate",
|
||||
"procurement.view_all",
|
||||
@@ -219,7 +226,6 @@ const ROLE_PERMISSIONS = {
|
||||
"asset.inventory",
|
||||
"system.settings",
|
||||
"system.audit.view",
|
||||
"payment.batch.manage",
|
||||
"risk.review.manage",
|
||||
"project.view_all",
|
||||
"attendance.view_all",
|
||||
|
||||
+20
-1
@@ -3,6 +3,7 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
|
||||
const NOTIFICATION_PREFERENCES_SCHEMA = z
|
||||
.object({
|
||||
@@ -30,7 +31,9 @@ function parseNotificationPreferences(body) {
|
||||
|
||||
function buildProfileCompleteness(employee) {
|
||||
const hasEmergencyContact = Boolean(employee?.emergency_contact_name && employee?.emergency_contact_phone);
|
||||
const hasBankAccount = Boolean(employee?.bank_account_encrypted && employee?.bank_name);
|
||||
const hasBankAccount = Boolean(
|
||||
employee?.bank_account_encrypted && (employee?.bank_institution || employee?.bank_name),
|
||||
);
|
||||
const hasAvatar = Boolean(employee?.avatar_path);
|
||||
const hasIdNumber = Boolean(employee?.id_number_encrypted);
|
||||
const completedCount = [hasEmergencyContact, hasBankAccount, hasAvatar, hasIdNumber].filter(Boolean).length;
|
||||
@@ -66,6 +69,7 @@ async function meRoutes(fastify) {
|
||||
emergency_contact_phone: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
avatar_path: true,
|
||||
id_number_encrypted: true,
|
||||
notification_push_enabled: true,
|
||||
@@ -76,6 +80,16 @@ async function meRoutes(fastify) {
|
||||
|
||||
const roleCodes = (employee?.roles ?? []).map((r) => r.role_code);
|
||||
const profileCompleteness = buildProfileCompleteness(employee);
|
||||
let bankAccountMasked = null;
|
||||
try {
|
||||
const bankAccount = employee?.bank_account_encrypted
|
||||
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
|
||||
: null;
|
||||
bankAccountMasked = bankAccount ? maskBankAccount(bankAccount) : null;
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500 });
|
||||
}
|
||||
return sendData(reply, request, {
|
||||
id: employee?.id ?? request.employee.id,
|
||||
employeeNo: employee?.employee_no ?? "",
|
||||
@@ -87,6 +101,11 @@ async function meRoutes(fastify) {
|
||||
status: employee?.status ?? "active",
|
||||
notificationPushEnabled: employee?.notification_push_enabled ?? true,
|
||||
roleCodes,
|
||||
bankAccount: {
|
||||
bankName: employee?.bank_institution?.name ?? employee?.bank_name ?? null,
|
||||
bankInstitutionCode: employee?.bank_institution?.code ?? null,
|
||||
accountNumberMasked: bankAccountMasked,
|
||||
},
|
||||
profileCompleteness,
|
||||
wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const ACCOUNT_BODY_SCHEMA = z.object({
|
||||
legalEntityId: UUID_SCHEMA,
|
||||
bankInstitutionId: UUID_SCHEMA,
|
||||
accountName: z.string().trim().min(1).max(120),
|
||||
accountNumber: z.string().trim().regex(/^\d{6,32}$/),
|
||||
}).strict();
|
||||
const REVEAL_BODY_SCHEMA = z.object({
|
||||
reason: z.string().trim().min(2).max(500),
|
||||
}).strict();
|
||||
const BANK_BODY_SCHEMA = z.object({
|
||||
code: z.string().trim().min(2).max(16).regex(/^[A-Za-z0-9_]+$/),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
}).strict();
|
||||
const BANK_RENAME_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100) }).strict();
|
||||
|
||||
function validationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parse(schema, value) {
|
||||
const result = schema.safeParse(value ?? {});
|
||||
if (!result.success) {
|
||||
throw validationError(result.error.flatten());
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function parseId(params) {
|
||||
const result = UUID_SCHEMA.safeParse(params?.id);
|
||||
if (!result.success) {
|
||||
throw validationError({ id: "invalid_uuid" });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function assertAnyPermission(fastify, request, permissions) {
|
||||
if (!permissions.some((permission) => fastify.hasPermission(request.employee, permission))) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
function decryptAccount(fastify, encrypted) {
|
||||
try {
|
||||
return decryptStringOrThrow(fastify.config, encrypted);
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error }, "payment account decryption failed");
|
||||
throw new AppError({ code: "PAYMENT_ACCOUNT_DECRYPT_FAILED", message: "付款账户读取失败", statusCode: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function mapAccount(fastify, record) {
|
||||
const accountNumber = decryptAccount(fastify, record.account_number_encrypted);
|
||||
return {
|
||||
id: record.id,
|
||||
legalEntity: {
|
||||
id: record.legal_entity.id,
|
||||
code: record.legal_entity.code,
|
||||
name: record.legal_entity.name,
|
||||
},
|
||||
bankInstitution: {
|
||||
id: record.bank_institution.id,
|
||||
code: record.bank_institution.code,
|
||||
name: record.bank_institution.name,
|
||||
},
|
||||
accountName: record.account_name,
|
||||
accountNumberMasked: maskBankAccount(accountNumber),
|
||||
enabled: Boolean(record.enabled && !record.archived_at),
|
||||
archivedAt: record.archived_at?.toISOString?.() ?? null,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const ACCOUNT_INCLUDE = {
|
||||
legal_entity: { select: { id: true, code: true, name: true } },
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
};
|
||||
|
||||
async function ensureReferences(tx, body) {
|
||||
const [legalEntity, bankInstitution] = await Promise.all([
|
||||
tx.legalEntity.findFirst({
|
||||
where: { id: body.legalEntityId, enabled: true, archived_at: null },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
tx.bankInstitution.findFirst({
|
||||
where: { id: body.bankInstitutionId, enabled: true, archived_at: null },
|
||||
select: { id: true, code: true, name: true },
|
||||
}),
|
||||
]);
|
||||
if (!legalEntity) throw validationError({ legalEntityId: "not_available" }, 409);
|
||||
if (!bankInstitution) throw validationError({ bankInstitutionId: "not_available" }, 409);
|
||||
return { legalEntity, bankInstitution };
|
||||
}
|
||||
|
||||
async function notifyAccountChange(tx, account, action) {
|
||||
const recipients = await tx.employeeRole.findMany({
|
||||
where: {
|
||||
role_code: { in: ["admin", "ceo"] },
|
||||
employee: { status: "active" },
|
||||
},
|
||||
select: { employee_id: true },
|
||||
});
|
||||
const actionLabel = action === "created" ? "新增" : action === "replaced" ? "替换" : "停用";
|
||||
await createNotifications(tx, {
|
||||
recipientEmployeeIds: recipients.map((item) => item.employee_id),
|
||||
type: "PAYMENT_ACCOUNT_CHANGED",
|
||||
title: `企业银行账户已${actionLabel}`,
|
||||
body: `${account.legalEntity.name}的企业银行账户已${actionLabel},当前尾号为${account.accountNumberMasked?.slice(-4) ?? "未提供"}。`,
|
||||
targetType: "payment_account",
|
||||
targetId: account.id,
|
||||
});
|
||||
}
|
||||
|
||||
function handleWriteError(error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw new AppError({
|
||||
code: "PAYMENT_ACCOUNT_ACTIVE_CONFLICT",
|
||||
message: "该法人主体已有启用的企业银行账户,请使用替换操作",
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function paymentAccountsRoutes(fastify) {
|
||||
fastify.get("/bank-institutions", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
assertAnyPermission(fastify, request, [
|
||||
"payment.account.read",
|
||||
"payment.account.manage",
|
||||
"employee.bank.read",
|
||||
"employee.bank.manage",
|
||||
]);
|
||||
const rows = await fastify.prisma.bankInstitution.findMany({
|
||||
where: { enabled: true, archived_at: null },
|
||||
orderBy: [{ name: "asc" }, { code: "asc" }],
|
||||
select: { id: true, code: true, name: true },
|
||||
});
|
||||
return sendData(reply, request, { items: rows });
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
"/bank-institutions",
|
||||
{ preHandler: fastify.requirePermission("payment.account.manage") },
|
||||
async (request, reply) => {
|
||||
const body = parse(BANK_BODY_SCHEMA, request.body);
|
||||
let bank;
|
||||
try {
|
||||
bank = await fastify.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bankInstitution.create({
|
||||
data: {
|
||||
code: body.code.toUpperCase(),
|
||||
name: body.name,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: { id: true, code: true, name: true },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "BANK_INSTITUTION_CREATED",
|
||||
targetType: "bank_institution",
|
||||
targetId: created.id,
|
||||
extra: { code: created.code },
|
||||
}),
|
||||
});
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw validationError({ code: "already_exists" }, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return sendData(reply.code(201), request, bank);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/bank-institutions/:id",
|
||||
{ preHandler: fastify.requirePermission("payment.account.manage") },
|
||||
async (request, reply) => {
|
||||
const bankId = parseId(request.params);
|
||||
const body = parse(BANK_RENAME_BODY_SCHEMA, request.body);
|
||||
const bank = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.bankInstitution.findUnique({
|
||||
where: { id: bankId },
|
||||
select: { id: true, code: true, name: true, archived_at: true },
|
||||
});
|
||||
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
const updated = await tx.bankInstitution.update({
|
||||
where: { id: bankId },
|
||||
data: { name: body.name, updated_by: request.employee.id },
|
||||
select: { id: true, code: true, name: true },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "BANK_INSTITUTION_RENAMED",
|
||||
targetType: "bank_institution",
|
||||
targetId: bankId,
|
||||
extra: { code: current.code, beforeName: current.name, afterName: updated.name },
|
||||
}),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
return sendData(reply, request, bank);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/bank-institutions/:id/disable",
|
||||
{ preHandler: fastify.requirePermission("payment.account.manage") },
|
||||
async (request, reply) => {
|
||||
const bankId = parseId(request.params);
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.bankInstitution.findUnique({
|
||||
where: { id: bankId },
|
||||
select: { id: true, code: true, enabled: true, archived_at: true },
|
||||
});
|
||||
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
const inUse = await tx.paymentAccount.count({
|
||||
where: { bank_institution_id: bankId, enabled: true, archived_at: null },
|
||||
});
|
||||
if (inUse > 0) throw validationError({ bankInstitution: "active_payment_account_exists" }, 409);
|
||||
const now = new Date();
|
||||
await tx.bankInstitution.update({
|
||||
where: { id: bankId },
|
||||
data: { enabled: false, archived_at: now, updated_by: request.employee.id },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "BANK_INSTITUTION_DISABLED",
|
||||
targetType: "bank_institution",
|
||||
targetId: bankId,
|
||||
extra: { code: current.code },
|
||||
}),
|
||||
});
|
||||
return { id: bankId, disabled: true };
|
||||
});
|
||||
return sendData(reply, request, result);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/payment-accounts",
|
||||
{ preHandler: fastify.requirePermission("payment.account.read") },
|
||||
async (request, reply) => {
|
||||
const rows = await fastify.prisma.paymentAccount.findMany({
|
||||
orderBy: [{ legal_entity: { code: "asc" } }, { created_at: "desc" }],
|
||||
include: ACCOUNT_INCLUDE,
|
||||
});
|
||||
return sendData(reply, request, { items: rows.map((row) => mapAccount(fastify, row)) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/payment-accounts",
|
||||
{ preHandler: fastify.requirePermission("payment.account.manage") },
|
||||
async (request, reply) => {
|
||||
const body = parse(ACCOUNT_BODY_SCHEMA, request.body);
|
||||
let created;
|
||||
try {
|
||||
created = await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureReferences(tx, body);
|
||||
const record = await tx.paymentAccount.create({
|
||||
data: {
|
||||
legal_entity_id: body.legalEntityId,
|
||||
bank_institution_id: body.bankInstitutionId,
|
||||
account_name: body.accountName,
|
||||
account_number_encrypted: encryptStringOrThrow(fastify.config, body.accountNumber),
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
include: ACCOUNT_INCLUDE,
|
||||
});
|
||||
const mapped = mapAccount(fastify, record);
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_ACCOUNT_CREATED",
|
||||
targetType: "payment_account",
|
||||
targetId: record.id,
|
||||
extra: { legalEntityId: body.legalEntityId, bankInstitutionCode: record.bank_institution.code, accountNumberMasked: mapped.accountNumberMasked },
|
||||
}),
|
||||
});
|
||||
await notifyAccountChange(tx, mapped, "created");
|
||||
return mapped;
|
||||
});
|
||||
} catch (error) {
|
||||
handleWriteError(error);
|
||||
}
|
||||
return sendData(reply.code(201), request, created);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/payment-accounts/:id/replace",
|
||||
{ preHandler: fastify.requirePermission("payment.account.manage") },
|
||||
async (request, reply) => {
|
||||
const accountId = parseId(request.params);
|
||||
const body = parse(ACCOUNT_BODY_SCHEMA, request.body);
|
||||
let created;
|
||||
try {
|
||||
created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.paymentAccount.findFirst({
|
||||
where: { id: accountId, enabled: true, archived_at: null },
|
||||
include: ACCOUNT_INCLUDE,
|
||||
});
|
||||
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
if (current.legal_entity_id !== body.legalEntityId) {
|
||||
throw validationError({ legalEntityId: "must_match_current_account" });
|
||||
}
|
||||
await ensureReferences(tx, body);
|
||||
const now = new Date();
|
||||
await tx.paymentAccount.update({
|
||||
where: { id: current.id },
|
||||
data: { enabled: false, archived_at: now, updated_at: now, updated_by: request.employee.id },
|
||||
});
|
||||
const next = await tx.paymentAccount.create({
|
||||
data: {
|
||||
legal_entity_id: body.legalEntityId,
|
||||
bank_institution_id: body.bankInstitutionId,
|
||||
account_name: body.accountName,
|
||||
account_number_encrypted: encryptStringOrThrow(fastify.config, body.accountNumber),
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
include: ACCOUNT_INCLUDE,
|
||||
});
|
||||
const mapped = mapAccount(fastify, next);
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_ACCOUNT_REPLACED",
|
||||
targetType: "payment_account",
|
||||
targetId: next.id,
|
||||
extra: { previousAccountId: current.id, legalEntityId: body.legalEntityId, accountNumberMasked: mapped.accountNumberMasked },
|
||||
}),
|
||||
});
|
||||
await notifyAccountChange(tx, mapped, "replaced");
|
||||
return mapped;
|
||||
});
|
||||
} catch (error) {
|
||||
handleWriteError(error);
|
||||
}
|
||||
return sendData(reply, request, created);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/payment-accounts/:id/disable",
|
||||
{ preHandler: fastify.requirePermission("payment.account.manage") },
|
||||
async (request, reply) => {
|
||||
const accountId = parseId(request.params);
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.paymentAccount.findFirst({
|
||||
where: { id: accountId, enabled: true, archived_at: null },
|
||||
include: ACCOUNT_INCLUDE,
|
||||
});
|
||||
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
const mapped = mapAccount(fastify, current);
|
||||
const now = new Date();
|
||||
await tx.paymentAccount.update({
|
||||
where: { id: current.id },
|
||||
data: { enabled: false, archived_at: now, updated_at: now, updated_by: request.employee.id },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_ACCOUNT_DISABLED",
|
||||
targetType: "payment_account",
|
||||
targetId: current.id,
|
||||
extra: { legalEntityId: current.legal_entity_id, accountNumberMasked: mapped.accountNumberMasked },
|
||||
}),
|
||||
});
|
||||
await notifyAccountChange(tx, mapped, "disabled");
|
||||
return { id: current.id, disabled: true };
|
||||
});
|
||||
return sendData(reply, request, result);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/payment-accounts/:id/reveal",
|
||||
{ preHandler: fastify.requirePermission("payment.account.read_full") },
|
||||
async (request, reply) => {
|
||||
const accountId = parseId(request.params);
|
||||
const { reason } = parse(REVEAL_BODY_SCHEMA, request.body);
|
||||
const account = await fastify.prisma.paymentAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
select: { id: true, account_number_encrypted: true },
|
||||
});
|
||||
if (!account) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED",
|
||||
targetType: "payment_account",
|
||||
targetId: account.id,
|
||||
extra: { reason },
|
||||
}),
|
||||
});
|
||||
return sendData(reply, request, { accountNumber: decryptAccount(fastify, account.account_number_encrypted) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = paymentAccountsRoutes;
|
||||
@@ -18,6 +18,7 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
|
||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls");
|
||||
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
buildAttachmentContentDisposition,
|
||||
@@ -38,6 +39,10 @@ const {
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
|
||||
const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]);
|
||||
const PAYMENT_ACCOUNT_INCLUDE = {
|
||||
legal_entity: { select: { id: true, code: true, name: true } },
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
};
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
entityType: ENTITY_TYPE_SCHEMA.optional(),
|
||||
@@ -251,6 +256,22 @@ function ensureBatchReviewPermission(fastify, request) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePaymentOrderExportPermission(fastify, request) {
|
||||
const roleCodes = request.employee?.roleCodes ?? [];
|
||||
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
|
||||
if (!hasFinanceRole || !fastify.hasPermission(request.employee, "payment.order.export")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePayeeBankRevealPermission(fastify, request) {
|
||||
const roleCodes = request.employee?.roleCodes ?? [];
|
||||
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
|
||||
if (!hasFinanceRole || !fastify.hasPermission(request.employee, "employee.bank.read")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
function roleCodesOf(employee) {
|
||||
return (employee?.roles ?? []).map((role) => role.role_code).filter(Boolean);
|
||||
}
|
||||
@@ -492,18 +513,27 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
|
||||
`);
|
||||
}
|
||||
|
||||
function buildPayeeSnapshot(config, owner) {
|
||||
function buildPayeeSnapshot(config, owner, options = {}) {
|
||||
const encryptedAccount = owner?.bank_account_encrypted;
|
||||
const bankAccount = encryptedAccount
|
||||
? decryptStringOrThrow(config, encryptedAccount)
|
||||
: null;
|
||||
let bankAccount = null;
|
||||
try {
|
||||
bankAccount = encryptedAccount ? decryptStringOrThrow(config, encryptedAccount) : null;
|
||||
} catch (error) {
|
||||
if (options.includeEncrypted) {
|
||||
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (encryptedAccount && !bankAccount) {
|
||||
throw new Error("Payee bank account could not be decrypted");
|
||||
}
|
||||
return {
|
||||
name: owner?.name ?? null,
|
||||
bankName: owner?.bank_name ?? null,
|
||||
bankInstitutionId: owner?.bank_institution?.id ?? null,
|
||||
bankInstitutionCode: owner?.bank_institution?.code ?? null,
|
||||
bankName: owner?.bank_institution?.name ?? owner?.bank_name ?? null,
|
||||
bankAccountMasked: maskBankAccount(bankAccount),
|
||||
...(options.includeEncrypted ? { bankAccountEncrypted: encryptedAccount ?? null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -525,8 +555,10 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
legal_entity_id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -539,6 +571,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
|
||||
currencyCode: "CNY",
|
||||
status: item.status,
|
||||
departmentId: item.department_id ?? null,
|
||||
legalEntityId: item.owner?.legal_entity_id ?? null,
|
||||
snapshotJson: {
|
||||
formNumber: item.form_number ?? null,
|
||||
description: item.description ?? null,
|
||||
@@ -547,7 +580,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
|
||||
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannel: item.payment_channel ?? null,
|
||||
paidAt: item.paid_at?.toISOString?.() ?? null,
|
||||
payee: buildPayeeSnapshot(config, item.owner),
|
||||
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -662,6 +695,7 @@ async function loadLegacyPayees(tx, batch, config) {
|
||||
name: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -669,6 +703,41 @@ async function loadLegacyPayees(tx, batch, config) {
|
||||
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
|
||||
}
|
||||
|
||||
function readPaymentAccountSnapshot(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
|
||||
const accountName = normalizeSnapshotText(snapshot.accountName);
|
||||
const bankName = normalizeSnapshotText(snapshot.bankName);
|
||||
const bankInstitutionCode = normalizeSnapshotText(snapshot.bankInstitutionCode);
|
||||
const accountNumberMasked = normalizeMaskedBankAccount(snapshot.accountNumberMasked);
|
||||
if (!accountName || !bankName || !bankInstitutionCode || !accountNumberMasked) return null;
|
||||
return { accountName, bankName, bankInstitutionCode, accountNumberMasked };
|
||||
}
|
||||
|
||||
function paymentOrderExportBlockers(batch) {
|
||||
const blockers = [];
|
||||
const add = (code, entityCode) => blockers.push({ code, ...(entityCode ? { entityCode } : {}) });
|
||||
if (batch.status !== "reviewed") add("status_not_reviewed");
|
||||
if (batch.entity_type !== "reimbursement") add("reimbursement_only");
|
||||
if (batch.default_payment_channel !== "bank") add("bank_channel_required");
|
||||
if (!batch.payment_account_snapshot_json) {
|
||||
add("legacy_payment_account_snapshot_missing");
|
||||
} else {
|
||||
const payer = readPaymentAccountSnapshot(batch.payment_account_snapshot_json);
|
||||
if (!payer) add("payment_account_snapshot_invalid");
|
||||
if (payer && !payer.bankInstitutionCode) add("payer_bank_institution_missing");
|
||||
else if (payer && payer.bankInstitutionCode !== "CCB") add("payer_bank_not_supported");
|
||||
}
|
||||
if (!(batch.items ?? []).length) add("items_missing");
|
||||
for (const item of batch.items ?? []) {
|
||||
const entityCode = normalizeSnapshotText(item.entity_code) ?? undefined;
|
||||
const payee = item.snapshot_json?.payee;
|
||||
if (!payee?.bankAccountEncrypted) add("legacy_payee_snapshot_missing", entityCode);
|
||||
if (!payee?.bankInstitutionCode) add("payee_bank_institution_missing", entityCode);
|
||||
else if (payee.bankInstitutionCode !== "CCB") add("payee_bank_not_supported", entityCode);
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
|
||||
const includePayee = options.includePayee === true;
|
||||
const batchRisk = summarizeBatchRisk(batch);
|
||||
@@ -681,6 +750,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
|
||||
riskMap.get(entityId).push(item);
|
||||
}
|
||||
|
||||
const paymentOrderExportBlockersValue = paymentOrderExportBlockers(batch);
|
||||
return {
|
||||
id: batch.id,
|
||||
batchNo: batch.batch_no,
|
||||
@@ -697,6 +767,13 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
|
||||
secondaryReviewComment: batch.secondary_review_comment ?? null,
|
||||
executionSummaryJson: batch.execution_summary_json ?? null,
|
||||
receiptAttachmentCount: batch._count?.receipt_attachments ?? 0,
|
||||
paymentAccount: readPaymentAccountSnapshot(batch.payment_account_snapshot_json),
|
||||
paymentOrderExportCount: Number(batch.payment_order_export_count ?? 0),
|
||||
paymentOrderLastExportedAt: batch.payment_order_last_exported_at?.toISOString?.() ?? null,
|
||||
paymentOrderLastExportedBy: batch.payment_order_last_exported_by ?? null,
|
||||
canExportPaymentOrder: paymentOrderExportBlockersValue.length === 0,
|
||||
paymentOrderExportBlockReason: paymentOrderExportBlockersValue[0]?.code ?? null,
|
||||
paymentOrderExportBlockers: paymentOrderExportBlockersValue,
|
||||
createdAt: batch.created_at?.toISOString?.() ?? null,
|
||||
createdBy: batch.created_by ?? null,
|
||||
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
|
||||
@@ -1335,6 +1412,60 @@ async function paymentBatchesRoutes(fastify) {
|
||||
ensureBatchEntityStatus(body.entityType, snapshot);
|
||||
}
|
||||
|
||||
const isBankBatch = (body.defaultPaymentChannel ?? "bank") === "bank";
|
||||
let paymentAccount = null;
|
||||
let paymentAccountSnapshot = null;
|
||||
let legalEntityId = null;
|
||||
if (isBankBatch) {
|
||||
const legalEntityIds = Array.from(
|
||||
new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean)),
|
||||
);
|
||||
if (legalEntityIds.length !== 1 || snapshots.some((snapshot) => !snapshot.legalEntityId)) {
|
||||
throw buildValidationError({ legalEntity: "single_legal_entity_required" }, 409);
|
||||
}
|
||||
legalEntityId = legalEntityIds[0];
|
||||
const invalidPayee = snapshots.find((snapshot) => {
|
||||
const payee = snapshot.snapshotJson?.payee;
|
||||
return payee?.bankInstitutionCode !== "CCB" || !payee?.bankAccountEncrypted;
|
||||
});
|
||||
if (invalidPayee) {
|
||||
throw buildValidationError({
|
||||
payeeBankAccount: `${invalidPayee.entityCode}:ccb_account_required`,
|
||||
}, 409);
|
||||
}
|
||||
paymentAccount = await tx.paymentAccount.findFirst({
|
||||
where: {
|
||||
legal_entity_id: legalEntityId,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
bank_institution: { code: "CCB", enabled: true, archived_at: null },
|
||||
},
|
||||
include: PAYMENT_ACCOUNT_INCLUDE,
|
||||
});
|
||||
if (!paymentAccount) {
|
||||
throw buildValidationError({ paymentAccount: "active_ccb_account_required" }, 409);
|
||||
}
|
||||
let paymentAccountNumber;
|
||||
try {
|
||||
paymentAccountNumber = decryptStringOrThrow(
|
||||
fastify.config,
|
||||
paymentAccount.account_number_encrypted,
|
||||
);
|
||||
} catch (error) {
|
||||
throw buildValidationError({ paymentAccount: "decrypt_failed" }, 409);
|
||||
}
|
||||
paymentAccountSnapshot = {
|
||||
paymentAccountId: paymentAccount.id,
|
||||
legalEntityId,
|
||||
accountName: paymentAccount.account_name,
|
||||
bankInstitutionId: paymentAccount.bank_institution.id,
|
||||
bankInstitutionCode: paymentAccount.bank_institution.code,
|
||||
bankName: paymentAccount.bank_institution.name,
|
||||
accountNumberEncrypted: paymentAccount.account_number_encrypted,
|
||||
accountNumberMasked: maskBankAccount(paymentAccountNumber),
|
||||
};
|
||||
}
|
||||
|
||||
const reservedItems = await tx.paymentBatchItem.findMany({
|
||||
where: {
|
||||
entity_id: { in: entityIds },
|
||||
@@ -1365,6 +1496,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
|
||||
default_payment_channel: body.defaultPaymentChannel ?? "bank",
|
||||
voucher_prefix: normalizeNullableString(body.voucherPrefix),
|
||||
legal_entity_id: legalEntityId,
|
||||
payment_account_id: paymentAccount?.id ?? null,
|
||||
payment_account_snapshot_json: paymentAccountSnapshot,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
@@ -1433,6 +1567,129 @@ async function paymentBatchesRoutes(fastify) {
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
"/payment-batches/:id/payment-order",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
ensurePaymentOrderExportPermission(fastify, request);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const { reason } = parseBody(REASON_SCHEMA, request.body);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (batch.status !== "reviewed") {
|
||||
throw buildValidationError({ status: "payment_order_requires_reviewed_batch" }, 409);
|
||||
}
|
||||
if (batch.entity_type !== "reimbursement") {
|
||||
throw buildValidationError({ entityType: "payment_order_reimbursement_only" }, 409);
|
||||
}
|
||||
const generated = generatePaymentOrderXls(fastify.config, batch);
|
||||
const exportCount = Number(batch.payment_order_export_count ?? 0) + 1;
|
||||
const exportedAt = new Date();
|
||||
await tx.paymentBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
payment_order_export_count: exportCount,
|
||||
payment_order_last_exported_at: exportedAt,
|
||||
payment_order_last_exported_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: exportCount === 1 ? "PAYMENT_ORDER_EXPORTED" : "PAYMENT_ORDER_REEXPORTED",
|
||||
targetType: "payment_batch",
|
||||
targetId: batch.id,
|
||||
extra: {
|
||||
batchNo: batch.batch_no,
|
||||
rowCount: generated.rowCount,
|
||||
totalAmount: generated.totalAmount,
|
||||
reason,
|
||||
exportCount,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { ...generated, batchNo: batch.batch_no };
|
||||
});
|
||||
} catch (error) {
|
||||
const resultCode = error instanceof PaymentOrderXlsError
|
||||
? error.code
|
||||
: error?.details?.status ?? error?.code ?? "export_failed";
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_ORDER_EXPORT_BLOCKED",
|
||||
targetType: "payment_batch",
|
||||
targetId: batchId,
|
||||
extra: { reason, resultCode },
|
||||
}),
|
||||
}).catch((auditError) => {
|
||||
fastify.log.error({ err: auditError, requestId: request.id }, "payment order block audit failed");
|
||||
});
|
||||
if (error instanceof PaymentOrderXlsError) {
|
||||
throw new AppError({
|
||||
code: "PAYMENT_ORDER_EXPORT_BLOCKED",
|
||||
message: "银行制单生成失败",
|
||||
statusCode: 409,
|
||||
details: { reason: error.code, ...error.details },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const safeBatchNo = String(result.batchNo).replace(/[^A-Za-z0-9-]/g, "_");
|
||||
reply.header("content-type", "application/vnd.ms-excel");
|
||||
reply.header("content-disposition", `attachment; filename="payment-order-${safeBatchNo}.xls"`);
|
||||
reply.header("cache-control", "no-store");
|
||||
reply.header("x-content-type-options", "nosniff");
|
||||
return reply.send(result.buffer);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/payment-batches/:id/items/:itemId/payee-bank/reveal",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
ensurePayeeBankRevealPermission(fastify, request);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const itemId = parseId(request.params?.itemId, "itemId");
|
||||
const { reason } = parseBody(REASON_SCHEMA, request.body);
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "payee_bank_reveal_not_available" }, 409);
|
||||
}
|
||||
const item = batch.items.find((candidate) => candidate.id === itemId);
|
||||
if (!item) throw buildNotFoundError();
|
||||
const encryptedAccount = item.snapshot_json?.payee?.bankAccountEncrypted;
|
||||
if (!encryptedAccount) {
|
||||
throw buildValidationError({ payeeBankAccount: "legacy_snapshot_missing" }, 409);
|
||||
}
|
||||
let bankAccount;
|
||||
try {
|
||||
bankAccount = decryptStringOrThrow(fastify.config, encryptedAccount);
|
||||
} catch (error) {
|
||||
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
|
||||
}
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED",
|
||||
targetType: "payment_batch",
|
||||
targetId: batch.id,
|
||||
extra: { paymentBatchItemId: item.id, entityCode: item.entity_code, reason },
|
||||
}),
|
||||
});
|
||||
return sendData(reply, request, { bankAccount });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchReadPermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
|
||||
+103
-23
@@ -97,12 +97,16 @@ const resetPasswordBodySchema = z.object({
|
||||
|
||||
const sensitiveBodySchema = z
|
||||
.object({
|
||||
bankName: z.string().trim().max(100).nullable().optional(),
|
||||
bankAccount: z.string().trim().max(100).nullable().optional(),
|
||||
bankInstitutionId: z.string().uuid().nullable().optional(),
|
||||
bankAccount: z.string().trim().regex(/^\d{6,32}$/).nullable().optional(),
|
||||
idNumber: z.string().trim().max(64).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const sensitiveRevealBodySchema = z.object({
|
||||
reason: z.string().trim().min(2).max(500),
|
||||
}).strict();
|
||||
|
||||
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"];
|
||||
const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"];
|
||||
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
|
||||
@@ -1863,7 +1867,8 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const canReadBank = fastify.hasPermission(request.employee, "employee.bank.read")
|
||||
const canReadBank = request.employee.id === userId
|
||||
|| fastify.hasPermission(request.employee, "employee.bank.read")
|
||||
|| fastify.hasPermission(request.employee, "employee.bank.manage");
|
||||
const canReadIdentity = fastify.hasPermission(request.employee, "employee.identity.manage");
|
||||
if (!canReadBank && !canReadIdentity) {
|
||||
@@ -1877,7 +1882,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
...(canReadBank
|
||||
? {
|
||||
bank_name: true,
|
||||
bank_institution_id: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
}
|
||||
: {}),
|
||||
...(canReadIdentity ? { id_number_encrypted: true } : {}),
|
||||
@@ -1895,10 +1902,19 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
const data = {};
|
||||
try {
|
||||
if (canReadBank) {
|
||||
data.bankName = employee.bank_name ? String(employee.bank_name) : null;
|
||||
data.bankAccount = employee.bank_account_encrypted
|
||||
const bankAccount = employee.bank_account_encrypted
|
||||
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
|
||||
: null;
|
||||
data.bankInstitution = employee.bank_institution
|
||||
? {
|
||||
id: employee.bank_institution.id,
|
||||
code: employee.bank_institution.code,
|
||||
name: employee.bank_institution.name,
|
||||
}
|
||||
: null;
|
||||
data.bankName = employee.bank_institution?.name
|
||||
?? (employee.bank_name ? String(employee.bank_name) : null);
|
||||
data.bankAccountMasked = bankAccount ? maskBankAccount(bankAccount) : null;
|
||||
}
|
||||
if (canReadIdentity) {
|
||||
data.idNumber = employee.id_number_encrypted
|
||||
@@ -1915,22 +1931,71 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (canReadIdentity) {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: { scopes: ["identity"] },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, data);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/sensitive/bank/reveal`,
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const parsed = sensitiveRevealBodySchema.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
const canRevealBank = fastify.hasPermission(request.employee, "employee.bank.read")
|
||||
|| fastify.hasPermission(request.employee, "employee.bank.manage");
|
||||
if (!canRevealBank) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, bank_account_encrypted: true },
|
||||
});
|
||||
if (!employee) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
let bankAccount = null;
|
||||
try {
|
||||
bankAccount = employee.bank_account_encrypted
|
||||
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
|
||||
: null;
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500, details: {} });
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: {
|
||||
scopes: [
|
||||
...(canReadBank ? ["bank"] : []),
|
||||
...(canReadIdentity ? ["identity"] : []),
|
||||
],
|
||||
},
|
||||
extra: { reason: parsed.data.reason },
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, data);
|
||||
return sendData(reply, request, { bankAccount });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1942,7 +2007,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const body = parseSensitiveBody(request.body);
|
||||
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankName")
|
||||
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankInstitutionId")
|
||||
|| Object.prototype.hasOwnProperty.call(body, "bankAccount");
|
||||
const writesIdentity = Object.prototype.hasOwnProperty.call(body, "idNumber");
|
||||
if (writesBank) {
|
||||
@@ -1960,7 +2025,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
...(writesBank ? { bank_name: true } : {}),
|
||||
...(writesBank ? { bank_name: true, bank_institution_id: true } : {}),
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
@@ -1977,13 +2042,28 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
updated_by: request.employee.id,
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "bankName")) {
|
||||
const nextBankName =
|
||||
typeof body.bankName === "string" ? body.bankName.trim() : body.bankName;
|
||||
data.bank_name = nextBankName ? String(nextBankName) : "";
|
||||
changes.bankName = {
|
||||
before: current.bank_name ? String(current.bank_name) : null,
|
||||
after: data.bank_name || null,
|
||||
if (Object.prototype.hasOwnProperty.call(body, "bankInstitutionId")) {
|
||||
let bankInstitution = null;
|
||||
if (body.bankInstitutionId) {
|
||||
bankInstitution = await tx.bankInstitution.findFirst({
|
||||
where: { id: body.bankInstitutionId, enabled: true, archived_at: null },
|
||||
select: { id: true, code: true, name: true },
|
||||
});
|
||||
if (!bankInstitution) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { bankInstitutionId: "not_available" },
|
||||
});
|
||||
}
|
||||
}
|
||||
data.bank_institution_id = bankInstitution?.id ?? null;
|
||||
data.bank_name = bankInstitution?.name ?? "";
|
||||
changes.bankInstitution = {
|
||||
beforeId: current.bank_institution_id ?? null,
|
||||
afterId: bankInstitution?.id ?? null,
|
||||
afterCode: bankInstitution?.code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -567,6 +567,36 @@ async function seedExpenseTypes(prisma) {
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBankInstitutions(prisma) {
|
||||
const existing = await prisma.bankInstitution.findUnique({
|
||||
where: { code: "CCB" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { created: 0, preserved: 1 };
|
||||
}
|
||||
|
||||
return prisma.bankInstitution.create({
|
||||
data: {
|
||||
code: "CCB",
|
||||
name: "中国建设银行",
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
},
|
||||
}).then(
|
||||
() => ({ created: 1, preserved: 0 }),
|
||||
(error) => {
|
||||
if (error?.code === "P2002") {
|
||||
return { created: 0, preserved: 1 };
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function seedApprovalRules(prisma) {
|
||||
const existingCount = await prisma.approvalRule.count();
|
||||
if (existingCount > 0) {
|
||||
@@ -1032,6 +1062,7 @@ async function main() {
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
const bankInstitutionSeed = await seedBankInstitutions(prisma);
|
||||
await seedExpenseTypes(prisma);
|
||||
const approvalRuleSeed = await seedApprovalRules(prisma);
|
||||
await seedDocumentSequence(prisma);
|
||||
@@ -1046,6 +1077,10 @@ async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Seeded config data:");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`- bank institutions: ${bankInstitutionSeed.created} created / ${bankInstitutionSeed.preserved} preserved`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
@@ -1096,6 +1131,7 @@ module.exports = {
|
||||
DEFAULT_FEATURE_FLAGS,
|
||||
ENABLED_EXPENSE_TYPES,
|
||||
seedApprovalRules,
|
||||
seedBankInstitutions,
|
||||
seedExpenseTypes,
|
||||
seedFeatureFlags,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
const XLSX = require("xlsx");
|
||||
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
|
||||
const HEADERS = Object.freeze([
|
||||
"*序号",
|
||||
"*付款方客户账号",
|
||||
"*付款方账户名称",
|
||||
"*收款方行别代码\n(01-本行 02-国内他行)",
|
||||
"*收款方客户账号",
|
||||
"*收款方账户名称",
|
||||
"收款方开户行名称",
|
||||
"收款方联行号",
|
||||
"客户方流水号",
|
||||
"*金额",
|
||||
"*用途",
|
||||
"备注",
|
||||
"是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)",
|
||||
"收款人手机号码",
|
||||
"短信通知附加信息",
|
||||
]);
|
||||
|
||||
class PaymentOrderXlsError extends Error {
|
||||
constructor(code, details = {}) {
|
||||
super(code);
|
||||
this.name = "PaymentOrderXlsError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCustomerSerial(batchNo, rowNumber) {
|
||||
const serial = `${String(batchNo).trim()}-${String(rowNumber).padStart(3, "0")}`;
|
||||
if (serial.length > 32) {
|
||||
throw new PaymentOrderXlsError("customer_serial_too_long");
|
||||
}
|
||||
if (!/^[A-Z0-9-]+$/.test(serial)) {
|
||||
throw new PaymentOrderXlsError("customer_serial_invalid_characters");
|
||||
}
|
||||
return serial;
|
||||
}
|
||||
|
||||
function requiredText(value, code) {
|
||||
const normalized = typeof value === "string" ? value.trim() : "";
|
||||
if (!normalized) throw new PaymentOrderXlsError(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function decryptRequired(config, value, code) {
|
||||
try {
|
||||
return requiredText(value ? decryptStringOrThrow(config, value) : "", code);
|
||||
} catch (error) {
|
||||
if (error instanceof PaymentOrderXlsError) throw error;
|
||||
throw new PaymentOrderXlsError(code);
|
||||
}
|
||||
}
|
||||
|
||||
function stringCell(value = "") {
|
||||
return { t: "s", v: String(value) };
|
||||
}
|
||||
|
||||
function buildPaymentOrderRows(config, batch) {
|
||||
const blockers = [];
|
||||
const addBlocker = (code, entityCode) => {
|
||||
blockers.push({ code, ...(entityCode ? { entityCode: String(entityCode) } : {}) });
|
||||
};
|
||||
const payer = batch?.payment_account_snapshot_json;
|
||||
if (!payer || typeof payer !== "object" || Array.isArray(payer)) {
|
||||
addBlocker("payment_account_snapshot_missing");
|
||||
}
|
||||
if (payer && !payer.bankInstitutionCode) {
|
||||
addBlocker("payer_bank_institution_missing");
|
||||
} else if (payer && payer.bankInstitutionCode !== "CCB") {
|
||||
addBlocker("payer_bank_not_supported");
|
||||
}
|
||||
let payerAccount = "";
|
||||
let payerName = "";
|
||||
if (payer) {
|
||||
try {
|
||||
payerAccount = decryptRequired(config, payer.accountNumberEncrypted, "payer_account_unavailable");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payer_account_unavailable");
|
||||
}
|
||||
try {
|
||||
payerName = requiredText(payer.accountName, "payer_name_missing");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payer_name_missing");
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...(batch.items ?? [])].sort((left, right) =>
|
||||
String(left.entity_code).localeCompare(String(right.entity_code), "zh-CN")
|
||||
|| String(left.id).localeCompare(String(right.id)),
|
||||
);
|
||||
if (!items.length) addBlocker("items_missing");
|
||||
|
||||
const serials = new Set();
|
||||
let totalAmount = 0;
|
||||
const preparedRows = items.map((item, index) => {
|
||||
const payee = item.snapshot_json?.payee;
|
||||
const entityCodeHint = typeof item.entity_code === "string" && item.entity_code.trim()
|
||||
? item.entity_code.trim()
|
||||
: undefined;
|
||||
if (!payee?.bankInstitutionCode) {
|
||||
addBlocker("payee_bank_institution_missing", entityCodeHint);
|
||||
} else if (payee.bankInstitutionCode !== "CCB") {
|
||||
addBlocker("payee_bank_not_supported", entityCodeHint);
|
||||
}
|
||||
let payeeAccount = "";
|
||||
let payeeName = "";
|
||||
let entityCode = "";
|
||||
try {
|
||||
payeeAccount = decryptRequired(config, payee?.bankAccountEncrypted, "payee_account_unavailable");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payee_account_unavailable", entityCodeHint);
|
||||
}
|
||||
try {
|
||||
payeeName = requiredText(payee?.name, "payee_name_missing");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payee_name_missing", entityCodeHint);
|
||||
}
|
||||
try {
|
||||
entityCode = requiredText(item.entity_code, "entity_code_missing");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "entity_code_missing");
|
||||
}
|
||||
const amount = Number(item.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
addBlocker("amount_invalid", entityCodeHint);
|
||||
}
|
||||
let serial = "";
|
||||
try {
|
||||
serial = buildCustomerSerial(batch.batch_no, index + 1);
|
||||
if (serials.has(serial)) addBlocker("customer_serial_duplicate", entityCodeHint);
|
||||
serials.add(serial);
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "customer_serial_invalid", entityCodeHint);
|
||||
}
|
||||
if (Number.isFinite(amount) && amount > 0) totalAmount += amount;
|
||||
return { payee, payeeAccount, payeeName, entityCode, amount, serial };
|
||||
});
|
||||
|
||||
if (blockers.length) {
|
||||
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
|
||||
}
|
||||
|
||||
const rows = preparedRows.map(({ payee, payeeAccount, payeeName, entityCode, amount, serial }, index) => {
|
||||
return [
|
||||
stringCell(index + 1),
|
||||
stringCell(payerAccount),
|
||||
stringCell(payerName),
|
||||
stringCell("01"),
|
||||
stringCell(payeeAccount),
|
||||
stringCell(payeeName),
|
||||
stringCell(payee.bankName ?? "中国建设银行"),
|
||||
stringCell(""),
|
||||
stringCell(serial),
|
||||
{ t: "n", v: Number(amount.toFixed(2)), z: "0.00" },
|
||||
stringCell("报销款"),
|
||||
stringCell(entityCode),
|
||||
stringCell("0"),
|
||||
stringCell(""),
|
||||
stringCell(""),
|
||||
];
|
||||
});
|
||||
return { rows, totalAmount: totalAmount.toFixed(2), serials: [...serials] };
|
||||
}
|
||||
|
||||
function buildSheet(rows) {
|
||||
const sheet = {};
|
||||
const allRows = [HEADERS.map(stringCell), ...rows];
|
||||
for (let row = 0; row < allRows.length; row += 1) {
|
||||
for (let column = 0; column < HEADERS.length; column += 1) {
|
||||
sheet[XLSX.utils.encode_cell({ r: row, c: column })] = allRows[row][column] ?? stringCell("");
|
||||
}
|
||||
}
|
||||
sheet["!ref"] = XLSX.utils.encode_range({ r: 0, c: 0 }, { r: allRows.length - 1, c: HEADERS.length - 1 });
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function generatePaymentOrderXls(config, batch) {
|
||||
const { rows, totalAmount, serials } = buildPaymentOrderRows(config, batch);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, buildSheet(rows), "Sheet1");
|
||||
XLSX.utils.book_append_sheet(workbook, {}, "Sheet2");
|
||||
XLSX.utils.book_append_sheet(workbook, {}, "Sheet3");
|
||||
const buffer = XLSX.write(workbook, { bookType: "biff8", type: "buffer" });
|
||||
return { buffer, rowCount: rows.length, totalAmount, serials };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HEADERS,
|
||||
PaymentOrderXlsError,
|
||||
buildCustomerSerial,
|
||||
buildPaymentOrderRows,
|
||||
generatePaymentOrderXls,
|
||||
};
|
||||
@@ -86,7 +86,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
||||
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
|
||||
contractsRegistry: { key: "contractsRegistry", label: "合同", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
|
||||
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
|
||||
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
|
||||
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
|
||||
featureFlags: { key: "featureFlags", label: "功能管理", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
|
||||
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ const Fastify = require('fastify');
|
||||
|
||||
const meRoutes = require('../../../src/routes/me');
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
const { encryptStringOrThrow } = require('../../../src/services/crypto');
|
||||
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
function createPrismaState(overrides = {}) {
|
||||
const auditEntries = [];
|
||||
@@ -18,6 +21,7 @@ function createPrismaState(overrides = {}) {
|
||||
emergency_contact_phone: null,
|
||||
bank_account_encrypted: '',
|
||||
bank_name: '',
|
||||
bank_institution: null,
|
||||
avatar_path: null,
|
||||
id_number_encrypted: null,
|
||||
notification_push_enabled: true,
|
||||
@@ -53,6 +57,7 @@ function createPrismaState(overrides = {}) {
|
||||
async function buildApp(prisma) {
|
||||
const app = Fastify();
|
||||
app.decorate('prisma', prisma);
|
||||
app.decorate('config', { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
|
||||
app.decorate('authenticate', async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({
|
||||
@@ -130,8 +135,12 @@ describe('me routes', () => {
|
||||
const { prisma } = createPrismaState({
|
||||
emergency_contact_name: '联系人',
|
||||
emergency_contact_phone: '13900000000',
|
||||
bank_account_encrypted: 'ENC',
|
||||
bank_name: '招商银行',
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
'6222000012345678',
|
||||
),
|
||||
bank_name: '中国建设银行',
|
||||
bank_institution: { id: 'bank-1', code: 'CCB', name: '中国建设银行' },
|
||||
avatar_path: null,
|
||||
id_number_encrypted: null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const paymentAccountsRoutes = require("../../../src/routes/paymentAccounts");
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ACCOUNT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const LEGAL_ENTITY_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const BANK_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
const SYNTHETIC_ACCOUNT = "6217000000000012345";
|
||||
|
||||
function buildRecord(overrides = {}) {
|
||||
return {
|
||||
id: ACCOUNT_ID,
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
bank_institution_id: BANK_ID,
|
||||
account_name: "合成测试公司",
|
||||
account_number_encrypted: encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_ACCOUNT),
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
created_at: new Date("2026-08-02T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-02T00:00:00.000Z"),
|
||||
legal_entity: { id: LEGAL_ENTITY_ID, code: "TEST", name: "合成测试公司" },
|
||||
bank_institution: { id: BANK_ID, code: "CCB", name: "中国建设银行" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(permissionCodes) {
|
||||
const app = Fastify();
|
||||
const actor = { id: ACTOR_ID, roleCodes: [], permissionCodes };
|
||||
const record = buildRecord();
|
||||
const auditCreate = vi.fn(async () => ({ id: "audit" }));
|
||||
const notificationCreateMany = vi.fn(async ({ data }) => ({ count: data.length }));
|
||||
const paymentAccountCreate = vi.fn(async ({ data }) => buildRecord({ ...data }));
|
||||
const tx = {
|
||||
legalEntity: { findFirst: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, name: "合成测试公司" })) },
|
||||
bankInstitution: { findFirst: vi.fn(async () => ({ id: BANK_ID, code: "CCB", name: "中国建设银行" })) },
|
||||
paymentAccount: {
|
||||
create: paymentAccountCreate,
|
||||
update: vi.fn(),
|
||||
findFirst: vi.fn(async () => record),
|
||||
},
|
||||
auditLog: { create: auditCreate },
|
||||
employeeRole: { findMany: vi.fn(async () => [{ employee_id: ACTOR_ID }]) },
|
||||
notification: { createMany: notificationCreateMany },
|
||||
};
|
||||
app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
|
||||
app.decorate("prisma", {
|
||||
bankInstitution: { findMany: vi.fn(async () => [{ id: BANK_ID, code: "CCB", name: "中国建设银行" }]) },
|
||||
paymentAccount: {
|
||||
findMany: vi.fn(async () => [record]),
|
||||
findUnique: vi.fn(async () => ({ id: ACCOUNT_ID, account_number_encrypted: record.account_number_encrypted })),
|
||||
},
|
||||
auditLog: { create: auditCreate },
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("authenticate", async (request) => { request.employee = actor; });
|
||||
app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permission));
|
||||
app.decorate("requirePermission", (permission) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!app.hasPermission(actor, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
|
||||
await app.register(paymentAccountsRoutes, { prefix: "/api" });
|
||||
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
|
||||
}
|
||||
|
||||
describe("payment account routes", () => {
|
||||
it("returns only a masked account on normal reads", async () => {
|
||||
const testApp = await buildApp(["payment.account.read"]);
|
||||
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0].accountNumberMasked).toBe("6217***********2345");
|
||||
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("requires a reason before revealing the full account and audits the successful view", async () => {
|
||||
const testApp = await buildApp(["payment.account.read_full"]);
|
||||
const missingReason = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: {} });
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
const response = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: { reason: "银行制单核对" } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.accountNumber).toBe(SYNTHETIC_ACCOUNT);
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED", extra: { reason: "银行制单核对" } }) });
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("creates an encrypted account with masked audit and notifies administrators", async () => {
|
||||
const testApp = await buildApp(["payment.account.manage"]);
|
||||
const response = await testApp.app.inject({ method: "POST", url: "/api/payment-accounts", payload: { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT } });
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(testApp.paymentAccountCreate.mock.calls[0][0].data.account_number_encrypted).toMatch(/^v1:/);
|
||||
expect(JSON.stringify(testApp.auditCreate.mock.calls)).not.toContain(SYNTHETIC_ACCOUNT);
|
||||
expect(testApp.notificationCreateMany).toHaveBeenCalledOnce();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("does not grant payment-account access to a system administrator by role implication", async () => {
|
||||
const testApp = await buildApp(["system.settings", "system.accounts.manage"]);
|
||||
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
|
||||
expect(response.statusCode).toBe(403);
|
||||
await testApp.app.close();
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,9 @@ const EXECUTED_REIMBURSEMENT_ID = "bbbbbbbb-2222-4222-8222-222222222222";
|
||||
const OWNER_ID = "77777777-7777-4777-8777-777777777777";
|
||||
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
const LEGAL_ENTITY_ID = "12121212-1212-4121-8121-121212121212";
|
||||
const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313";
|
||||
const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414";
|
||||
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
@@ -174,7 +177,13 @@ function buildReimbursement(overrides = {}) {
|
||||
id: OWNER_ID,
|
||||
employee_no: "EMP001",
|
||||
name: "测试员工",
|
||||
bank_name: "测试银行",
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
bank_name: "中国建设银行",
|
||||
bank_institution: {
|
||||
id: BANK_INSTITUTION_ID,
|
||||
code: "CCB",
|
||||
name: "中国建设银行",
|
||||
},
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
@@ -238,6 +247,21 @@ function buildPrisma(options = {}) {
|
||||
},
|
||||
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
|
||||
reservedEntityIds: options.reservedEntityIds ?? [],
|
||||
paymentAccount: {
|
||||
id: PAYMENT_ACCOUNT_ID,
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
bank_institution_id: BANK_INSTITUTION_ID,
|
||||
account_name: "测试公司",
|
||||
account_number_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
`6217${"0".repeat(8)}1234`,
|
||||
),
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
legal_entity: { id: LEGAL_ENTITY_ID, code: "HYB", name: "测试公司" },
|
||||
bank_institution: { id: BANK_INSTITUTION_ID, code: "CCB", name: "中国建设银行" },
|
||||
...(options.paymentAccount ?? {}),
|
||||
},
|
||||
employees: options.employees ?? [
|
||||
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
|
||||
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
|
||||
@@ -254,6 +278,9 @@ function buildPrisma(options = {}) {
|
||||
};
|
||||
|
||||
const tx = {
|
||||
paymentAccount: {
|
||||
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
|
||||
},
|
||||
paymentBatch: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.id !== state.batch.id) {
|
||||
@@ -967,9 +994,13 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
|
||||
expect(detailResponse.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([
|
||||
{ code: "legacy_payment_account_snapshot_missing" },
|
||||
{ code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" },
|
||||
]));
|
||||
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
|
||||
await app.close();
|
||||
@@ -1002,7 +1033,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
@@ -1150,17 +1181,17 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
snapshot_json: expect.objectContaining({
|
||||
payee: {
|
||||
payee: expect.objectContaining({
|
||||
name: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(response.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
|
||||
@@ -1210,7 +1241,8 @@ describe("payment batch reimbursement routes", () => {
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.payeeBankAccount).toBe("decrypt_failed");
|
||||
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
@@ -1517,7 +1549,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
|
||||
expect(detail.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(state.batch.items[0].snapshot_json).toEqual({});
|
||||
@@ -2293,4 +2325,116 @@ describe("payment batch reimbursement routes", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => {
|
||||
const payerEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
`6217${"0".repeat(8)}1234`,
|
||||
);
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx, state } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: {
|
||||
status: "reviewed",
|
||||
payment_account_snapshot_json: {
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
accountNumberMasked: "6217********1234",
|
||||
accountNumberEncrypted: payerEncrypted,
|
||||
},
|
||||
payment_order_export_count: 0,
|
||||
items: [buildPaymentBatch().items[0],].map((item) => ({
|
||||
...item,
|
||||
snapshot_json: {
|
||||
payee: {
|
||||
name: "测试员工",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
bankAccountMasked: "6222********5678",
|
||||
bankAccountEncrypted: payeeEncrypted,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "导入企业网银" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(state.batch.payment_order_export_count).toBe(1);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: { status: "reviewed", payment_account_snapshot_json: null },
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "旧批次尝试" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("payment_account_snapshot_missing");
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reveals the frozen payee account to finance only after a reason and audits the view", async () => {
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "employee.bank.read"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: {
|
||||
status: "reviewed",
|
||||
items: buildPaymentBatch().items.map((item) => ({
|
||||
...item,
|
||||
snapshot_json: { payee: { bankAccountEncrypted: payeeEncrypted } },
|
||||
})),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}/payee-bank/reveal`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "人工转账核对" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.bankAccount).toBe(SYNTHETIC_BANK_ACCOUNT);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED", extra: expect.objectContaining({ reason: "人工转账核对", paymentBatchItemId: ITEM_ID }) }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,15 +85,16 @@ describe("user sensitive field permissions", () => {
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("returns only bank fields to finance and audits the authorized scope", async () => {
|
||||
it("returns only masked bank fields to finance without a full-account view audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.read"]);
|
||||
|
||||
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
bankInstitution: null,
|
||||
bankName: "合成银行",
|
||||
bankAccount: "6222000012345678",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(response.json().data).not.toHaveProperty("idNumber");
|
||||
expect(testApp.employeeFindUnique).toHaveBeenCalledWith({
|
||||
@@ -101,15 +102,12 @@ describe("user sensitive field permissions", () => {
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_institution_id: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
extra: { scopes: ["bank"] },
|
||||
}),
|
||||
});
|
||||
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
@@ -119,7 +117,7 @@ describe("user sensitive field permissions", () => {
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankName: "新银行" },
|
||||
payload: { bankAccount: "6222000099995678" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
@@ -134,7 +132,7 @@ describe("user sensitive field permissions", () => {
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankName: "新银行", idNumber: "110101199001011234" },
|
||||
payload: { bankAccount: "6222000099995678", idNumber: "110101199001011234" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
@@ -143,6 +141,33 @@ describe("user sensitive field permissions", () => {
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("reveals a full bank account only after a reason and records the view audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.read"]);
|
||||
|
||||
const missingReason = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
|
||||
payload: {},
|
||||
});
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
const response = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
|
||||
payload: { reason: "付款核对" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({ bankAccount: "6222000012345678" });
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
|
||||
extra: { reason: "付款核对" },
|
||||
}),
|
||||
});
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("writes only authorized fields and keeps plaintext out of the audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.manage", "employee.identity.manage"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const XLSX = require("xlsx");
|
||||
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const {
|
||||
HEADERS,
|
||||
PaymentOrderXlsError,
|
||||
buildCustomerSerial,
|
||||
generatePaymentOrderXls,
|
||||
} = require("../../../src/services/paymentOrderXls");
|
||||
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
|
||||
const encrypt = (value) => encryptStringOrThrow(config, value);
|
||||
|
||||
function buildBatch(overrides = {}) {
|
||||
return {
|
||||
batch_no: "PB-20260802-002",
|
||||
payment_account_snapshot_json: {
|
||||
bankInstitutionCode: "CCB",
|
||||
accountName: "合成测试公司",
|
||||
accountNumberEncrypted: encrypt("6217000000000012345"),
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: "b",
|
||||
entity_code: "BX-20260802-0002",
|
||||
amount: "2.00",
|
||||
snapshot_json: { payee: { name: "乙", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } },
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
entity_code: "BX-20260802-0001",
|
||||
amount: "1.20",
|
||||
snapshot_json: { payee: { name: "甲", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000012345") } },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("payment order BIFF8 generator", () => {
|
||||
it("writes the fixed workbook contract with typed string identifiers and stable ordering", () => {
|
||||
const first = generatePaymentOrderXls(config, buildBatch());
|
||||
const second = generatePaymentOrderXls(config, buildBatch());
|
||||
const workbook = XLSX.read(first.buffer, { type: "buffer", cellNF: true });
|
||||
const sheet = workbook.Sheets.Sheet1;
|
||||
|
||||
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
||||
expect(workbook.SheetNames).toEqual(["Sheet1", "Sheet2", "Sheet3"]);
|
||||
expect(XLSX.utils.sheet_to_json(sheet, { header: 1 })[0]).toEqual(HEADERS);
|
||||
expect(sheet.B2).toMatchObject({ t: "s", v: "6217000000000012345" });
|
||||
expect(sheet.D2).toMatchObject({ t: "s", v: "01" });
|
||||
expect(sheet.E2).toMatchObject({ t: "s", v: "6222000000000012345" });
|
||||
expect(sheet.I2).toMatchObject({ t: "s", v: "PB-20260802-002-001" });
|
||||
expect(sheet.J2).toMatchObject({ t: "n", v: 1.2 });
|
||||
expect(sheet.M2).toMatchObject({ t: "s", v: "0" });
|
||||
expect(first.serials).toEqual(second.serials);
|
||||
expect(first.totalAmount).toBe("3.20");
|
||||
});
|
||||
|
||||
it("fails the whole export and reports every blocking payee issue", () => {
|
||||
const batch = buildBatch();
|
||||
batch.items[1].snapshot_json.payee.bankInstitutionCode = "OTHER";
|
||||
batch.items[0].snapshot_json.payee.bankAccountEncrypted = null;
|
||||
try {
|
||||
generatePaymentOrderXls(config, batch);
|
||||
throw new Error("expected payment order generation to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PaymentOrderXlsError);
|
||||
expect(error.code).toBe("payee_bank_not_supported");
|
||||
expect(error.details.blockers).toEqual(expect.arrayContaining([
|
||||
{ code: "payee_bank_not_supported", entityCode: "BX-20260802-0001" },
|
||||
{ code: "payee_account_unavailable", entityCode: "BX-20260802-0002" },
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
it("validates customer serial length and character set without truncation", () => {
|
||||
expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001");
|
||||
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
|
||||
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户