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;