文件
hyb-oa/server/src/routes/paymentBatches.js
T

2512 行
86 KiB
JavaScript

const crypto = require("node:crypto");
const path = require("node:path");
const { Prisma } = require("@prisma/client");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
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 {
buildPaymentProcessingSemantics,
requiresBankReceipt,
resolveItemPaymentChannel,
} = require("../services/paymentProcessingProgress");
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
buildAttachmentContentDisposition,
PurchaseAttachmentPolicyError,
validateAndNormalizePurchaseAttachment,
} = require("../services/purchaseAttachmentPolicy");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
GOVERNANCE_STATUS,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
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(),
status: z.enum(["draft", "reviewed", "executed", "failed", "cancelled"]).optional(),
});
const CANDIDATES_QUERY_SCHEMA = z.object({
q: z.string().trim().max(100).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
}).strict();
const CREATE_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA,
name: z.string().trim().min(1).max(120).optional(),
entityIds: z.array(UUID_SCHEMA).min(1).max(200),
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(),
voucherPrefix: z.string().trim().min(1).max(32).optional(),
}).strict();
const PATCH_ITEM_SCHEMA = z.object({
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const REVIEW_SCHEMA = z.object({
comment: z.string().trim().max(500).optional(),
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
}).strict();
const REASON_SCHEMA = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseId(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeNullableString(value) {
if (value == null) {
return null;
}
const trimmed = String(value).trim();
return trimmed || null;
}
function mapReceiptAttachment(item) {
return {
id: item.id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
...mapGovernanceFields(item, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
attachmentType: ATTACHMENT_TYPE.receipt,
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
}),
uploadedBy: item.uploader
? {
id: item.uploader.id,
employeeNo: item.uploader.employee_no,
name: item.uploader.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
async function ensureBankReceiptForExecutableBatch(tx, batch) {
if (!requiresBankReceipt(batch)) {
return;
}
const receiptCount = await tx.paymentBatchReceiptAttachment.count({
where: { payment_batch_id: batch.id, archived_at: null },
});
if (receiptCount <= 0) {
throw buildValidationError({ receiptAttachments: "bank_receipt_required" }, 409);
}
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
}
return gate;
}
async function evaluatePaymentBatchDepartments(fastify, prisma, departmentIds) {
const uniqueDepartmentIds = Array.from(
new Set((departmentIds ?? []).map((departmentId) => departmentId ?? null)),
);
for (const departmentId of uniqueDepartmentIds) {
const gate = await evaluateFeatureFlag({
prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
departmentId,
strictDepartment: true,
});
if (!gate.enabled) {
return { enabled: false, gate };
}
}
return { enabled: true, gate: null };
}
async function ensurePaymentBatchDepartmentsEnabled(fastify, prisma, departmentIds) {
const result = await evaluatePaymentBatchDepartments(fastify, prisma, departmentIds);
if (!result.enabled) {
throw buildValidationError({
featureFlag: "payment_batch_v1_disabled",
reason: result.gate?.reason ?? "department_not_allowed",
}, 409);
}
}
async function loadBatchSourceDepartmentIds(tx, batch) {
if (batch.entity_type !== "reimbursement") {
return [];
}
const entityIds = Array.from(new Set((batch.items ?? []).map((item) => item.entity_id))).sort();
if (!entityIds.length) {
return [];
}
const sources = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
select: { id: true, department_id: true },
});
if (sources.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
}
return sources.map((source) => source.department_id ?? null);
}
async function ensureBatchSourceDepartmentsEnabled(fastify, tx, batch) {
if (batch.entity_type !== "reimbursement") {
return;
}
const departmentIds = await loadBatchSourceDepartmentIds(tx, batch);
await ensurePaymentBatchDepartmentsEnabled(fastify, tx, departmentIds);
}
function ensureBatchManagePermission(fastify, request) {
if (!fastify.hasPermission(request.employee, "payment.batch.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensureBatchReadPermission(fastify, request) {
if (
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
!fastify.hasPermission(request.employee, "risk.review.manage") &&
!fastify.hasPermission(request.employee, "payment.batch.review")
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensureBatchReviewPermission(fastify, request) {
const roleCodes = request.employee?.roleCodes ?? [];
const hasBusinessReviewerRole = roleCodes.includes("manager") || roleCodes.includes("ceo");
if (!fastify.hasPermission(request.employee, "payment.batch.review") || !hasBusinessReviewerRole) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
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);
}
function employeeHasRolePermission(employee, permissionCode) {
return hasPermission(resolvePermissionsForRoles(roleCodesOf(employee)), permissionCode);
}
async function resolvePaymentReviewRecipientIds(tx, makerId) {
const candidates = await tx.employee.findMany({
where: {
status: "active",
id: { not: makerId },
roles: { some: { role_code: { in: ["manager", "ceo"] } } },
},
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: {
id: true,
roles: { select: { role_code: true } },
},
});
const eligible = candidates.filter((employee) =>
employee.id !== makerId && employeeHasRolePermission(employee, "payment.batch.review"),
);
const chiefExecutive = eligible.find((employee) => roleCodesOf(employee).includes("ceo"));
if (chiefExecutive) {
return [chiefExecutive.id];
}
const manager = eligible.find((employee) => roleCodesOf(employee).includes("manager"));
return manager ? [manager.id] : [];
}
async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) {
const candidates = await tx.employee.findMany({
where: {
status: "active",
id: { not: reviewerId },
OR: [
{ id: makerId },
{ roles: { some: { role_code: { in: ["finance", "finance_mgr"] } } } },
],
},
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: {
id: true,
roles: { select: { role_code: true } },
},
});
const eligible = candidates.filter((employee) =>
employee.id !== reviewerId && employeeHasRolePermission(employee, "payment.batch.manage"),
);
const activeMaker = eligible.find((employee) => employee.id === makerId);
if (activeMaker) {
return [activeMaker.id];
}
return eligible
.filter((employee) => roleCodesOf(employee).some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)))
.map((employee) => employee.id);
}
async function createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type,
title,
body,
batchId,
actorId,
}) {
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds,
type,
title,
body,
targetType: "payment_batch",
targetId: batchId,
});
return {
notifications,
type,
title,
body,
targetType: "payment_batch",
targetId: batchId,
targetPath: `/payment-batches?id=${encodeURIComponent(batchId)}`,
actorId,
};
}
async function dispatchPaymentResponsibilityNotification(fastify, request, task) {
if (!task?.notifications?.length) {
return;
}
try {
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: [task],
});
} catch (error) {
fastify.log.error(
{
err: error,
requestId: request.id,
notificationType: task.type,
targetType: task.targetType,
targetId: task.targetId,
},
"Failed to dispatch payment responsibility notification",
);
}
}
function parsePaymentBatchDailySequence(batchNo, compactDate) {
const match = new RegExp(`^PB-${compactDate}-([0-9]{3})$`).exec(batchNo ?? "");
return match ? Number.parseInt(match[1], 10) : 0;
}
async function allocatePaymentBatchNumber(tx, timeZone) {
const businessDate = currentDateString(timeZone ?? "Asia/Shanghai");
const compactDate = businessDate.replaceAll("-", "");
const dateLockKey = Number.parseInt(compactDate, 10);
const batchNoPrefix = `PB-${compactDate}-`;
const batchNoPattern = `^PB-${compactDate}-[0-9]{3}$`;
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(
${ADVISORY_LOCK_NAMESPACES.paymentBatchDailySequence}::int,
${dateLockKey}::int
)
`;
const rows = await tx.$queryRaw`
SELECT "batch_no"
FROM "payment_batches"
WHERE "batch_no" LIKE ${`${batchNoPrefix}%`}
AND "batch_no" ~ ${batchNoPattern}
ORDER BY "batch_no" DESC
LIMIT 1
`;
const nextSequence = parsePaymentBatchDailySequence(rows?.[0]?.batch_no, compactDate) + 1;
if (nextSequence > PAYMENT_BATCH_DAILY_SEQUENCE_MAX) {
throw buildValidationError({ batchNo: "daily_sequence_exhausted" }, 409);
}
const sequence = String(nextSequence).padStart(3, "0");
return {
batchNo: `${batchNoPrefix}${sequence}`,
businessDate,
sequence,
};
}
function buildDefaultBatchName(businessDate, sequence) {
return `${businessDate} 付款批次 ${sequence}`;
}
function summarizeBatchRisk(batch) {
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
const totalAmount = toMoneyString(totalAmountValue);
const highRisk = totalAmountValue >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT;
const completedReviewCount = batch.reviewed_at ? 1 : 0;
const requiredReviewCount = 1;
const nextStep = completedReviewCount >= requiredReviewCount
? "ready_for_execute"
: "first_review";
return {
totalAmount,
highRisk,
reviewRequirement: {
thresholdAmount: toMoneyString(HIGH_RISK_BATCH_THRESHOLD_AMOUNT),
requiredReviewCount,
completedReviewCount,
nextStep,
makerCheckerRequired: true,
reviewerPermission: "payment.batch.review",
},
};
}
async function loadBatchWithItems(tx, batchId) {
const batch = await tx.paymentBatch.findUnique({
where: { id: batchId },
include: {
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
},
_count: {
select: { receipt_attachments: { where: { archived_at: null } } },
},
},
});
if (!batch) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return batch;
}
async function lockBatchWithItems(tx, batchId) {
const rows = await tx.$queryRaw`
SELECT id
FROM "payment_batches"
WHERE id = ${batchId}::uuid
FOR UPDATE
`;
if (!Array.isArray(rows) || rows.length === 0) {
throw buildNotFoundError();
}
return loadBatchWithItems(tx, batchId);
}
async function lockPaymentEntities(tx, entityType, entityIds) {
const sortedIds = Array.from(new Set(entityIds)).sort();
if (!sortedIds.length) return;
if (entityType === "reimbursement") {
await tx.$queryRaw(Prisma.sql`
SELECT id
FROM "reimbursement_forms"
WHERE id IN (${Prisma.join(sortedIds)})
ORDER BY id
FOR UPDATE
`);
return;
}
await tx.$queryRaw(Prisma.sql`
SELECT id
FROM "travel_orders"
WHERE id IN (${Prisma.join(sortedIds)})
ORDER BY id
FOR UPDATE
`);
}
function buildPayeeSnapshot(config, owner, options = {}) {
const encryptedAccount = owner?.bank_account_encrypted;
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,
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 } : {}),
};
}
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
select: {
id: true,
form_number: true,
description: true,
total_amount: true,
status: true,
department_id: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
owner: {
select: {
name: true,
legal_entity_id: true,
bank_name: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
},
},
});
return items.map((item) => ({
entityId: item.id,
entityCode: item.form_number ?? item.id,
entityTitle: item.description || item.form_number || item.id,
amount: toMoneyString(item.total_amount),
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,
departmentId: item.department_id ?? null,
paymentVoucherNo: item.payment_voucher_no ?? null,
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, { includeEncrypted: true }),
},
}));
}
const items = await tx.travelOrder.findMany({
where: { id: { in: entityIds } },
select: {
id: true,
source: true,
external_order_no: true,
amount: true,
currency: true,
status: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
},
});
return items.map((item) => ({
entityId: item.id,
entityCode: item.external_order_no,
entityTitle: `${item.source}:${item.external_order_no}`,
amount: toMoneyString(item.amount),
currencyCode: item.currency,
status: item.status,
snapshotJson: {
source: item.source,
externalOrderNo: item.external_order_no,
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
},
}));
}
function ensureBatchEntityStatus(entityType, snapshot) {
if (
entityType === "reimbursement" &&
(snapshot.status !== "approved" || snapshot.snapshotJson?.paidAt)
) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
}
if (
entityType === "travel_order" &&
(snapshot.status !== "confirmed" || snapshot.snapshotJson?.paidAt)
) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
}
}
function normalizeSnapshotText(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
const compactDigits = normalized.replace(/[\s-]/g, "");
if (
!normalized ||
normalized.length > 100 ||
normalized.startsWith("v1:") ||
/^\d{9,32}$/.test(compactDigits)
) return null;
return normalized;
}
function normalizeMaskedBankAccount(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 64) return null;
if (/^\*{1,8}$/.test(normalized)) return normalized;
return /^[0-9A-Za-z]{4}\*+[0-9A-Za-z]{4}$/.test(normalized) ? normalized : null;
}
function readSnapshotPayee(snapshotJson) {
const payee = snapshotJson?.payee;
if (!payee || typeof payee !== "object" || Array.isArray(payee)) {
return null;
}
const bankAccountMasked = normalizeMaskedBankAccount(payee.bankAccountMasked);
const name = normalizeSnapshotText(payee.name);
const bankName = normalizeSnapshotText(payee.bankName);
if (
(payee.name != null && !name) ||
(payee.bankName != null && !bankName) ||
(payee.bankAccountMasked != null && !bankAccountMasked)
) {
return null;
}
return {
name,
bankName,
bankAccountMasked,
};
}
async function loadLegacyPayees(tx, batch, config) {
if (batch.entity_type !== "reimbursement") {
return new Map();
}
const entityIds = (batch.items ?? [])
.filter((item) => !readSnapshotPayee(item.snapshot_json))
.map((item) => item.entity_id);
if (!entityIds.length) {
return new Map();
}
const reimbursements = await tx.reimbursementForm.findMany({
where: { id: { in: Array.from(new Set(entityIds)) } },
select: {
id: true,
owner: {
select: {
name: true,
bank_name: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
},
},
});
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.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 (!batch.payment_account_snapshot_json.accountNumberEncrypted) add("payer_account_unavailable");
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 (resolveItemPaymentChannel(batch, item) !== "bank") add("bank_channel_required", entityCode);
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);
const safePayee = readSnapshotPayee(item.snapshot_json);
if (!safePayee?.name) add("payee_name_missing", entityCode);
if (!safePayee?.bankName) add("payee_bank_name_missing", entityCode);
if (!safePayee?.bankAccountMasked) add("payee_account_masked_missing", entityCode);
}
return blockers;
}
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
const includePayee = options.includePayee === true;
const batchRisk = summarizeBatchRisk(batch);
const riskMap = new Map();
for (const item of openRiskFlags) {
const entityId = item.entityId;
if (!riskMap.has(entityId)) {
riskMap.set(entityId, []);
}
riskMap.get(entityId).push(item);
}
const paymentOrderExportBlockersValue = paymentOrderExportBlockers(batch);
return {
id: batch.id,
batchNo: batch.batch_no,
entityType: batch.entity_type,
name: batch.name,
status: batch.status,
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
defaultPaymentChannel: batch.default_payment_channel ?? (batch.entity_type === "reimbursement" ? "bank" : null),
voucherPrefix: batch.voucher_prefix ?? null,
totalAmount: batchRisk.totalAmount,
highRisk: batchRisk.highRisk,
reviewRequirement: batchRisk.reviewRequirement,
reviewComment: batch.review_comment ?? null,
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,
reviewedBy: batch.reviewed_by ?? null,
secondaryReviewedAt: batch.secondary_reviewed_at?.toISOString?.() ?? null,
secondaryReviewedBy: batch.secondary_reviewed_by ?? null,
executedAt: batch.executed_at?.toISOString?.() ?? null,
executedBy: batch.executed_by ?? null,
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
cancelledBy: batch.cancelled_by ?? null,
cancelledReason: batch.cancelled_reason ?? null,
items: (batch.items ?? []).map((item) => {
const payee = includePayee
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
: {};
return {
id: item.id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null,
payeeName: payee.name ?? null,
bankName: payee.bankName ?? null,
bankAccountMasked: payee.bankAccountMasked ?? null,
status: item.status,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
openRiskFlags: riskMap.get(item.entity_id) ?? [],
};
}),
};
}
function isoDateTimeOrNull(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function buildPaymentBatchProcessingProgress(batch, actorNames) {
const semantics = buildPaymentProcessingSemantics({
status: batch.status,
createdAt: batch.created_at,
reviewedAt: batch.reviewed_at,
executedAt: batch.executed_at,
cancelledAt: batch.cancelled_at,
cancelledReason: batch.cancelled_reason,
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
});
const actorIds = {
creator: batch.created_by,
reviewer: batch.reviewed_by,
executor: batch.executed_by,
canceller: batch.cancelled_by,
};
return {
stages: [semantics.created, semantics.review, semantics.payment].map((stage) => ({
key: stage.key,
label: stage.label,
state: stage.state,
actorName: stage.actorSource ? actorNames.get(actorIds[stage.actorSource]) ?? null : null,
occurredAt: isoDateTimeOrNull(stage.occurredAt),
result: stage.result,
comment: stage.comment,
detail: stage.detail,
targetHref: null,
})),
};
}
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
const legacyPayees = await loadLegacyPayees(tx, batch, config);
const actorIds = Array.from(new Set([
batch.created_by,
batch.reviewed_by,
batch.executed_by,
batch.cancelled_by,
].filter(Boolean)));
const actors = actorIds.length
? await tx.employee.findMany({
where: { id: { in: actorIds } },
select: { id: true, name: true },
})
: [];
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
return {
...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true }),
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames),
};
}
async function listOpenRiskFlagsForBatch(tx, batch) {
const entityIds = batch.items.map((item) => item.entity_id);
if (!entityIds.length) {
return [];
}
const items = await tx.entityRiskFlag.findMany({
where: {
entity_type: batch.entity_type,
entity_id: { in: entityIds },
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return items.map(buildPersistedRiskFlag);
}
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request, timeZone }) {
const current = await tx.reimbursementForm.findUnique({
where: { id: item.entity_id },
select: {
id: true,
status: true,
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
total_amount: true,
},
});
if (!current) {
throw buildNotFoundError();
}
if (current.status === "paid") {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
}
if (current.status !== "approved") {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
}
if (toMoneyString(current.total_amount) !== toMoneyString(item.amount)) {
throw buildValidationError({ payment: "source_amount_changed" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
?? currentDateString(timeZone ?? "Asia/Shanghai");
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? "bank";
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
data: {
status: "archived",
archived_reason: "used_complete",
archived_at: now,
updated_by: actorId,
},
});
await tx.reimbursementForm.update({
where: { id: item.entity_id },
data: {
status: "paid",
payment_voucher_no: voucher,
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
payment_channel: paymentChannel,
paid_at: now,
paid_by: actorId,
updated_by: actorId,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: current.id,
extra: {
formNumber: current.form_number ?? null,
paymentVoucherNo: voucher,
paymentDate,
paymentChannel,
paymentNote: null,
idempotent: false,
archivedInvoiceCount: archivedResult.count,
source: "payment_batch",
paymentBatchId: batch.id,
paymentBatchItemId: item.id,
},
}),
});
return { idempotent: false };
}
async function fetchPaidReimbursementForFollowUp(prisma, reimbursementId) {
return prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
status: true,
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
owner: {
select: {
legal_entity: {
select: {
code: true,
},
},
},
},
submitter: {
select: {
legal_entity: {
select: {
code: true,
},
},
},
},
},
});
}
async function processPaidReimbursementFollowUps({ fastify, request, reimbursementId }) {
const reimbursement = await fetchPaidReimbursementForFollowUp(fastify.prisma, reimbursementId);
if (!reimbursement || reimbursement.status !== "paid") {
return;
}
const departmentId = reimbursement.department_id ?? request.employee.department_id ?? null;
const title = `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`;
const body = `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`;
const targetPath = `/reimbursements/${reimbursementId}`;
try {
const erpGate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (erpGate.enabled) {
await enqueueReimbursementJournalExport(fastify.prisma, fastify.config, {
reimbursement,
actorId: request.employee.id,
});
} else {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
targetType: "reimbursement_form",
targetId: reimbursement.id,
extra: {
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
reason: erpGate.reason,
source: "payment_batch",
},
}),
});
}
} catch (error) {
fastify.log.error(
{ err: error, reimbursementId },
"Failed to enqueue reimbursement ERP follow-up after payment batch execution",
);
}
try {
const recipients = Array.from(new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)));
const notifications = await createNotificationsWithRecords(fastify.prisma, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.reimbursementPaid,
title,
body,
targetType: "reimbursement_form",
targetId: reimbursementId,
});
for (const recipientEmployeeId of recipients) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
type: NOTIFICATION_TYPES.reimbursementPaid,
recipientEmployeeIds: [recipientEmployeeId],
subject: title,
body,
targetPath,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: [{
notifications,
type: NOTIFICATION_TYPES.reimbursementPaid,
title,
body,
targetType: "reimbursement_form",
targetId: reimbursementId,
targetPath,
actorId: request.employee.id,
}],
});
} catch (error) {
fastify.log.error(
{ err: error, reimbursementId },
"Failed to dispatch reimbursement paid notifications after payment batch execution",
);
}
}
async function executeTravelOrderPayment(tx, { batch, item, actorId, index, timeZone }) {
const current = await tx.travelOrder.findUnique({
where: { id: item.entity_id },
select: {
id: true,
status: true,
amount: true,
currency: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
},
});
if (!current) {
throw buildNotFoundError();
}
if (current.status === "paid") {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
}
if (current.status !== "confirmed") {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
}
if (toMoneyString(current.amount) !== toMoneyString(item.amount) || current.currency !== item.currency_code) {
throw buildValidationError({ payment: "source_amount_changed" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
?? currentDateString(timeZone ?? "Asia/Shanghai");
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
const matchedTransaction = await tx.cardTransaction.findFirst({
where: {
travel_order_id: item.entity_id,
txn_type: "charge",
status: "settled",
amount: current.amount,
currency: current.currency,
},
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: { id: true },
});
if (!matchedTransaction) {
throw buildValidationError({ payment: "missing_settled_transaction" }, 409);
}
await tx.travelOrder.update({
where: { id: item.entity_id },
data: {
status: "paid",
payment_voucher_no: voucher,
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
payment_channel: paymentChannel,
paid_at: new Date(),
paid_by: actorId,
updated_by: actorId,
},
});
return { idempotent: false };
}
function buildNotFoundError() {
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
class ReceiptAttachmentSizeLimitError extends Error {
constructor() {
super("receipt_attachment_size_limit");
this.name = "ReceiptAttachmentSizeLimitError";
}
}
function resolveReceiptAttachmentMaxBytes(config) {
const configured = Number(config?.maxUploadSizeBytes);
return Number.isSafeInteger(configured) && configured > 0 ? configured : 10 * 1024 * 1024;
}
function destroyStream(stream) {
if (typeof stream?.destroy === "function" && !stream.destroyed) {
try {
stream.destroy();
return;
} catch {
// Continue with Web Stream cancellation when available.
}
}
if (typeof stream?.cancel === "function") {
try {
Promise.resolve(stream.cancel()).catch(() => {});
} catch {
// Cleanup must not replace the original validation failure.
}
}
}
async function readBoundedStream(stream, maxBytes) {
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += bufferChunk.length;
if (size > maxBytes) {
destroyStream(stream);
throw new ReceiptAttachmentSizeLimitError();
}
chunks.push(bufferChunk);
}
} catch (error) {
if (error instanceof ReceiptAttachmentSizeLimitError) {
throw error;
}
destroyStream(stream);
throw error;
}
if (stream?.truncated) {
destroyStream(stream);
throw new ReceiptAttachmentSizeLimitError();
}
return Buffer.concat(chunks, size);
}
function buildReceiptAttachmentBlockedError(reasonCode) {
return new AppError({
code: "VALID_001",
message: "付款回单未通过安全校验",
statusCode: 409,
details: { receiptAttachment: "security_validation_failed", reason: reasonCode },
});
}
async function auditBlockedReceiptDownload({ fastify, request, batchId, attachment, reasonCode, downloadReason }) {
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DOWNLOAD_BLOCKED",
targetType: "payment_batch",
targetId: batchId,
extra: {
attachmentId: attachment?.id ?? null,
attachmentType: attachment?.attachment_type ?? ATTACHMENT_TYPE.receipt,
reasonCode,
reason: downloadReason,
},
}),
});
} catch {
fastify.log.error(
{ requestId: request.id, batchId, attachmentId: attachment?.id ?? null, errorCode: "receipt_blocked_audit_failed" },
"Payment receipt blocked-download audit failed",
);
}
}
async function cleanupStoredReceiptObjects(fastify, storagePaths, requestId, batchId) {
for (const storagePath of storagePaths) {
try {
await deleteObject({ config: fastify.config, storagePath });
} catch {
fastify.log.error(
{ requestId, batchId, errorCode: "receipt_storage_compensation_failed" },
"Payment receipt storage compensation failed",
);
}
}
}
async function readReceiptUploadFiles(fastify, request) {
const files = [];
const maxBytes = resolveReceiptAttachmentMaxBytes(fastify.config);
try {
for await (const part of request.parts({ preservePath: true })) {
if (part.type === "field") {
continue;
}
if (part.type !== "file") {
continue;
}
const buffer = await readBoundedStream(part.file, maxBytes);
if (part.fieldname !== "files") {
throw buildValidationError({ file: "invalid_field" });
}
files.push(await validateAndNormalizePurchaseAttachment({
buffer,
fileName: part.filename,
declaredMimeType: part.mimetype,
maxBytes,
normalizeImages: true,
}));
}
} catch (error) {
if (error instanceof ReceiptAttachmentSizeLimitError || error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
}
if (error?.code === "FST_FILES_LIMIT") {
throw buildValidationError({ file: "too_many_files" });
}
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
throw buildValidationError({ file: "multipart_limit_exceeded" });
}
if (error instanceof PurchaseAttachmentPolicyError) {
throw buildValidationError(error.details ?? { file: error.reasonCode });
}
throw error;
}
if (!files.length) {
throw buildValidationError({ file: "missing" });
}
return files;
}
async function paymentBatchesRoutes(fastify) {
fastify.get("/payment-batches/candidates", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const parsed = CANDIDATES_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
const { q, page, pageSize } = parsed.data;
const reservedItems = await fastify.prisma.paymentBatchItem.findMany({
where: {
payment_batch: {
entity_type: "reimbursement",
status: { not: "cancelled" },
},
},
distinct: ["entity_id"],
select: { entity_id: true },
});
const reservedIds = reservedItems.map((item) => item.entity_id);
const where = {
status: "approved",
paid_at: null,
...(reservedIds.length ? { id: { notIn: reservedIds } } : {}),
...(q
? {
OR: [
{ form_number: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
{ owner: { is: { name: { contains: q, mode: "insensitive" } } } },
{ owner: { is: { employee_no: { contains: q, mode: "insensitive" } } } },
],
}
: {}),
};
const allCandidates = await fastify.prisma.reimbursementForm.findMany({
where,
select: {
id: true,
form_number: true,
description: true,
reimbursement_date: true,
total_amount: true,
department_id: true,
owner: {
select: { id: true, employee_no: true, name: true },
},
},
orderBy: [{ reimbursement_date: "desc" }, { id: "desc" }],
});
const departmentGateCache = new Map();
const eligibleCandidates = [];
for (const candidate of allCandidates) {
const departmentId = candidate.department_id ?? null;
const cacheKey = departmentId ?? "__missing_department__";
let enabled = departmentGateCache.get(cacheKey);
if (enabled == null) {
const result = await evaluatePaymentBatchDepartments(fastify, fastify.prisma, [departmentId]);
enabled = result.enabled;
departmentGateCache.set(cacheKey, enabled);
}
if (enabled) {
eligibleCandidates.push(candidate);
}
}
const total = eligibleCandidates.length;
const candidates = eligibleCandidates.slice((page - 1) * pageSize, page * pageSize);
return sendData(reply, request, {
items: candidates.map((item) => ({
entityId: item.id,
entityType: "reimbursement",
businessNo: item.form_number ?? "待编号报销单",
employee: {
id: item.owner.id,
employeeNo: item.owner.employee_no,
name: item.owner.name,
},
amount: toMoneyString(item.total_amount),
currencyCode: "CNY",
businessDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null,
summary: item.description ?? null,
})),
page,
pageSize,
total,
});
});
fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const queryParsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
const query = queryParsed.data;
const items = await fastify.prisma.paymentBatch.findMany({
where: {
...(query.entityType ? { entity_type: query.entityType } : {}),
...(query.status ? { status: query.status } : {}),
},
include: {
items: { orderBy: [{ created_at: "asc" }, { id: "asc" }] },
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const mapped = [];
for (const batch of items) {
if (batch.entity_type === "reimbursement") {
const departmentIds = await loadBatchSourceDepartmentIds(fastify.prisma, batch);
const rollout = await evaluatePaymentBatchDepartments(fastify, fastify.prisma, departmentIds);
if (!rollout.enabled) {
continue;
}
}
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
mapped.push(mapBatch(batch, openRiskFlags));
}
return sendData(reply, request, { items: mapped });
});
fastify.post("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const body = parseBody(CREATE_SCHEMA, request.body);
if (body.entityType !== "reimbursement") {
throw buildValidationError({ entityType: "travel_payment_frozen" }, 409);
}
const entityIds = Array.from(new Set(body.entityIds)).sort();
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
if (snapshots.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
}
await ensurePaymentBatchDepartmentsEnabled(
fastify,
tx,
snapshots.map((snapshot) => snapshot.departmentId ?? null),
);
for (const snapshot of snapshots) {
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 },
payment_batch: {
entity_type: body.entityType,
status: { not: "cancelled" },
},
},
select: { entity_id: true },
});
if (reservedItems.length) {
throw buildValidationError({ entityIds: "contains_active_payment_batch" }, 409);
}
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
if (!recipientEmployeeIds.length) {
throw buildValidationError({ review: "no_eligible_reviewer" }, 409);
}
const allocatedNumber = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone);
const batch = await tx.paymentBatch.create({
data: {
batch_no: allocatedNumber.batchNo,
entity_type: body.entityType,
name: body.name ?? buildDefaultBatchName(allocatedNumber.businessDate, allocatedNumber.sequence),
status: "draft",
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,
},
});
for (const snapshot of snapshots) {
await tx.paymentBatchItem.create({
data: {
payment_batch_id: batch.id,
entity_id: snapshot.entityId,
entity_code: snapshot.entityCode,
entity_title: snapshot.entityTitle,
amount: snapshot.amount,
currency_code: snapshot.currencyCode,
status: "pending",
snapshot_json: snapshot.snapshotJson,
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_CREATED",
targetType: "payment_batch",
targetId: batch.id,
extra: {
entityType: body.entityType,
entityCount: snapshots.length,
totalAmount: toMoneyString(snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0)),
highRisk: snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0) >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT,
},
}),
});
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
title: "待付款复核",
body: "付款安排已创建,请完成付款复核。",
batchId: batch.id,
actorId: request.employee.id,
});
return {
batch: await loadBatchWithItems(tx, batch.id),
notificationTask: nextNotificationTask,
};
});
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
});
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
});
});
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);
const blockers = paymentOrderExportBlockers(batch);
if (blockers.length) {
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
}
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);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const items = await fastify.prisma.paymentBatchReceiptAttachment.findMany({
where: { payment_batch_id: batchId },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
});
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const files = await readReceiptUploadFiles(fastify, request);
const storedFiles = [];
let createdItems;
try {
for (const file of files) {
const storagePath = await writeBuffer({
config: fastify.config,
key: path.posix.join(
"payment-batch-receipts",
batchId,
`${crypto.randomUUID()}${file.extension}`,
),
buffer: file.buffer,
contentType: file.mimeType,
});
storedFiles.push({ ...file, storagePath });
}
createdItems = await fastify.prisma.$transaction(async (tx) => {
const lockedBatch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
if (!["reviewed", "failed"].includes(lockedBatch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const created = [];
for (const file of storedFiles) {
created.push(await tx.paymentBatchReceiptAttachment.create({
data: {
payment_batch_id: batchId,
storage_path: file.storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: file.fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData({}, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
attachmentType: ATTACHMENT_TYPE.receipt,
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
}),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
}));
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_UPLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: {
attachmentIds: created.map((item) => item.id),
attachmentCount: created.length,
attachmentTypes: [...new Set(created.map((item) => item.attachment_type))],
},
}),
});
return created;
});
} catch (error) {
await cleanupStoredReceiptObjects(
fastify,
storedFiles.map((file) => file.storagePath),
request.id,
batchId,
);
throw error;
}
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
});
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchReadPermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const maxBytes = resolveReceiptAttachmentMaxBytes(fastify.config);
const attachment = await fastify.prisma.paymentBatchReceiptAttachment.findFirst({
where: {
id: attachmentId,
payment_batch_id: batchId,
},
select: {
id: true,
storage_path: true,
original_name: true,
mime_type: true,
file_size: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
archived_at: true,
},
});
if (!attachment) {
throw buildNotFoundError();
}
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "voided" }, 409);
}
const downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
if (attachment.governance_status === "quarantined") {
await auditBlockedReceiptDownload({
fastify,
request,
batchId,
attachment,
reasonCode: "quarantined",
downloadReason,
});
throw buildReceiptAttachmentBlockedError("quarantined");
}
const recordedSize = Number(attachment.file_size);
if (Number.isFinite(recordedSize) && recordedSize > maxBytes) {
await auditBlockedReceiptDownload({
fastify,
request,
batchId,
attachment,
reasonCode: "size_limit",
downloadReason,
});
throw buildReceiptAttachmentBlockedError("size_limit");
}
let opened;
try {
opened = await openReadStream({
config: fastify.config,
storagePath: attachment.storage_path,
});
} catch (error) {
if (error instanceof StorageNotFoundError) {
await auditBlockedReceiptDownload({
fastify,
request,
batchId,
attachment,
reasonCode: "missing_object",
downloadReason,
});
throw buildNotFoundError();
}
throw error;
}
let storedBuffer;
try {
storedBuffer = await readBoundedStream(opened.stream, maxBytes);
} catch (error) {
if (error instanceof ReceiptAttachmentSizeLimitError) {
await auditBlockedReceiptDownload({
fastify,
request,
batchId,
attachment,
reasonCode: "size_limit",
downloadReason,
});
throw buildReceiptAttachmentBlockedError("size_limit");
}
throw error;
}
let validated;
try {
validated = await validateAndNormalizePurchaseAttachment({
buffer: storedBuffer,
fileName: attachment.original_name,
declaredMimeType: attachment.mime_type,
maxBytes,
normalizeImages: false,
});
} catch (error) {
if (!(error instanceof PurchaseAttachmentPolicyError)) {
throw error;
}
await auditBlockedReceiptDownload({
fastify,
request,
batchId,
attachment,
reasonCode: error.reasonCode,
downloadReason,
});
throw buildReceiptAttachmentBlockedError(error.reasonCode);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.receipt,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
reply.type("application/octet-stream");
reply.header(
"content-disposition",
buildAttachmentContentDisposition(validated.originalName),
);
reply.header("x-content-type-options", "nosniff");
reply.header("cache-control", "private, no-store, max-age=0");
reply.header("pragma", "no-cache");
reply.header("referrer-policy", "no-referrer");
reply.header(
"content-security-policy",
"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
);
reply.header("cross-origin-resource-policy", "same-origin");
return reply.send(validated.buffer);
});
fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
parseId(request.params?.id);
parseId(request.params?.attachmentId, "attachmentId");
throw buildValidationError({ receiptAttachment: "void_required" }, 409);
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const { reason } = parseBody(REASON_SCHEMA, request.body);
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
where: { id: attachmentId, payment_batch_id: batchId },
});
if (!attachment) {
throw buildNotFoundError();
}
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "already_voided" }, 409);
}
const voidedAt = new Date();
await tx.paymentBatchReceiptAttachment.update({
where: { id: attachmentId },
data: {
governance_status: GOVERNANCE_STATUS.archived,
archived_at: voidedAt,
archived_by: request.employee.id,
archive_note: reason,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_VOIDED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId, reason },
}),
});
return { attachmentId, voidedAt };
});
return sendData(reply, request, {
attachmentId: result.attachmentId,
outcome: "voided",
voidedAt: result.voidedAt.toISOString(),
});
});
fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const itemId = parseId(request.params?.itemId, "itemId");
const body = parseBody(PATCH_ITEM_SCHEMA, request.body);
const updated = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
throw buildNotFoundError();
}
if (!["pending", "failed"].includes(item.status)) {
throw buildValidationError({ item: "only_open_item_editable" }, 409);
}
const before = {
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
};
const next = await tx.paymentBatchItem.update({
where: { id: itemId },
data: {
...(body.paymentVoucherNoOverride !== undefined
? { payment_voucher_no_override: normalizeNullableString(body.paymentVoucherNoOverride) }
: {}),
...(body.paymentDateOverride !== undefined
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
: {}),
},
});
const after = {
paymentVoucherNoOverride: next.payment_voucher_no_override ?? null,
paymentDateOverride: next.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
};
if (JSON.stringify(before) !== JSON.stringify(after)) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_ITEM_METADATA_UPDATED",
targetType: "payment_batch",
targetId: batchId,
extra: { itemId, entityCode: item.entity_code, before, after },
}),
});
}
return next;
});
return sendData(reply, request, {
item: {
id: updated.id,
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
},
});
});
fastify.post("/payment-batches/:id/review", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReviewPermission(fastify, request);
const batchId = parseId(request.params?.id);
const body = parseBody(REVIEW_SCHEMA, request.body);
const { batch: result, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
const batchRisk = summarizeBatchRisk(batch);
const isFirstReview = batch.status === "draft" && !batch.reviewed_at;
if (!isFirstReview) {
throw buildValidationError({ review: "batch_not_reviewable" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
}
if (!batch.created_by) {
throw buildValidationError({ review: "maker_identity_missing" }, 409);
}
if (batch.created_by === request.employee.id) {
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
where: {
entity_type: batch.entity_type,
entity_id: { in: batch.items.map((item) => item.entity_id) },
status: "open",
},
});
const resolveIds = new Set(body.resolveRiskFlagIds ?? []);
const unresolved = openRiskFlags.filter((item) => !resolveIds.has(item.id));
if (unresolved.length) {
throw buildValidationError({ resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409);
}
if (openRiskFlags.length) {
await tx.entityRiskFlag.updateMany({
where: { id: { in: openRiskFlags.map((item) => item.id) } },
data: {
status: "resolved",
resolved_at: new Date(),
resolved_by: request.employee.id,
},
});
}
const reviewedAt = new Date();
const reviewed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: "reviewed",
review_comment: normalizeNullableString(body.comment),
reviewed_at: reviewedAt,
reviewed_by: request.employee.id,
updated_by: request.employee.id,
},
include: {
items: true,
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_REVIEWED",
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
resolvedRiskFlagCount: openRiskFlags.length,
reviewStage: "single_review",
},
}),
});
const recipientEmployeeIds = await resolvePaymentExecutionRecipientIds(tx, {
makerId: batch.created_by,
reviewerId: request.employee.id,
});
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
title: "待完成付款",
body: "付款复核已通过,请完成付款并登记回单。",
batchId,
actorId: request.employee.id,
});
return {
batch: reviewed,
notificationTask: nextNotificationTask,
};
});
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
});
});
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const successfulReimbursementIds = [];
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
const batchRisk = summarizeBatchRisk(batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
}
if (batchRisk.reviewRequirement.completedReviewCount < batchRisk.reviewRequirement.requiredReviewCount) {
throw buildValidationError({ review: "batch_review_required" }, 409);
}
await lockPaymentEntities(
tx,
batch.entity_type,
batch.items.filter((item) => ["pending", "failed"].includes(item.status)).map((item) => item.entity_id),
);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
await ensureBankReceiptForExecutableBatch(tx, batch);
const finalItemStatuses = new Map(batch.items.map((item) => [item.id, item.status]));
let attemptSuccessCount = 0;
let attemptFailedCount = 0;
for (const [index, item] of batch.items.entries()) {
if (!["pending", "failed"].includes(item.status)) {
continue;
}
try {
if (batch.entity_type === "reimbursement") {
const paymentResult = await executeReimbursementPayment(tx, {
batch,
item,
actorId: request.employee.id,
index,
request,
timeZone: fastify.config.appTimezone,
});
if (!paymentResult.idempotent) {
successfulReimbursementIds.push(item.entity_id);
}
} else {
await executeTravelOrderPayment(tx, {
batch,
item,
actorId: request.employee.id,
index,
timeZone: fastify.config.appTimezone,
});
}
await tx.paymentBatchItem.update({
where: { id: item.id },
data: {
status: "executed",
failure_code: null,
failure_message: null,
},
});
finalItemStatuses.set(item.id, "executed");
attemptSuccessCount += 1;
} catch (error) {
const recoverableBusinessError =
error instanceof AppError &&
error.code === "VALID_001" &&
error.statusCode >= 400 &&
error.statusCode < 500;
if (!recoverableBusinessError) {
throw error;
}
if (
error.statusCode === 409 &&
["already_paid_conflict", "source_amount_changed"].includes(error.details?.payment)
) {
throw error;
}
const failureCode = error.code;
const failureMessage = error.message;
await tx.paymentBatchItem.update({
where: { id: item.id },
data: {
status: "failed",
failure_code: failureCode,
failure_message: failureMessage,
},
});
finalItemStatuses.set(item.id, "failed");
await ensureRiskFlag(tx, {
entityType: batch.entity_type,
entityId: item.entity_id,
riskCode: "payment_batch_execute_failed",
level: "high",
title: "支付批次执行失败",
detail: `${item.entity_code} 执行失败:${failureMessage}`,
source: "payment_batch",
actorId: request.employee.id,
payloadJson: {
batchId: batch.id,
itemId: item.id,
failureCode,
},
});
attemptFailedCount += 1;
}
}
const executionSummary = Array.from(finalItemStatuses.values()).reduce(
(summary, status) => {
if (status === "executed") summary.successCount += 1;
if (status === "failed") summary.failedCount += 1;
summary.attemptedCount = summary.successCount + summary.failedCount;
return summary;
},
{ successCount: 0, failedCount: 0, attemptedCount: 0 },
);
const attemptSummary = {
successCount: attemptSuccessCount,
failedCount: attemptFailedCount,
attemptedCount: attemptSuccessCount + attemptFailedCount,
};
const refreshed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: executionSummary.failedCount > 0 ? "failed" : "executed",
execution_summary_json: executionSummary,
executed_at: new Date(),
executed_by: request.employee.id,
updated_by: request.employee.id,
},
include: {
items: true,
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_EXECUTED",
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
totalAmount: batchRisk.totalAmount,
...attemptSummary,
summaryScope: "current_attempt",
retry: batch.status === "failed",
finalItemSummary: executionSummary,
},
}),
});
return refreshed;
});
for (const reimbursementId of new Set(successfulReimbursementIds)) {
await processPaidReimbursementFollowUps({
fastify,
request,
reimbursementId,
});
}
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
});
});
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const { reason } = parseBody(REASON_SCHEMA, request.body);
const cancelled = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, {
...batch,
items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)),
});
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "batch_not_cancellable" }, 409);
}
const openItemIds = batch.items
.filter((item) => ["pending", "failed"].includes(item.status))
.map((item) => item.id);
if (openItemIds.length) {
await tx.paymentBatchItem.updateMany({
where: { id: { in: openItemIds } },
data: { status: "cancelled" },
});
}
await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: "cancelled",
cancelled_at: new Date(),
cancelled_by: request.employee.id,
cancelled_reason: reason,
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_CANCELLED",
targetType: "payment_batch",
targetId: batchId,
extra: {
reason,
cancelledOpenItemCount: openItemIds.length,
executedItemCount: batch.items.filter((item) => item.status === "executed").length,
},
}),
});
return loadBatchWithItems(tx, batchId);
});
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
});
});
}
module.exports = paymentBatchesRoutes;