const { AppError } = require("../utils/AppError"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl"); const { z } = require("zod"); const { REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES, REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES, REIMBURSEMENT_LEDGER_STATUS_VALUES, listFinanceLedger, } = require("../services/financeLedger"); const FINANCE_LEDGER_PERMISSION_CODES = ["finance.ledger.view"]; const FINANCE_LEDGER_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), status: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(), paymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(), invoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(), dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), q: z.string().trim().max(100).optional(), }); function ensureReadPermission(fastify, request) { if ( !FINANCE_LEDGER_PERMISSION_CODES.some((permission) => fastify.hasPermission(request.employee, permission)) ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } } 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 buildBusinessValidationError( "ap_operations.feature_disabled", { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409, ); } } function mapBatchSummary(item) { const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0)); return { id: item.id, batchNo: item.batch_no, entityType: item.entity_type, name: item.name, status: item.status, totalAmount, highRisk: toMoneyNumber(totalAmount) >= 50000, createdAt: item.created_at?.toISOString?.() ?? null, }; } function mapFailedPaymentItem(item) { return { id: item.id, paymentBatchId: item.payment_batch_id, entityId: item.entity_id, entityCode: item.entity_code, entityTitle: item.entity_title, amount: toMoneyString(item.amount), currencyCode: item.currency_code, failureCode: item.failure_code ?? null, failureMessage: item.failure_message ?? null, createdAt: item.created_at?.toISOString?.() ?? null, }; } function mapReconciliationAnomaly(item) { return { id: item.id, travelReconciliationBatchId: item.travel_reconciliation_batch_id, travelOrderId: item.travel_order_id ?? null, travelRequestId: item.travel_request_id ?? null, matchStatus: item.match_status, resolutionStatus: item.resolution_status, anomalyCode: item.anomaly_code ?? null, orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null, transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null, currency: item.currency, createdAt: item.created_at?.toISOString?.() ?? null, }; } async function apOperationsRoutes(fastify) { fastify.get("/ap-operations", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureReadPermission(fastify, request); const [ batches, pendingPaymentCount, failedItems, failedPaymentItemCount, openRiskFlags, nonBudgetRiskFlagCount, budgetOverspendCount, anomalies, partialMatchCount, ] = await Promise.all([ fastify.prisma.paymentBatch.findMany({ where: { status: { in: ["draft", "reviewed", "failed"] } }, include: { items: { select: { amount: true, }, }, }, orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 20, }), fastify.prisma.paymentBatchItem.count({ where: { payment_batch: { status: { in: ["draft", "reviewed", "failed"] }, }, status: { in: ["pending", "failed"] }, }, }), fastify.prisma.paymentBatchItem.findMany({ where: { status: "failed" }, orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 50, }), fastify.prisma.paymentBatchItem.count({ where: { status: "failed" }, }), fastify.prisma.entityRiskFlag.findMany({ where: { status: "open" }, orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 50, }), fastify.prisma.entityRiskFlag.count({ where: { status: "open", risk_code: { notIn: BUDGET_RISK_CODES }, }, }), fastify.prisma.entityRiskFlag.count({ where: { status: "open", risk_code: { in: BUDGET_RISK_CODES }, }, }), fastify.prisma.travelReconciliationItem.findMany({ where: { resolution_status: "pending", match_status: { not: "matched" }, }, orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 50, select: { id: true, travel_reconciliation_batch_id: true, travel_order_id: true, travel_request_id: true, match_status: true, resolution_status: true, anomaly_code: true, order_amount: true, transaction_amount: true, currency: true, created_at: true, }, }), fastify.prisma.travelReconciliationItem.count({ where: { resolution_status: "pending", match_status: { not: "matched" }, }, }), ]); const riskFlags = openRiskFlags.map(buildPersistedRiskFlag); const budgetOverspendRisks = riskFlags.filter((item) => BUDGET_RISK_CODES.includes(item.riskCode)); const exceptionRiskFlags = riskFlags.filter((item) => !BUDGET_RISK_CODES.includes(item.riskCode)); return sendData(reply, request, { summary: { pendingPaymentCount, partialMatchCount, exceptionCount: failedPaymentItemCount + nonBudgetRiskFlagCount, budgetOverspendCount, }, pendingPaymentBatches: batches.map(mapBatchSummary), failedPaymentBatchItems: failedItems.map(mapFailedPaymentItem), exceptionRiskFlags, budgetOverspendRisks, partialMatchItems: anomalies.map(mapReconciliationAnomaly), }); }); fastify.get("/ap-operations/reimbursement-ledger", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureReadPermission(fastify, request); const parsed = FINANCE_LEDGER_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { throw buildBusinessValidationError("ap_operations.query_invalid", parsed.error.flatten(), 400); } const ledger = await listFinanceLedger(fastify.prisma, parsed.data); return sendData(reply, request, ledger); }); } module.exports = apOperationsRoutes;