const { AppError } = require("../utils/appError"); const { withTransaction } = require("../db/transaction"); function normalizeTripStatus(raw) { if (!raw) { return null; } const value = String(raw).toUpperCase(); if (!["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"].includes(value)) { throw new AppError("status must be PLANNED, IN_PROGRESS, COMPLETED, or CANCELLED", 400); } return value; } function buildTripCode() { const now = new Date(); const stamp = now .toISOString() .replace(/[-:]/g, "") .replace(/\..*$/, "") .replace("T", ""); const suffix = Math.random().toString(36).slice(2, 6).toUpperCase(); return `TRIP-${stamp}-${suffix}`; } function toLatLon(value) { if (value === undefined || value === null || value === "") { return null; } const parsed = Number(value); if (!Number.isFinite(parsed)) { throw new AppError("Invalid coordinate value", 400); } return parsed; } function normalizeCargoType(raw) { return String(raw || "") .trim() .toUpperCase() .replace(/[^A-Z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); } function createTripsService(deps) { const { pool, tripsRepository, assetRepository, auditRepository, alertsRepository, tripSummaryAiService, } = deps; async function listTrips(query, context) { const tenantCode = context?.tenantCode || null; const status = normalizeTripStatus(query.status); return tripsRepository.listTrips(pool, { tenantCode, status, truckCode: query.truckCode || null, containerCode: query.containerCode || null, managerUserId: context?.managerUserId || null, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function createTrip(input, context) { if (!input || typeof input !== "object") { throw new AppError("Request body must be a JSON object", 400); } const tenantCode = (context?.tenantCode || input.tenantCode || "").toString().trim(); if (!tenantCode && !context?.isSuperAdmin) { throw new AppError("tenantCode is required", 400); } const truckCode = String(input.truckCode || "").trim(); const containerCode = String(input.containerCode || "").trim(); const originName = String(input.originName || "").trim(); const destinationName = String(input.destinationName || "").trim(); const cargoTypeInput = String(input.cargoType || "").trim(); const goodsDescription = String(input.goodsDescription || "").trim(); if (!truckCode || !containerCode) { throw new AppError("truckCode and containerCode are required", 400); } if (!originName || !destinationName) { throw new AppError("originName and destinationName are required", 400); } if (!cargoTypeInput) { throw new AppError("cargoType is required", 400); } const cargoProfile = tripSummaryAiService?.resolveCargoProfile ? tripSummaryAiService.resolveCargoProfile(cargoTypeInput) : { code: normalizeCargoType(cargoTypeInput) || "GENERAL_CARGO", label: cargoTypeInput, prioritySignals: ["temperature", "humidity", "gas", "shock", "gps_fix"], }; const originLat = toLatLon(input.originLat); const originLon = toLatLon(input.originLon); const destinationLat = toLatLon(input.destinationLat); const destinationLon = toLatLon(input.destinationLon); const plannedStartAt = input.plannedStartAt ? new Date(input.plannedStartAt).toISOString() : null; const plannedEndAt = input.plannedEndAt ? new Date(input.plannedEndAt).toISOString() : null; const tripCode = input.tripCode ? String(input.tripCode).trim() : buildTripCode(); return withTransaction(pool, async (client) => { const contextRow = await assetRepository.resolveAssetContextByCodes(client, { tenantCode, truckCode, containerCode, }); if (!contextRow) { throw new AppError("Truck/container mapping not found", 404); } if (context?.managerUserId) { const assignment = await tripsRepository.getActiveManagerAssignment(client, { tenantId: contextRow.tenant_id, containerId: contextRow.container_id, managerUserId: context.managerUserId, }); if (!assignment) { throw new AppError("Container is not assigned to this fleet manager", 403); } } const activeTrip = await tripsRepository.getActiveTripByAsset(client, { tenantId: contextRow.tenant_id, truckId: contextRow.truck_id, containerId: contextRow.container_id, }); if (activeTrip) { throw new AppError("An active trip already exists for this truck/container", 409); } let created; try { created = await tripsRepository.createTrip(client, { tenantId: contextRow.tenant_id, tripCode, fleetId: contextRow.fleet_id, truckId: contextRow.truck_id, containerId: contextRow.container_id, routeId: null, originName, destinationName, plannedStartAt, plannedEndAt, status: "PLANNED", metadata: { origin: { name: originName, lat: originLat, lon: originLon, }, destination: { name: destinationName, lat: destinationLat, lon: destinationLon, }, cargo: { cargoType: cargoProfile.code, cargoLabel: cargoProfile.label, goodsDescription: goodsDescription || null, prioritySignals: cargoProfile.prioritySignals, }, }, }); } catch (error) { if (error && error.code === "23505") { throw new AppError("Trip code already exists", 409); } throw error; } if (auditRepository) { await auditRepository.insertAuditLog(client, { tenantId: contextRow.tenant_id, actorUserId: context?.actorUserId || null, action: "TRIP_CREATE", targetType: "trip", targetId: created.id, metadata: { tripCode, truckCode, containerCode, originName, destinationName, cargoType: cargoProfile.code, goodsDescription: goodsDescription || null, }, ipAddress: context?.ipAddress || null, userAgent: context?.userAgent || null, }); } return created; }); } async function startTrip(tripId, context) { if (!tripId) { throw new AppError("tripId is required", 400); } const tenantCode = (context?.tenantCode || context?.tenantCodeOverride || "") .toString() .trim(); if (!tenantCode && !context?.isSuperAdmin) { throw new AppError("tenantCode is required", 400); } return withTransaction(pool, async (client) => { const existing = await tripsRepository.getTripById(client, { tripId, tenantCode, managerUserId: context?.managerUserId || null, }); if (!existing) { throw new AppError("Trip not found", 404); } if (existing.status !== "PLANNED") { throw new AppError("Trip cannot be started from current status", 409); } const updated = await tripsRepository.startTrip(client, { tripId, tenantId: existing.tenant_id, }); if (!updated) { throw new AppError("Trip not found", 404); } if (auditRepository) { await auditRepository.insertAuditLog(client, { tenantId: existing.tenant_id, actorUserId: context?.actorUserId || null, action: "TRIP_START", targetType: "trip", targetId: existing.id, metadata: { tripCode: existing.trip_code, }, ipAddress: context?.ipAddress || null, userAgent: context?.userAgent || null, }); } return updated; }); } async function completeTrip(tripId, context) { if (!tripId) { throw new AppError("tripId is required", 400); } const tenantCode = (context?.tenantCode || context?.tenantCodeOverride || "") .toString() .trim(); if (!tenantCode && !context?.isSuperAdmin) { throw new AppError("tenantCode is required", 400); } return withTransaction(pool, async (client) => { const existing = await tripsRepository.getTripById(client, { tripId, tenantCode, managerUserId: context?.managerUserId || null, }); if (!existing) { throw new AppError("Trip not found", 404); } if (existing.status !== "IN_PROGRESS") { throw new AppError("Trip cannot be completed from current status", 409); } const updated = await tripsRepository.completeTrip(client, { tripId, tenantId: existing.tenant_id, }); if (!updated) { throw new AppError("Trip not found", 404); } const metadata = existing.metadata_json && typeof existing.metadata_json === "object" ? existing.metadata_json : {}; const cargo = metadata.cargo || { cargoType: "GENERAL_CARGO", cargoLabel: "General cargo", goodsDescription: null, prioritySignals: ["temperature", "humidity", "gas", "shock", "gps_fix"], }; const metrics = await tripsRepository.getTripTelemetryAggregate(client, { tenantId: existing.tenant_id, tripId: existing.id, }); const alertSummary = alertsRepository ? await alertsRepository.getAlertSummaryByTrip(client, { tenantId: existing.tenant_id, tripId: existing.id, }) : { count: 0, bySeverity: {} }; let finalTrip = updated; if (tripSummaryAiService?.generateTripSummary) { const aiSummary = await tripSummaryAiService.generateTripSummary({ cargoType: cargo.cargoType, goodsDescription: cargo.goodsDescription || null, metrics, alertSummary, }); const patched = await tripsRepository.updateTripMetadata(client, { tripId: existing.id, tenantId: existing.tenant_id, patch: { aiSummary, cargo, }, }); if (patched) { finalTrip = patched; } } if (auditRepository) { await auditRepository.insertAuditLog(client, { tenantId: existing.tenant_id, actorUserId: context?.actorUserId || null, action: "TRIP_COMPLETE", targetType: "trip", targetId: existing.id, metadata: { tripCode: existing.trip_code, cargoType: cargo.cargoType, }, ipAddress: context?.ipAddress || null, userAgent: context?.userAgent || null, }); } return finalTrip; }); } return { listTrips, createTrip, startTrip, completeTrip, }; } module.exports = { createTripsService, };