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 { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow"); const { sendNotificationEmailSafe } = require("../services/email"); const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); const { buildTravelPaymentTrack } = require("../services/travelPayments"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); const { buildPersistedRiskFlag, createRiskFlagsFromPrecheck, resolveEntityPrecheck, } = require("../services/feeControl"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const DATE_TIME_SCHEMA = z.string().min(1).max(64); const TRAVEL_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected"]); const TRIP_TYPE_SCHEMA = z.enum(["domestic", "international"]); const SEGMENT_ROLE_SCHEMA = z.enum(["outbound", "return", "connection"]); const TRANSPORT_TYPE_SCHEMA = z.enum(["flight", "train", "car", "bus", "other"]); const TRAVEL_LIST_QUERY_SCHEMA = z.object({ status: TRAVEL_STATUS_SCHEMA.optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), mine: BOOLEAN_QUERY_SCHEMA, tripType: TRIP_TYPE_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), }); const SEGMENT_BODY_SCHEMA = z .object({ segmentRole: SEGMENT_ROLE_SCHEMA, transportType: TRANSPORT_TYPE_SCHEMA, fromCity: z.string().trim().min(1).max(120), toCity: z.string().trim().min(1).max(120), departAt: DATE_TIME_SCHEMA, arriveAt: DATE_TIME_SCHEMA, }) .strict(); const TRAVEL_CREATE_BODY_SCHEMA = z .object({ purpose: z.string().trim().min(1).max(500), tripType: TRIP_TYPE_SCHEMA, startDate: DATE_ONLY_SCHEMA, endDate: DATE_ONLY_SCHEMA, legalEntityId: UUID_SCHEMA, costCenterId: UUID_SCHEMA, estimatedAmount: z.union([MONEY_STRING_SCHEMA, z.number().min(0)]), segments: z.array(SEGMENT_BODY_SCHEMA).min(1).max(20), }) .strict(); const TRAVEL_PATCH_BODY_SCHEMA = TRAVEL_CREATE_BODY_SCHEMA.partial(); const TRAVEL_SUMMARY_SELECT = { id: true, request_no: true, applicant_employee_id: true, submitter_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, budget_pool_id: true, project_id: true, purpose: true, trip_type: true, start_date: true, end_date: true, estimated_amount: true, status: true, created_at: true, updated_at: true, applicant: { select: { id: true, employee_no: true, name: true, }, }, submitter: { select: { id: true, employee_no: true, name: true, }, }, department: { select: { id: true, name: true, }, }, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, legal_entity_id: true, }, }, budget_pool: { select: { id: true, code: true, name: true, }, }, project: { select: { id: true, project_no: true, name: true, }, }, }; const TRAVEL_DETAIL_SELECT = { ...TRAVEL_SUMMARY_SELECT, segments: { orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], select: { id: true, sort_order: true, segment_role: true, transport_type: true, from_city: true, to_city: true, depart_at: true, arrive_at: true, created_at: true, updated_at: true, }, }, travel_orders: { orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], select: { id: true, source: true, external_order_no: true, employee_id: true, linked_reimbursement_id: true, travel_request_id: true, occurred_at: 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, }, }, workflow_instances: { where: { entity_type: "travel_request", current_status: "pending", }, orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_step_id: true, current_status: true, workflow_tasks: { where: { status: "pending" }, orderBy: { created_at: "asc" }, take: 1, select: { id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, }, }, }, }, }; function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseBooleanQueryValue(rawValue) { if (rawValue == null) { return null; } return rawValue === "1" || rawValue === "true"; } function parseListQuery(query) { const parsed = TRAVEL_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { status: parsed.data.status, page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, mine: parseBooleanQueryValue(parsed.data.mine) ?? true, tripType: parsed.data.tripType, legalEntityId: parsed.data.legalEntityId ?? null, costCenterId: parsed.data.costCenterId ?? null, }; } function parseTravelRequestId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "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 parseDateTimeValue(value, field) { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw buildValidationError({ [field]: "invalid_datetime" }); } return parsed; } function decimalInputToString(value, { field, min = 0 }) { const numeric = typeof value === "number" ? value : Number(value); if (!Number.isFinite(numeric)) { throw buildValidationError({ [field]: "invalid_decimal" }); } if (numeric < min) { throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); } return numeric.toFixed(2); } function normalizeSegments(segments) { return segments.map((item, index) => { const departAt = parseDateTimeValue(item.departAt, `segments.${index}.departAt`); const arriveAt = parseDateTimeValue(item.arriveAt, `segments.${index}.arriveAt`); if (arriveAt < departAt) { throw buildValidationError({ [`segments.${index}.arriveAt`]: "must_be_after_departAt" }); } return { sortOrder: index + 1, segmentRole: item.segmentRole, transportType: item.transportType, fromCity: item.fromCity.trim(), toCity: item.toCity.trim(), departAt, arriveAt, }; }); } function ensureDateRangeValid(startDate, endDate) { if (endDate < startDate) { throw buildValidationError({ endDate: "must_be_after_startDate" }); } } function buildPrecheckBlockedError(precheck) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { precheck: precheck.blockingReasons }, }); } async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) { const [legalEntity, costCenter] = await Promise.all([ tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, enabled: true, archived_at: true }, }), tx.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, code: true, name: true, legal_entity_id: true, enabled: true, archived_at: true, }, }), ]); if (!legalEntity || !legalEntity.enabled || legalEntity.archived_at) { throw buildValidationError({ legalEntityId: "not_found_or_disabled" }, 409); } if (!costCenter || !costCenter.enabled || costCenter.archived_at) { throw buildValidationError({ costCenterId: "not_found_or_disabled" }, 409); } if (costCenter.legal_entity_id !== legalEntityId) { throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409); } return { legalEntity, costCenter }; } function toIsoDate(value) { return value?.toISOString?.()?.slice(0, 10) ?? null; } function toIsoDateTime(value) { return value?.toISOString?.() ?? null; } function moneyToFixed(value) { if (value == null) { return "0.00"; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00"; } function mapEmployeeBasic(employee) { if (!employee) { return null; } return { id: employee.id, employeeNo: employee.employee_no, name: employee.name, }; } function mapTravelSegment(item) { return { id: item.id, sortOrder: item.sort_order, segmentRole: item.segment_role, transportType: item.transport_type, fromCity: item.from_city, toCity: item.to_city, departAt: toIsoDateTime(item.depart_at), arriveAt: toIsoDateTime(item.arrive_at), createdAt: toIsoDateTime(item.created_at), updatedAt: toIsoDateTime(item.updated_at), }; } function mapTravelOrder(item) { return { id: item.id, source: item.source, externalOrderNo: item.external_order_no, employeeId: item.employee_id, linkedReimbursementId: item.linked_reimbursement_id ?? null, travelRequestId: item.travel_request_id ?? null, occurredAt: toIsoDate(item.occurred_at), amount: moneyToFixed(item.amount), currency: item.currency, status: item.status, paymentVoucherNo: item.payment_voucher_no ?? null, paymentDate: toIsoDate(item.payment_date), paymentChannel: item.payment_channel ?? null, paidAt: toIsoDateTime(item.paid_at), paidBy: item.paid_by ?? null, createdAt: toIsoDateTime(item.created_at), updatedAt: toIsoDateTime(item.updated_at), }; } function mapTravelRequestSummary(item) { return { id: item.id, requestNo: item.request_no ?? null, applicantEmployeeId: item.applicant_employee_id, submitterId: item.submitter_id ?? null, departmentId: item.department_id, legalEntityId: item.legal_entity_id, costCenterId: item.cost_center_id, budgetPoolId: item.budget_pool_id ?? null, projectId: item.project_id ?? null, purpose: item.purpose, tripType: item.trip_type, startDate: toIsoDate(item.start_date), endDate: toIsoDate(item.end_date), estimatedAmount: moneyToFixed(item.estimated_amount), status: item.status, createdAt: toIsoDateTime(item.created_at), updatedAt: toIsoDateTime(item.updated_at), applicant: mapEmployeeBasic(item.applicant), submitter: mapEmployeeBasic(item.submitter), department: item.department ? { id: item.department.id, name: item.department.name, } : null, legalEntity: item.legal_entity ? { id: item.legal_entity.id, code: item.legal_entity.code, name: item.legal_entity.name, } : null, costCenter: item.cost_center ? { id: item.cost_center.id, code: item.cost_center.code, name: item.cost_center.name, legalEntityId: item.cost_center.legal_entity_id, } : null, budgetPool: item.budget_pool ? { id: item.budget_pool.id, code: item.budget_pool.code, name: item.budget_pool.name, } : null, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name, } : null, }; } function mapTravelRequestDetail(item, request) { const summary = mapTravelRequestSummary(item); const isApplicant = item.applicant_employee_id === request.employee.id; const canManage = request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("travel.manage"); return { ...summary, segments: item.segments.map(mapTravelSegment), travelOrders: item.travel_orders.map(mapTravelOrder), pendingWorkflow: item.workflow_instances[0] ? { instanceId: item.workflow_instances[0].id, currentStatus: item.workflow_instances[0].current_status, currentStepId: item.workflow_instances[0].current_step_id, task: item.workflow_instances[0].workflow_tasks[0] ? { id: item.workflow_instances[0].workflow_tasks[0].id, stepId: item.workflow_instances[0].workflow_tasks[0].step_id, stepName: item.workflow_instances[0].workflow_tasks[0].step_name, assigneeId: item.workflow_instances[0].workflow_tasks[0].assignee_id ?? null, assigneeRole: item.workflow_instances[0].workflow_tasks[0].assignee_role ?? null, } : null, } : null, permissions: { canEditDraft: item.status === "draft" && isApplicant, canSubmit: item.status === "draft" && isApplicant, canWithdraw: item.status === "reviewing" && isApplicant, canManage, }, }; } function ensureTravelReadPermission(fastify, request) { const canViewOwn = fastify.hasPermission(request.employee, "travel.view_own"); const canViewAll = fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage"); if (!canViewOwn && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return { canViewOwn, canViewAll }; } function canReadTravelRequest(request, travelRequest) { if (!travelRequest) { return false; } if ( request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("travel.view_all") || request.employee.permissionCodes.includes("travel.manage") ) { return true; } return ( travelRequest.applicant_employee_id === request.employee.id || travelRequest.submitter_id === request.employee.id ); } function ensureDraftOwner(request, travelRequest, detailsKey = "travelRequest") { if (travelRequest.applicant_employee_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { [detailsKey]: "only_applicant" }, }); } } async function loadTravelRequestForUpdate(tx, travelRequestId) { const rows = await tx.$queryRaw` SELECT id FROM "travel_requests" WHERE id = ${travelRequestId}::uuid FOR UPDATE `; if (!Array.isArray(rows) || rows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const item = await tx.travelRequest.findUnique({ where: { id: travelRequestId }, select: { id: true, request_no: true, applicant_employee_id: true, submitter_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, budget_pool_id: true, purpose: true, trip_type: true, start_date: true, end_date: true, estimated_amount: true, status: true, applicant: { select: { id: true, name: true, }, }, segments: { orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], select: { id: true }, }, }, }); if (!item) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return item; } async function loadReadableTravelRequest(tx, travelRequestId, request) { const travelRequest = await tx.travelRequest.findUnique({ where: { id: travelRequestId }, select: TRAVEL_DETAIL_SELECT, }); if (!travelRequest) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (!canReadTravelRequest(request, travelRequest)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return travelRequest; } async function createTravelRequestsRoutes(fastify) { fastify.get( "/travel-requests", { preHandler: fastify.authenticate, }, async (request, reply) => { const visibility = ensureTravelReadPermission(fastify, request); const query = parseListQuery(request.query); const mine = visibility.canViewAll ? query.mine : true; const skip = (query.page - 1) * query.pageSize; const where = { ...(query.status ? { status: query.status } : {}), ...(query.tripType ? { trip_type: query.tripType } : {}), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), ...(mine ? { OR: [ { applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }, ], } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.travelRequest.findMany({ where, orderBy: [{ created_at: "desc" }], skip, take: query.pageSize, select: TRAVEL_SUMMARY_SELECT, }), fastify.prisma.travelRequest.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, mine, items: items.map(mapTravelRequestSummary), }); }, ); fastify.post( "/travel-requests", { preHandler: fastify.requirePermission("travel.create"), }, async (request, reply) => { const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body); await ensureCutoverAllowed({ prisma: fastify.prisma, config: fastify.config, request, domain: "travel", departmentId: request.employee.department_id ?? null, legalEntityId: body.legalEntityId, }); const startDate = parseDateOnlyString(body.startDate); const endDate = parseDateOnlyString(body.endDate); ensureDateRangeValid(startDate, endDate); const estimatedAmount = decimalInputToString(body.estimatedAmount, { field: "estimatedAmount", min: 0, }); const segments = normalizeSegments(body.segments); await ensureFinancialDimensions(fastify.prisma, { legalEntityId: body.legalEntityId, costCenterId: body.costCenterId, }); const created = await fastify.prisma.$transaction(async (tx) => { const travelRequest = await tx.travelRequest.create({ data: { applicant_employee_id: request.employee.id, submitter_id: null, department_id: request.employee.department_id, legal_entity_id: body.legalEntityId, cost_center_id: body.costCenterId, purpose: body.purpose, trip_type: body.tripType, start_date: startDate, end_date: endDate, estimated_amount: estimatedAmount, created_by: request.employee.id, updated_by: request.employee.id, segments: { create: segments.map((item) => ({ sort_order: item.sortOrder, segment_role: item.segmentRole, transport_type: item.transportType, from_city: item.fromCity, to_city: item.toCity, depart_at: item.departAt, arrive_at: item.arriveAt, created_by: request.employee.id, updated_by: request.employee.id, })), }, }, select: TRAVEL_DETAIL_SELECT, }); return travelRequest; }); void fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_REQUEST_CREATED", targetType: "travel_request", targetId: created.id, extra: { tripType: created.trip_type, startDate: toIsoDate(created.start_date), endDate: toIsoDate(created.end_date), estimatedAmount: moneyToFixed(created.estimated_amount), segmentCount: created.segments.length, }, }), }).catch((error) => { fastify.log.error( { err: error, travelRequestId: created.id }, "Failed to write travel request create audit log", ); }); return sendData(reply, request, mapTravelRequestDetail(created, request)); }, ); fastify.get( "/travel-requests/:id", { preHandler: fastify.authenticate, }, async (request, reply) => { ensureTravelReadPermission(fastify, request); const travelRequestId = parseTravelRequestId(request.params?.id); const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request); const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ where: { entity_type: "travel_request", entity_id: travelRequestId, status: "open", }, orderBy: [{ created_at: "desc" }, { id: "desc" }], }); return sendData(reply, request, { ...mapTravelRequestDetail(detail, request), riskFlags: riskFlags.map(buildPersistedRiskFlag), }); }, ); fastify.patch( "/travel-requests/:id", { preHandler: fastify.requirePermission("travel.create"), }, async (request, reply) => { const travelRequestId = parseTravelRequestId(request.params?.id); const body = parseBody(TRAVEL_PATCH_BODY_SCHEMA, request.body); if (Object.keys(body).length === 0) { throw buildValidationError({ body: "empty_patch" }); } let response = null; await fastify.prisma.$transaction(async (tx) => { const locked = await loadTravelRequestForUpdate(tx, travelRequestId); ensureDraftOwner(request, locked); if (locked.status !== "draft") { throw buildValidationError({ status: "only_draft_editable" }, 409); } const nextStartDate = Object.prototype.hasOwnProperty.call(body, "startDate") ? parseDateOnlyString(body.startDate) : locked.start_date; const nextEndDate = Object.prototype.hasOwnProperty.call(body, "endDate") ? parseDateOnlyString(body.endDate) : locked.end_date; ensureDateRangeValid(nextStartDate, nextEndDate); const nextLegalEntityId = body.legalEntityId ?? locked.legal_entity_id; const nextCostCenterId = body.costCenterId ?? locked.cost_center_id; await ensureFinancialDimensions(tx, { legalEntityId: nextLegalEntityId, costCenterId: nextCostCenterId, }); const normalizedSegments = Object.prototype.hasOwnProperty.call(body, "segments") ? normalizeSegments(body.segments) : null; await tx.travelRequest.update({ where: { id: travelRequestId }, data: { ...(Object.prototype.hasOwnProperty.call(body, "purpose") ? { purpose: body.purpose } : {}), ...(Object.prototype.hasOwnProperty.call(body, "tripType") ? { trip_type: body.tripType } : {}), ...(Object.prototype.hasOwnProperty.call(body, "startDate") ? { start_date: nextStartDate } : {}), ...(Object.prototype.hasOwnProperty.call(body, "endDate") ? { end_date: nextEndDate } : {}), ...(Object.prototype.hasOwnProperty.call(body, "legalEntityId") ? { legal_entity_id: body.legalEntityId, budget_pool_id: null } : {}), ...(Object.prototype.hasOwnProperty.call(body, "costCenterId") ? { cost_center_id: body.costCenterId, budget_pool_id: null } : {}), ...(Object.prototype.hasOwnProperty.call(body, "estimatedAmount") ? { estimated_amount: decimalInputToString(body.estimatedAmount, { field: "estimatedAmount", min: 0, }), budget_pool_id: null, } : {}), updated_by: request.employee.id, }, }); if (normalizedSegments) { await tx.travelRequestSegment.deleteMany({ where: { travel_request_id: travelRequestId }, }); if (normalizedSegments.length) { await tx.travelRequestSegment.createMany({ data: normalizedSegments.map((item) => ({ travel_request_id: travelRequestId, sort_order: item.sortOrder, segment_role: item.segmentRole, transport_type: item.transportType, from_city: item.fromCity, to_city: item.toCity, depart_at: item.departAt, arrive_at: item.arriveAt, created_by: request.employee.id, updated_by: request.employee.id, })), }); } } const updated = await tx.travelRequest.findUnique({ where: { id: travelRequestId }, select: TRAVEL_DETAIL_SELECT, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_REQUEST_UPDATED", targetType: "travel_request", targetId: travelRequestId, extra: { fields: Object.keys(body), segmentCount: updated?.segments.length ?? locked.segments.length, }, }), }); response = updated; }); return sendData(reply, request, mapTravelRequestDetail(response, request)); }, ); fastify.post( "/travel-requests/:id/precheck", { preHandler: fastify.requirePermission("travel.submit"), }, async (request, reply) => { const travelRequestId = parseTravelRequestId(request.params?.id); const precheck = await resolveEntityPrecheck({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, entityType: "travel_request", entityId: travelRequestId, actorDepartmentId: request.employee.department_id ?? null, }); return sendData(reply, request, precheck); }, ); fastify.post( "/travel-requests/:id/submit", { preHandler: fastify.requirePermission("travel.submit"), }, async (request, reply) => { const travelRequestId = parseTravelRequestId(request.params?.id); let response = null; const emailTasks = []; const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId); ensureDraftOwner(request, travelRequest); await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "travel", departmentId: travelRequest.department_id ?? request.employee.department_id ?? null, legalEntityId: travelRequest.legal_entity_id ?? null, }); if (travelRequest.status !== "draft") { throw buildValidationError({ status: "only_draft_submittable" }, 409); } if (!travelRequest.segments.length) { throw buildValidationError({ segments: "required" }, 409); } const authoritativePrecheck = await resolveEntityPrecheck({ prisma: tx, config: fastify.config, logger: fastify.log, entityType: "travel_request", entityId: travelRequestId, actorDepartmentId: request.employee.department_id ?? null, }); if (authoritativePrecheck.decision === "block") { throw buildPrecheckBlockedError(authoritativePrecheck); } const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { entityType: "travel_request", entityId: travelRequestId, actorId: request.employee.id, precheck: authoritativePrecheck, }); const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "travel_request", is_active: true }, orderBy: { version: "desc" }, select: { id: true, definition_json: true, }, }); if (!workflowDefinition) { throw buildValidationError({ workflow: "missing_definition" }, 409); } const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: travelRequest.applicant_employee_id, logger: fastify.log, }); if (!actionable) { throw buildValidationError({ workflow: "no_actionable_approver" }, 409); } let requestNo = travelRequest.request_no; if (!requestNo) { const issued = await issueDocumentNumber(tx, { documentType: "travel_request", actorId: request.employee.id, timeZone: fastify.config.appTimezone, }); requestNo = issued.documentNumber; } const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { legalEntityId: travelRequest.legal_entity_id, costCenterId: travelRequest.cost_center_id, effectiveDate: travelRequest.start_date, }); const selectedBudgetPoolId = resolvedBudgetPool?.id ?? null; await tx.travelRequest.update({ where: { id: travelRequestId }, data: { request_no: requestNo, submitter_id: request.employee.id, budget_pool_id: selectedBudgetPoolId, status: "reviewing", updated_by: request.employee.id, }, }); const budgetReservation = await reserveBudgetForEntity(tx, { budgetPoolId: selectedBudgetPoolId, entityType: "travel_request", entityId: travelRequestId, amount: travelRequest.estimated_amount, actorId: request.employee.id, note: `submit_reserve:${requestNo ?? travelRequestId}`, }); const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, entity_type: "travel_request", entity_id: travelRequestId, travel_request_id: travelRequestId, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, context_data: { owner_id: travelRequest.applicant_employee_id, department_id: travelRequest.department_id, workflow_steps_snapshot: workflowSteps, legal_entity_id: travelRequest.legal_entity_id, cost_center_id: travelRequest.cost_center_id, request_no: requestNo, trip_type: travelRequest.trip_type, }, context_department_id: travelRequest.department_id, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true }, }); for (const skippedStep of skipped) { await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "skip", payload: { step_id: skippedStep.step.id, reason: skippedStep.reason, owner_id: travelRequest.applicant_employee_id, depth_checked: skippedStep.depthChecked, }, }, }); } const workflowTask = await tx.workflowTask.create({ data: { instance_id: workflowInstance.id, step_id: actionable.step.id, step_name: actionable.step.name ?? actionable.step.id, assignee_id: actionable.assigneeId, assignee_role: actionable.assigneeRole, }, select: { id: true, assignee_id: true, assignee_role: true, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: workflowTask.id, actor_id: request.employee.id, action: "start", payload: { step_id: actionable.step.id, skipped_step_count: skipped.length, }, }, }); const recipients = await resolveRecipientsForAssignee(tx, { assigneeId: workflowTask.assignee_id, assigneeRole: workflowTask.assignee_role, }); const pendingCopy = buildPendingTaskCopy( buildWorkflowEntityMeta( "travel_request", { request_no: requestNo, estimated_amount: travelRequest.estimated_amount, applicant: travelRequest.applicant, }, travelRequestId, ), ); const { title, body } = pendingCopy; const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTask.id, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTask.id, targetPath: `/workflow/tasks/${workflowTask.id}`, actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.workflowTaskAssigned, recipientEmployeeIds: recipients, subject: title, body, targetPath: `/workflow/tasks/${workflowTask.id}`, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_REQUEST_SUBMITTED", targetType: "travel_request", targetId: travelRequestId, extra: { requestNo, estimatedAmount: moneyToFixed(travelRequest.estimated_amount), workflowInstanceId: workflowInstance.id, workflowTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, budgetReservation, }, }), }); response = { travelRequestId, requestNo, status: "reviewing", workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, budgetReservation, precheckDecision: authoritativePrecheck.decision, openedRiskFlagCount: openedRiskFlags.length, }; }); for (const emailTask of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...emailTask, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); return sendData(reply, request, response); }, ); fastify.get( "/travel-requests/:id/payment-track", { preHandler: fastify.authenticate, }, async (request, reply) => { ensureTravelReadPermission(fastify, request); const travelRequestId = parseTravelRequestId(request.params?.id); await loadReadableTravelRequest(fastify.prisma, travelRequestId, request); const track = await buildTravelPaymentTrack(fastify.prisma, travelRequestId, { canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"), }); return sendData(reply, request, track); }, ); fastify.post( "/travel-requests/:id/link-project", { preHandler: fastify.authenticate, }, async (request, reply) => { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409); } const travelRequestId = parseUuid(request.params?.id, "id"); const body = parseBody(z.object({ projectId: UUID_SCHEMA.nullable() }).strict(), request.body); const current = await fastify.prisma.travelRequest.findUnique({ where: { id: travelRequestId }, select: { id: true, applicant_employee_id: true, submitter_id: true, status: true }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!canReadTravelRequest(request, current)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "travel.view_own" } }); } const updated = await fastify.prisma.travelRequest.update({ where: { id: travelRequestId }, data: { project_id: body.projectId ?? null, updated_by: request.employee.id }, select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } }, }); return sendData(reply, request, { item: { travelRequestId: updated.id, projectId: updated.project_id ?? null, project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null, }, }); }, ); fastify.post( "/travel-requests/:id/withdraw", { preHandler: fastify.requirePermission("travel.submit"), }, async (request, reply) => { const travelRequestId = parseTravelRequestId(request.params?.id); let response = null; await fastify.prisma.$transaction(async (tx) => { const locked = await loadTravelRequestForUpdate(tx, travelRequestId); ensureDraftOwner(request, locked); if (locked.status !== "reviewing") { throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409); } const workflowInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "travel_request", entity_id: travelRequestId, current_status: "pending", }, orderBy: { created_at: "desc" }, select: { id: true }, }); if (workflowInstance) { await tx.workflowTask.updateMany({ where: { instance_id: workflowInstance.id, status: "pending", }, data: { status: "cancelled", completed_at: new Date(), completed_by: request.employee.id, }, }); await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "cancelled", completed_at: new Date(), updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "withdraw", payload: { source: "travel_request_withdraw", }, }, }); } const budgetRelease = await releaseReservedBudgetIfAny(tx, { entityType: "travel_request", entityId: travelRequestId, actorId: request.employee.id, note: "withdraw_release_travel_budget", }); await tx.travelRequest.update({ where: { id: travelRequestId }, data: { status: "draft", updated_by: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_REQUEST_WITHDRAWN", targetType: "travel_request", targetId: travelRequestId, extra: { workflowInstanceId: workflowInstance?.id ?? null, budgetRelease, }, }), }); response = { travelRequestId, status: "draft", outcome: "withdrawn", workflowInstanceId: workflowInstance?.id ?? null, budgetRelease, }; }); return sendData(reply, request, response); }, ); } module.exports = createTravelRequestsRoutes; module.exports.__testables = { normalizeSegments, canReadTravelRequest, ensureDateRangeValid, mapTravelRequestSummary, };