const crypto = require("node:crypto"); const path = require("node:path"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { sha256Hex } = require("../utils/crypto"); const { compactDateString, parseDateOnlyString } = require("../utils/date"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { createNotificationsWithRecords } = require("../services/notifications"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); 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 { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { ATTACHMENT_TYPE, DOWNLOAD_POLICY, SENSITIVITY_LEVEL, governanceCreateData, mapGovernanceFields, parseMultipartGovernanceField, 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 LIST_QUERY_SCHEMA = z.object({ entityType: ENTITY_TYPE_SCHEMA.optional(), status: z.enum(["draft", "reviewed", "executed", "failed", "cancelled"]).optional(), }); const CREATE_SCHEMA = z.object({ entityType: ENTITY_TYPE_SCHEMA, name: z.string().trim().min(1).max(120), 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(), paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.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 HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000; const RECEIPT_ATTACHMENT_MIME_ALLOWLIST = new Set([ "application/pdf", "image/jpeg", "image/png", "image/webp", ]); 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 sanitizeAttachmentFilename(fileName) { if (!fileName) return "file"; return fileName.replace(/[\r\n"]/g, "_"); } 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, }; } function resolveItemPaymentChannel(batch, item) { return item.payment_channel_override ?? batch.default_payment_channel ?? null; } async function ensureBankReceiptForExecutableBatch(tx, batch) { const requiresBankReceipt = (batch.items ?? []).some((item) => ["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank", ); if (!requiresBankReceipt) { return; } const receiptCount = await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id }, }); 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; } 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") ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } } function ensureBatchReviewPermission(fastify, request) { if (!fastify.hasPermission(request.employee, "risk.review.manage")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } } function buildBatchNo() { const now = new Date(); const date = now.toISOString().slice(0, 10).replace(/-/g, ""); const time = now.toISOString().slice(11, 19).replace(/:/g, ""); return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`; } 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.secondary_reviewed_at ? 2 : batch.reviewed_at ? 1 : 0; const requiredReviewCount = highRisk ? 2 : 1; const nextStep = completedReviewCount >= requiredReviewCount ? "ready_for_execute" : completedReviewCount === 0 ? "first_review" : "second_review"; return { totalAmount, highRisk, reviewRequirement: { thresholdAmount: toMoneyString(HIGH_RISK_BATCH_THRESHOLD_AMOUNT), requiredReviewCount, completedReviewCount, nextStep, makerCheckerRequired: true, reviewerPermission: "risk.review.manage", }, }; } async function loadBatchWithItems(tx, batchId) { const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: { orderBy: [{ created_at: "asc" }, { id: "asc" }], }, }, }); if (!batch) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return batch; } async function loadEntitySnapshots(tx, entityType, entityIds) { 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, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: 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, snapshotJson: { formNumber: item.form_number ?? null, description: item.description ?? 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, }, })); } 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" && !["approved", "paid"].includes(snapshot.status)) { throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409); } if (entityType === "travel_order" && !["confirmed", "paid"].includes(snapshot.status)) { throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409); } } function mapBatch(batch, openRiskFlags = []) { 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); } 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 ?? 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, 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, items: (batch.items ?? []).map((item) => ({ 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, status: item.status, failureCode: item.failure_code ?? null, failureMessage: item.failure_message ?? null, snapshotJson: item.snapshot_json, createdAt: item.created_at?.toISOString?.() ?? null, openRiskFlags: riskMap.get(item.entity_id) ?? [], })), }; } 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 }) { 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, }, }); if (!current) { throw buildNotFoundError(); } if (!["approved", "paid"].includes(current.status)) { throw buildValidationError({ status: "invalid_pay_status" }, 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) ?? null; const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null; const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null; if (current.status === "paid") { const conflict = (voucher != null && voucher !== (current.payment_voucher_no ?? null)) || (paymentDate != null && paymentDate !== existingPaymentDate) || (paymentChannel != null && paymentChannel !== (current.payment_channel ?? null)); if (conflict) { throw buildValidationError({ payment: "already_paid_conflict" }, 409); } 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: current.payment_voucher_no ?? null, paymentDate: existingPaymentDate, paymentChannel: current.payment_channel ?? null, paymentNote: null, idempotent: true, archivedInvoiceCount: 0, source: "payment_batch", paymentBatchId: batch.id, paymentBatchItemId: item.id, }, }), }); return { idempotent: true }; } 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 }) { 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 (!["confirmed", "paid"].includes(current.status)) { throw buildValidationError({ status: "invalid_pay_status" }, 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) ?? null; const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null; const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null; if (current.status === "paid") { const conflict = voucher !== (current.payment_voucher_no ?? null) || paymentDate !== existingPaymentDate || paymentChannel !== (current.payment_channel ?? null); if (conflict) { throw buildValidationError({ payment: "already_paid_conflict" }, 409); } return { idempotent: true }; } 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 }); } async function readReceiptUploadFiles(fastify, request) { const files = []; let governanceFields = {}; try { for await (const part of request.parts()) { if (part.type === "field") { governanceFields = parseMultipartGovernanceField(part, governanceFields); continue; } if (part.type !== "file") { continue; } const originalName = part.filename ?? "receipt"; const mimeType = part.mimetype; if (!RECEIPT_ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) { throw buildValidationError({ file: "unsupported_mime_type" }); } const chunks = []; let fileSize = 0; for await (const chunk of part.file) { fileSize += chunk.length; if (fileSize > fastify.config.maxUploadSizeBytes) { throw buildValidationError({ file: "file_too_large" }); } chunks.push(chunk); } if (fileSize === 0) { throw buildValidationError({ file: "empty_file" }); } files.push({ buffer: Buffer.concat(chunks), originalName, mimeType, fileSize, }); } } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { throw buildValidationError({ file: "file_too_large" }); } throw error; } if (!files.length) { throw buildValidationError({ file: "missing" }); } return files.map((file) => ({ ...file, governanceFields })); } async function paymentBatchesRoutes(fastify) { 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" }] } }, orderBy: [{ created_at: "desc" }, { id: "desc" }], }); const mapped = []; for (const batch of items) { 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); const entityIds = Array.from(new Set(body.entityIds)); const created = await fastify.prisma.$transaction(async (tx) => { const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds); if (snapshots.length !== entityIds.length) { throw buildValidationError({ entityIds: "contains_not_found" }, 404); } for (const snapshot of snapshots) { ensureBatchEntityStatus(body.entityType, snapshot); } const batch = await tx.paymentBatch.create({ data: { batch_no: buildBatchNo(), entity_type: body.entityType, name: body.name, status: "draft", default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null, default_payment_channel: body.defaultPaymentChannel ?? null, voucher_prefix: normalizeNullableString(body.voucherPrefix), 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, }, }), }); return loadBatchWithItems(tx, batch.id); }); return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } }); }); 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); const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch); return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) }); }); fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchReadPermission(fastify, request); const batchId = parseId(request.params?.id); await loadBatchWithItems(fastify.prisma, batchId); 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) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const batchId = parseId(request.params?.id); const batch = await loadBatchWithItems(fastify.prisma, batchId); if (["executed", "cancelled"].includes(batch.status)) { throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409); } const files = await readReceiptUploadFiles(fastify, request); const datePrefix = compactDateString(fastify.config.appTimezone); const createdItems = []; for (const file of files) { const fileSha256 = sha256Hex(file.buffer); const extension = path.extname(file.originalName ?? "").toLowerCase(); const objectKey = path.posix.join("payment-batch-receipts", datePrefix, `${batchId}-${crypto.randomUUID()}${extension}`); const storagePath = await writeBuffer({ config: fastify.config, key: objectKey, buffer: file.buffer, contentType: file.mimeType, }); const created = await fastify.prisma.paymentBatchReceiptAttachment.create({ data: { payment_batch_id: batchId, storage_path: storagePath, original_name: file.originalName, mime_type: file.mimeType, file_size: file.fileSize, file_sha256: fileSha256, uploaded_by: request.employee.id, ...governanceCreateData(file.governanceFields, { 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, }, }, }, }); createdItems.push(created); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PAYMENT_BATCH_RECEIPT_UPLOADED", targetType: "payment_batch", targetId: batchId, extra: { attachmentIds: createdItems.map((item) => item.id), attachmentCount: createdItems.length, attachmentTypes: [...new Set(createdItems.map((item) => item.attachment_type))], }, }), }); return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) }); }); fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchReadPermission(fastify, request); const batchId = parseId(request.params?.id); const attachmentId = parseId(request.params?.attachmentId, "attachmentId"); 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, governance_status: true, sensitivity_level: true, attachment_type: true, download_policy: true, }, }); if (!attachment) { throw buildNotFoundError(); } const downloadReason = sanitizeDownloadReason(request.query?.reason); if (requiresDownloadReason(attachment) && !downloadReason) { throw buildValidationError({ reason: "required" }); } let stream = null; try { const opened = await openReadStream({ config: fastify.config, storagePath: attachment.storage_path, }); stream = opened.stream; } catch (error) { if (error instanceof StorageNotFoundError) { throw buildNotFoundError(); } throw error; } 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(attachment.mime_type || "application/octet-stream"); reply.header( "content-disposition", `inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`, ); return reply.send(stream); }); fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const batchId = parseId(request.params?.id); const attachmentId = parseId(request.params?.attachmentId, "attachmentId"); const result = await fastify.prisma.$transaction(async (tx) => { const batch = await loadBatchWithItems(tx, batchId); if (["executed", "cancelled"].includes(batch.status)) { throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409); } const deleted = await tx.paymentBatchReceiptAttachment.deleteMany({ where: { id: attachmentId, payment_batch_id: batchId, }, }); if (deleted.count === 0) { throw buildNotFoundError(); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PAYMENT_BATCH_RECEIPT_DELETED", targetType: "payment_batch", targetId: batchId, extra: { attachmentId }, }), }); return { attachmentId }; }); return sendData(reply, request, { attachmentId: result.attachmentId, outcome: "deleted" }); }); 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 batch = await loadBatchWithItems(fastify.prisma, batchId); if (!["draft", "reviewed", "failed"].includes(batch.status)) { throw buildValidationError({ status: "only_open_batch_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 updated = await fastify.prisma.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 } : {}), ...(body.paymentChannelOverride !== undefined ? { payment_channel_override: body.paymentChannelOverride ?? null } : {}), }, }); 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, paymentChannelOverride: updated.payment_channel_override ?? 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 result = await fastify.prisma.$transaction(async (tx) => { const batch = await loadBatchWithItems(tx, batchId); const batchRisk = summarizeBatchRisk(batch); const isFirstReview = batch.status === "draft" && !batch.reviewed_at; const isSecondReview = batchRisk.highRisk && batch.status === "reviewed" && !batch.secondary_reviewed_at; if (!isFirstReview && !isSecondReview) { 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 && batch.created_by === request.employee.id) { throw buildValidationError({ review: "maker_checker_conflict" }, 409); } if (isSecondReview && batch.reviewed_by && batch.reviewed_by === request.employee.id) { throw buildValidationError({ review: "second_reviewer_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: isFirstReview ? { status: "reviewed", review_comment: normalizeNullableString(body.comment), reviewed_at: reviewedAt, reviewed_by: request.employee.id, updated_by: request.employee.id, } : { secondary_review_comment: normalizeNullableString(body.comment), secondary_reviewed_at: reviewedAt, secondary_reviewed_by: request.employee.id, updated_by: request.employee.id, }, include: { items: true }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: isFirstReview ? "PAYMENT_BATCH_REVIEWED" : "PAYMENT_BATCH_SECOND_REVIEWED", targetType: "payment_batch", targetId: batchId, extra: { highRisk: batchRisk.highRisk, resolvedRiskFlagCount: openRiskFlags.length, reviewStage: isFirstReview ? "first_review" : "second_review", }, }), }); return reviewed; }); return sendData(reply, request, { item: mapBatch(result, []) }); }); 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 loadBatchWithItems(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: batchRisk.highRisk ? "high_risk_second_review_required" : "batch_review_required", }, 409); } await ensureBankReceiptForExecutableBatch(tx, batch); let successCount = 0; let failedCount = 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, }); if (!paymentResult.idempotent) { successfulReimbursementIds.push(item.entity_id); } } else { await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index }); } await tx.paymentBatchItem.update({ where: { id: item.id }, data: { status: "executed", failure_code: null, failure_message: null, }, }); successCount += 1; } catch (error) { const failureCode = error instanceof AppError ? error.code : "SYS_001"; const failureMessage = error instanceof AppError ? error.message : error instanceof Error ? error.message : "支付执行失败"; await tx.paymentBatchItem.update({ where: { id: item.id }, data: { status: "failed", failure_code: failureCode, failure_message: failureMessage, }, }); 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, }, }); failedCount += 1; } } const refreshed = await tx.paymentBatch.update({ where: { id: batchId }, data: { status: failedCount > 0 ? "failed" : "executed", execution_summary_json: { successCount, failedCount, attemptedCount: successCount + failedCount, }, executed_at: new Date(), executed_by: request.employee.id, updated_by: request.employee.id, }, include: { items: true }, }); 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, successCount, failedCount, }, }), }); 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: mapBatch(result, openRiskFlags) }); }); 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 batch = await loadBatchWithItems(fastify.prisma, batchId); if (["executed", "cancelled"].includes(batch.status)) { throw buildValidationError({ status: "batch_not_cancellable" }, 409); } const cancelled = await fastify.prisma.paymentBatch.update({ where: { id: batchId }, data: { status: "cancelled", cancelled_at: new Date(), cancelled_by: request.employee.id, updated_by: request.employee.id, }, include: { items: true }, }); return sendData(reply, request, { item: mapBatch(cancelled, []) }); }); } module.exports = paymentBatchesRoutes;