实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户