const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { issueDocumentNumber } = require("../services/documentSequence"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { TRAVEL_RECONCILIATION_BATCH_STATUSES, TRAVEL_RECONCILIATION_RESOLUTION_STATUSES, buildReconciliationItemsSnapshot, buildValidationError, ensureBatchFinalizable, ensureConfirmedReconciliationItem, ensureDraftBatch, ensurePendingReconciliationItem, ensureReopenableReconciliationItem, listTravelReconciliationBatches, loadReconciliationScope, loadTravelReconciliationBatchDetail, mapBatchItem, normalizeSummaryJson, } = require("../services/travelReconciliation"); const { TRAVEL_ERP_EVENT_TYPE, buildTravelReconciliationSummaryDedupeKey, enqueueTravelReconciliationSummaryExport, } = require("../services/integrations/erpTravel"); const UUID_SCHEMA = z.string().uuid(); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "false", "true"]).optional(); const BATCH_STATUS_SCHEMA = z.enum(["draft", "reconciled"]); const LIST_QUERY_SCHEMA = z.object({ status: BATCH_STATUS_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), periodStart: DATE_ONLY_SCHEMA.optional(), periodEnd: DATE_ONLY_SCHEMA.optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), runNow: BOOLEAN_QUERY_SCHEMA, }); const CREATE_BODY_SCHEMA = z .object({ legalEntityId: UUID_SCHEMA, costCenterId: UUID_SCHEMA.optional(), periodStart: DATE_ONLY_SCHEMA, periodEnd: DATE_ONLY_SCHEMA, }) .strict(); const NOTE_BODY_SCHEMA = z .object({ note: z.string().trim().max(500).nullable().optional(), }) .strict(); function normalizeNullableString(value) { if (value == null) { return null; } const normalized = String(value).trim(); return normalized || null; } function ensureReconciliationReadPermission(fastify, request) { const allowed = fastify.hasPermission(request.employee, "travel.payment.manage") || fastify.hasPermission(request.employee, "integration.manage"); if (!allowed) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } } function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten(), 400); } return { status: parsed.data.status, legalEntityId: parsed.data.legalEntityId ?? null, costCenterId: parsed.data.costCenterId ?? null, periodStart: parsed.data.periodStart ? parseDateOnlyString(parsed.data.periodStart) : null, periodEnd: parsed.data.periodEnd ? parseDateOnlyString(parsed.data.periodEnd) : null, page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, }; } function parseRunNow(query) { return query?.runNow === "1" || query?.runNow === 1 || query?.runNow === true || query?.runNow === "true"; } function ensureValidPeriod(periodStart, periodEnd) { if (periodStart.getTime() > periodEnd.getTime()) { throw buildValidationError({ period: "period_start_after_period_end" }); } } async function validateBatchScopeContext(prisma, { legalEntityId, costCenterId }) { const [legalEntity, costCenter] = await Promise.all([ prisma.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true }, }), costCenterId ? prisma.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, code: true, name: true, legal_entity_id: true }, }) : Promise.resolve(null), ]); if (!legalEntity) { throw buildValidationError({ legalEntityId: "not_found" }); } if (costCenterId && !costCenter) { throw buildValidationError({ costCenterId: "not_found" }); } if (costCenter && costCenter.legal_entity_id !== legalEntityId) { throw buildValidationError({ costCenterId: "legal_entity_mismatch" }); } return { legalEntity, costCenter }; } async function loadBatchForUpdate(tx, batchId) { const rows = await tx.$queryRaw` SELECT id FROM "travel_reconciliation_batches" WHERE id = ${batchId}::uuid FOR UPDATE `; if (!Array.isArray(rows) || rows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return tx.travelReconciliationBatch.findUnique({ where: { id: batchId }, select: { id: true, batch_no: true, legal_entity_id: true, cost_center_id: true, status: true, summary_json: true, confirmed_at: true, confirmed_by: true, items: { select: { id: true, resolution_status: true, }, }, }, }); } async function loadItemForUpdate(tx, itemId) { const rows = await tx.$queryRaw` SELECT id FROM "travel_reconciliation_items" WHERE id = ${itemId}::uuid FOR UPDATE `; if (!Array.isArray(rows) || rows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return tx.travelReconciliationItem.findUnique({ where: { id: itemId }, select: { id: true, travel_reconciliation_batch_id: true, match_status: true, resolution_status: true, confirmed_at: true, confirmed_by: true, confirm_note: true, batch: { select: { id: true, status: true, summary_json: true, }, }, travel_request: { select: { id: true, request_no: true, }, }, travel_order: { select: { id: true, external_order_no: true, status: true, }, }, card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true, }, }, order_amount: true, transaction_amount: true, currency: true, anomaly_code: true, snapshot_json: true, created_at: true, updated_at: true, travel_request_id: true, travel_order_id: true, card_transaction_id: true, }, }); } async function syncBatchPendingSummary(tx, batchId) { const batch = await tx.travelReconciliationBatch.findUnique({ where: { id: batchId }, select: { id: true, summary_json: true, items: { select: { resolution_status: true, }, }, }, }); if (!batch) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const summary = normalizeSummaryJson(batch.summary_json); summary.pendingItems = batch.items.filter( (item) => item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, ).length; await tx.travelReconciliationBatch.update({ where: { id: batchId }, data: { summary_json: summary, }, }); return summary; } async function travelReconciliationRoutes(fastify) { fastify.get( "/travel/reconciliation/batches", { preHandler: fastify.authenticate, }, async (request, reply) => { ensureReconciliationReadPermission(fastify, request); const query = parseListQuery(request.query); const result = await listTravelReconciliationBatches(fastify.prisma, query); return sendData(reply, request, result); }, ); fastify.post( "/travel/reconciliation/batches", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const parsed = CREATE_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten(), 400); } const costCenterId = parsed.data.costCenterId ?? null; const periodStart = parseDateOnlyString(parsed.data.periodStart); const periodEnd = parseDateOnlyString(parsed.data.periodEnd); ensureValidPeriod(periodStart, periodEnd); const scopeContext = await validateBatchScopeContext(fastify.prisma, { legalEntityId: parsed.data.legalEntityId, costCenterId, }); const scope = await loadReconciliationScope(fastify.prisma, { legalEntityId: parsed.data.legalEntityId, costCenterId, periodStart, periodEnd, }); if (!scope.orders.length && !scope.transactions.length) { throw buildValidationError({ batch: "travel_reconciliation_empty_scope" }); } const now = new Date(); const snapshot = buildReconciliationItemsSnapshot({ orders: scope.orders, transactions: scope.transactions, invoices: scope.invoices, actorId: request.employee.id, now, }); const issued = await issueDocumentNumber(fastify.prisma, { documentType: "travel_reconciliation_batch", actorId: request.employee.id, timeZone: fastify.config.appTimezone, }); let batchId = null; await fastify.prisma.$transaction(async (tx) => { const batch = await tx.travelReconciliationBatch.create({ data: { batch_no: issued.documentNumber, legal_entity_id: parsed.data.legalEntityId, cost_center_id: costCenterId, period_start: periodStart, period_end: periodEnd, status: TRAVEL_RECONCILIATION_BATCH_STATUSES.draft, summary_json: snapshot.summary, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true }, }); batchId = batch.id; if (snapshot.items.length) { await tx.travelReconciliationItem.createMany({ data: snapshot.items.map((item) => ({ ...item, travel_reconciliation_batch_id: batch.id, })), }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_RECONCILIATION_BATCH_CREATED", targetType: "travel_reconciliation_batch", targetId: batch.id, extra: { batchNo: issued.documentNumber, legalEntityId: parsed.data.legalEntityId, costCenterId, legalEntityCode: scopeContext.legalEntity.code, costCenterCode: scopeContext.costCenter?.code ?? null, periodStart: parsed.data.periodStart, periodEnd: parsed.data.periodEnd, summary: snapshot.summary, }, }), }); }); const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, batchId, { canManagePayments: true, canRetryErp: fastify.hasPermission(request.employee, "integration.manage"), }); return sendData(reply, request, detail); }, ); fastify.get( "/travel/reconciliation/batches/:batchId", { preHandler: fastify.authenticate, }, async (request, reply) => { ensureReconciliationReadPermission(fastify, request); const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); if (!parsedId.success) { throw buildValidationError({ batchId: "invalid_uuid" }, 400); } const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, parsedId.data, { canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"), canRetryErp: fastify.hasPermission(request.employee, "integration.manage"), }); return sendData(reply, request, detail); }, ); fastify.post( "/travel/reconciliation/items/:itemId/confirm", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId); if (!parsedId.success) { throw buildValidationError({ itemId: "invalid_uuid" }, 400); } const parsedBody = NOTE_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { throw buildValidationError(parsedBody.error.flatten(), 400); } let response = null; await fastify.prisma.$transaction(async (tx) => { const item = await loadItemForUpdate(tx, parsedId.data); ensureDraftBatch(item.batch); ensurePendingReconciliationItem(item); const updated = await tx.travelReconciliationItem.update({ where: { id: parsedId.data }, data: { resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed, confirmed_at: new Date(), confirmed_by: request.employee.id, confirm_note: normalizeNullableString(parsedBody.data.note), }, select: { id: true, item_kind: true, travel_request_id: true, travel_order_id: true, card_transaction_id: true, match_status: true, resolution_status: true, order_amount: true, transaction_amount: true, currency: true, anomaly_code: true, snapshot_json: true, confirmed_at: true, confirmed_by: true, confirm_note: true, created_at: true, updated_at: true, travel_request: { select: { id: true, request_no: true } }, travel_order: { select: { id: true, external_order_no: true, status: true } }, card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true } }, }, }); const summary = await syncBatchPendingSummary(tx, item.batch.id); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_RECONCILIATION_ITEM_CONFIRMED", targetType: "travel_reconciliation_item", targetId: item.id, extra: { batchId: item.batch.id, matchStatus: item.match_status, note: normalizeNullableString(parsedBody.data.note), summary, }, }), }); response = { batchId: item.batch.id, summary, item: mapBatchItem(updated, { canManagePayments: true, batchStatus: item.batch.status }), }; }); return sendData(reply, request, response); }, ); fastify.post( "/travel/reconciliation/items/:itemId/reopen", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId); if (!parsedId.success) { throw buildValidationError({ itemId: "invalid_uuid" }, 400); } let response = null; await fastify.prisma.$transaction(async (tx) => { const item = await loadItemForUpdate(tx, parsedId.data); ensureDraftBatch(item.batch); ensureConfirmedReconciliationItem(item); ensureReopenableReconciliationItem(item); const updated = await tx.travelReconciliationItem.update({ where: { id: parsedId.data }, data: { resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, confirmed_at: null, confirmed_by: null, confirm_note: null, }, select: { id: true, item_kind: true, travel_request_id: true, travel_order_id: true, card_transaction_id: true, match_status: true, resolution_status: true, order_amount: true, transaction_amount: true, currency: true, anomaly_code: true, snapshot_json: true, confirmed_at: true, confirmed_by: true, confirm_note: true, created_at: true, updated_at: true, travel_request: { select: { id: true, request_no: true } }, travel_order: { select: { id: true, external_order_no: true, status: true } }, card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true } }, }, }); const summary = await syncBatchPendingSummary(tx, item.batch.id); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_RECONCILIATION_ITEM_REOPENED", targetType: "travel_reconciliation_item", targetId: item.id, extra: { batchId: item.batch.id, matchStatus: item.match_status, summary, }, }), }); response = { batchId: item.batch.id, summary, item: mapBatchItem(updated, { canManagePayments: true, batchStatus: item.batch.status }), }; }); return sendData(reply, request, response); }, ); fastify.post( "/travel/reconciliation/batches/:batchId/finalize", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); if (!parsedId.success) { throw buildValidationError({ batchId: "invalid_uuid" }, 400); } let response = null; await fastify.prisma.$transaction(async (tx) => { const batch = await loadBatchForUpdate(tx, parsedId.data); const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id); if (batch.status === TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled) { const existingOutbox = await tx.integrationOutbox.findUnique({ where: { event_type_dedupe_key: { event_type: TRAVEL_ERP_EVENT_TYPE, dedupe_key: dedupeKey, }, }, select: { id: true }, }); response = { batchId: batch.id, batchNo: batch.batch_no, status: batch.status, erpExport: { eventType: TRAVEL_ERP_EVENT_TYPE, outboxId: existingOutbox?.id ?? null, duplicated: Boolean(existingOutbox), requeued: false, enqueued: Boolean(existingOutbox), }, }; return; } ensureDraftBatch(batch); ensureBatchFinalizable(batch.items); const finalizedAt = new Date(); await tx.travelReconciliationBatch.update({ where: { id: batch.id }, data: { status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled, confirmed_at: finalizedAt, confirmed_by: request.employee.id, updated_by: request.employee.id, summary_json: { ...normalizeSummaryJson(batch.summary_json), pendingItems: 0, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_RECONCILIATION_FINALIZED", targetType: "travel_reconciliation_batch", targetId: batch.id, extra: { batchNo: batch.batch_no, itemCount: batch.items.length, }, }), }); const gate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.erpSync, departmentId: null, strictDepartment: false, }); let erpExport = { eventType: TRAVEL_ERP_EVENT_TYPE, outboxId: null, duplicated: false, requeued: false, enqueued: false, }; if (fastify.config.erpSyncEnabled && gate.enabled) { const exportContext = await tx.travelReconciliationBatch.findUnique({ where: { id: batch.id }, select: { id: true, batch_no: true, legal_entity_id: true, cost_center_id: true, period_start: true, period_end: true, status: true, summary_json: true, confirmed_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true } }, cost_center: { select: { id: true, code: true, name: true } }, items: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, travel_request_id: true, travel_order_id: true, card_transaction_id: true, match_status: true, resolution_status: true, order_amount: true, transaction_amount: true, currency: true, snapshot_json: true, }, }, }, }); const outbox = await enqueueTravelReconciliationSummaryExport(tx, fastify.config, { batch: exportContext, actorId: request.employee.id, requeueOnFailure: true, }); erpExport = { eventType: TRAVEL_ERP_EVENT_TYPE, outboxId: outbox.id, duplicated: outbox.duplicated, requeued: outbox.requeued, enqueued: true, }; } else { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", targetType: "travel_reconciliation_batch", targetId: batch.id, extra: { featureFlag: FEATURE_FLAG_KEYS.erpSync, reason: fastify.config.erpSyncEnabled ? gate.reason : "env_disabled", }, }), }); } response = { batchId: batch.id, batchNo: batch.batch_no, status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled, erpExport, }; }); return sendData(reply, request, response); }, ); } travelReconciliationRoutes.__testables = { ensureReconciliationReadPermission, parseListQuery, parseRunNow, }; module.exports = travelReconciliationRoutes;