cargo / src /services /tripsService.js
vish85521's picture
Upload 53 files
964569f verified
Raw
History Blame Contribute Delete
11.2 kB
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,
};