const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { normalizeTravelImportRows } = require("../services/integrations/travel"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { enqueueTravelOrderPaymentJournalExport, buildTravelOrderPaymentJournalDedupeKey, } = require("../services/integrations/erpJournal"); const { parseDateOnlyString } = require("../utils/date"); const IMPORT_SCHEMA = z.object({ orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100), }); const UUID_SCHEMA = z.string().uuid(); const PAYMENT_CHANNEL_SCHEMA = z.enum([ "bank_transfer", "corporate_card", "virtual_card", "manual", "other", ]); const MARK_PAID_SCHEMA = z .object({ paymentVoucherNo: z.string().trim().max(50).nullable().optional(), paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), paymentChannel: PAYMENT_CHANNEL_SCHEMA.nullable().optional(), }) .strict(); function mapTravelOrder(item) { return { id: item.id, source: item.source, externalOrderNo: item.external_order_no, employeeId: item.employee_id, departmentId: item.department_id ?? null, linkedReimbursementId: item.linked_reimbursement_id ?? null, travelRequestId: item.travel_request_id ?? null, occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null, amount: item.amount?.toString?.() ?? null, currency: item.currency, status: item.status, 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, paidBy: item.paid_by ?? null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function normalizeNullableString(value) { if (value == null) { return null; } const normalized = String(value).trim(); return normalized || null; } function toDateOnlyString(value) { if (!value) { return null; } try { return new Date(value).toISOString().slice(0, 10); } catch { return null; } } function buildOrderKey(source, externalOrderNo) { return `${source}::${externalOrderNo}`; } async function travelOrdersRoutes(fastify) { fastify.post( "/travel/orders/import", { preHandler: fastify.requirePermission("integration.manage"), }, async (request, reply) => { if (!fastify.config.travelSyncEnabled) { throw buildValidationError({ integration: "travel_sync_disabled" }, 409); } const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const rows = normalizeTravelImportRows(parsed.data.orders); if (!rows.length) { throw buildValidationError({ orders: "empty_after_normalization" }); } const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); const travelRequestIds = Array.from( new Set( rows .filter((item) => item.hasTravelRequestId && item.travelRequestId) .map((item) => item.travelRequestId), ), ).filter((item) => UUID_SCHEMA.safeParse(item).success); const existingOrderWhere = rows.map((item) => ({ source: item.source, external_order_no: item.externalOrderNo, })); const [employees, travelRequests, existingOrders] = await Promise.all([ fastify.prisma.employee.findMany({ where: { id: { in: employeeIds }, status: "active", }, select: { id: true, department_id: true, }, }), travelRequestIds.length ? fastify.prisma.travelRequest.findMany({ where: { id: { in: travelRequestIds } }, select: { id: true, applicant_employee_id: true, }, }) : Promise.resolve([]), existingOrderWhere.length ? fastify.prisma.travelOrder.findMany({ where: { OR: existingOrderWhere }, select: { id: true, source: true, external_order_no: true, status: true, travel_request_id: true, }, }) : Promise.resolve([]), ]); const employeeMap = new Map(employees.map((item) => [item.id, item])); const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item])); const existingOrderMap = new Map( existingOrders.map((item) => [buildOrderKey(item.source, item.external_order_no), item]), ); const imported = []; const skipped = []; let linkedTravelRequestCount = 0; for (const row of rows) { const employee = employeeMap.get(row.employeeId); if (!employee) { skipped.push({ source: row.source, externalOrderNo: row.externalOrderNo, reason: "employee_not_found", }); continue; } let nextTravelRequestId; if (row.hasTravelRequestId && row.travelRequestId) { const travelRequest = travelRequestMap.get(row.travelRequestId); if (!travelRequest) { skipped.push({ source: row.source, externalOrderNo: row.externalOrderNo, reason: "travel_request_not_found", }); continue; } if (travelRequest.applicant_employee_id !== row.employeeId) { skipped.push({ source: row.source, externalOrderNo: row.externalOrderNo, reason: "travel_request_employee_mismatch", }); continue; } nextTravelRequestId = travelRequest.id; } else if (row.hasTravelRequestId && !row.travelRequestId) { nextTravelRequestId = undefined; } const existingOrder = existingOrderMap.get(buildOrderKey(row.source, row.externalOrderNo)) ?? null; const updateData = { employee_id: row.employeeId, department_id: employee.department_id, linked_reimbursement_id: row.linkedReimbursementId, occurred_at: row.occurredAt, amount: row.amount, currency: row.currency, status: existingOrder?.status ?? "imported", raw_payload: row.rawPayload, updated_by: request.employee.id, }; if (nextTravelRequestId !== undefined) { updateData.travel_request_id = nextTravelRequestId; } const createData = { source: row.source, external_order_no: row.externalOrderNo, employee_id: row.employeeId, department_id: employee.department_id, linked_reimbursement_id: row.linkedReimbursementId, travel_request_id: nextTravelRequestId ?? null, occurred_at: row.occurredAt, amount: row.amount, currency: row.currency, status: "imported", raw_payload: row.rawPayload, created_by: request.employee.id, updated_by: request.employee.id, }; const upserted = await fastify.prisma.travelOrder.upsert({ where: { source_external_order_no: { source: row.source, external_order_no: row.externalOrderNo, }, }, update: updateData, create: createData, }); if (upserted.travel_request_id) { linkedTravelRequestCount += 1; } imported.push(upserted); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_ORDERS_IMPORTED", targetType: "travel_order", extra: { requested: rows.length, createdOrUpdated: imported.length, linkedTravelRequestCount, skipped, }, }), }); return sendData(reply, request, { requested: rows.length, imported: imported.length, linkedTravelRequestCount, skipped, items: imported.map(mapTravelOrder), }); }, ); fastify.post( "/travel-orders/:id/mark-paid", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.id); if (!parsedId.success) { throw buildValidationError({ id: "invalid_uuid" }); } const parsedBody = MARK_PAID_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { throw buildValidationError(parsedBody.error.flatten()); } const paymentVoucherNo = normalizeNullableString(parsedBody.data.paymentVoucherNo); const paymentDate = parsedBody.data.paymentDate ?? null; const paymentChannel = parsedBody.data.paymentChannel ?? null; let response = null; await fastify.prisma.$transaction(async (tx) => { const orderRows = await tx.$queryRaw` SELECT id, status, source, external_order_no, employee_id, department_id, travel_request_id, amount, currency, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by, created_at, updated_at FROM "travel_orders" WHERE id = ${parsedId.data}::uuid FOR UPDATE `; if (!Array.isArray(orderRows) || orderRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = orderRows[0]; if (!["confirmed", "paid"].includes(current.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_pay_status" }, }); } const existingPaymentDate = toDateOnlyString(current.payment_date); if (current.status === "paid") { const voucherConflict = paymentVoucherNo !== (current.payment_voucher_no ?? null); const paymentDateConflict = paymentDate !== existingPaymentDate; const paymentChannelConflict = paymentChannel !== (current.payment_channel ?? null); if (voucherConflict || paymentDateConflict || paymentChannelConflict) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { payment: "already_paid_conflict" }, }); } } const matchedTransaction = await tx.cardTransaction.findFirst({ where: { travel_order_id: parsedId.data, txn_type: "charge", status: "settled", amount: current.amount, currency: current.currency, }, orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], select: { id: true }, }); if (!matchedTransaction) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { payment: "missing_settled_transaction" }, }); } const now = new Date(); if (current.status === "confirmed") { await tx.travelOrder.update({ where: { id: parsedId.data }, data: { status: "paid", payment_voucher_no: paymentVoucherNo, payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null, payment_channel: paymentChannel, paid_at: now, paid_by: request.employee.id, updated_by: request.employee.id, }, }); } const refreshed = await tx.travelOrder.findUnique({ where: { id: parsedId.data }, select: { id: true, source: true, external_order_no: true, employee_id: true, department_id: true, linked_reimbursement_id: true, travel_request_id: true, amount: true, currency: true, status: true, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: true, paid_by: true, created_at: true, updated_at: true, travel_request: { select: { id: true, request_no: true, department_id: true, applicant_employee_id: true, submitter_id: true, legal_entity: { select: { code: true } }, cost_center: { select: { code: true } }, budget_pool: { select: { code: true } }, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_ORDER_MARKED_PAID", targetType: "travel_order", targetId: parsedId.data, extra: { paymentVoucherNo, paymentDate, paymentChannel, idempotent: current.status === "paid", matchedTransactionId: matchedTransaction.id, }, }), }); const departmentId = refreshed?.department_id ?? refreshed?.travel_request?.department_id ?? null; const erpGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.erpSync, departmentId, strictDepartment: true, }); let erpJournal = { eventType: "erp.journal.export", outboxId: null, dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data), enqueued: false, }; if (erpGate.enabled && refreshed) { const outbox = await enqueueTravelOrderPaymentJournalExport(tx, fastify.config, { travelOrder: refreshed, actorId: request.employee.id, }); erpJournal = { eventType: "erp.journal.export", outboxId: outbox.id, dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data), enqueued: true, }; } else { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", targetType: "travel_order", targetId: parsedId.data, extra: { featureFlag: FEATURE_FLAG_KEYS.erpSync, departmentId, reason: erpGate.reason, }, }), }); } response = { travelOrderId: refreshed.id, travelRequestId: refreshed.travel_request_id ?? null, status: refreshed.status, paymentVoucherNo: refreshed.payment_voucher_no ?? null, paymentDate: refreshed.payment_date?.toISOString?.()?.slice(0, 10) ?? null, paymentChannel: refreshed.payment_channel ?? null, erpJournal, }; }); return sendData(reply, request, response); }, ); } module.exports = travelOrdersRoutes;