实现企业银行账户与建行制单(corporate-bank-account-payment-order)

这个提交包含在:
2026-08-02 23:36:40 +08:00
父节点 f946d64539
当前提交 5261d2849b
共修改 46 个文件,包含 2751 行新增和 73 行删除
+2
查看文件
@@ -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" });
+7 -1
查看文件
@@ -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,
});
+419
查看文件
@@ -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;
+263 -6
查看文件
@@ -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,
};
}
+36
查看文件
@@ -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,
};
+197
查看文件
@@ -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,
};
+1 -1
查看文件
@@ -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"] },