Spaces:
Paused
Paused
| 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, | |
| }; | |