src/loading-bays/loading-bays.service.ts
Service responsible for loading bay CRUD, occupancy management, and capacity planning within a tenant.
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Defined in src/loading-bays/loading-bays.service.ts:46
|
||||||
|
Parameters :
|
| Async assignVehicle | ||||||||||||||||
assignVehicle(organizationId: string, id: string, vehicleId: string)
|
||||||||||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:149
|
||||||||||||||||
|
Assign a vehicle to a loading bay, marking it as OCCUPIED. Uses serialisable transaction isolation to prevent two dispatchers from simultaneously assigning the same bay. Validates that the bay is AVAILABLE (not OCCUPIED or under MAINTENANCE) and the vehicle belongs to this organisation.
Parameters :
Returns :
unknown
The updated loading bay record. |
| Async bulkUploadFromFile | ||||||||||||
bulkUploadFromFile(organizationId: string, file: Express.Multer.File)
|
||||||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:420
|
||||||||||||
|
Bulk import loading bays from an Excel or CSV file. Parses the file using the standard column mappings, skips rows without a code and rows that already exist (by code, case-insensitive), and creates new bay records for the rest.
Parameters :
Returns :
Promise<BulkUploadResult>
Bulk upload result with counts of created, skipped, and errored rows. |
| Async create | ||||||||||||
create(organizationId: string, dto: CreateLoadingBayDto)
|
||||||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:57
|
||||||||||||
|
Create a new loading bay.
Parameters :
Returns :
unknown
The created loading bay record. |
| Async findAll | ||||||||||||
findAll(organizationId: string, params: PaginationParams)
|
||||||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:82
|
||||||||||||
|
List loading bays with pagination and optional search by name or code.
Parameters :
Returns :
unknown
Paginated result with |
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/loading-bays/loading-bays.service.ts:107
|
|
Org-scoped lookup. Cross-tenant ID-guessing impossible.
Returns :
unknown
|
| Async getShiftCapacity | ||||||||
getShiftCapacity(organizationId: string)
|
||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:373
|
||||||||
|
Calculate the organisation's loading bay shift capacity. Returns aggregate metrics for capacity planning: total shift capacity (configured per bay), total tonnage per hour, number of active and available bays, and an estimated trucks-per-shift figure based on a 45-minute average load time over an 8-hour shift.
Parameters :
Returns :
unknown
Capacity metrics for the loading bay dashboard. |
| Async getStatus | ||||||||
getStatus(organizationId: string)
|
||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:217
|
||||||||
|
Get the real-time status of all active loading bays with enriched data. Each bay is enriched with:
Also returns a summary with total/available/occupied/maintenance counts for dashboard KPIs.
Parameters :
Returns :
unknown
Object with |
| Async releaseBay | ||||||||||||
releaseBay(organizationId: string, id: string)
|
||||||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:188
|
||||||||||||
|
Release an occupied bay, setting it back to AVAILABLE.
Parameters :
Returns :
unknown
The updated loading bay record. |
| Async remove |
remove(organizationId: string, id: string)
|
|
Defined in src/loading-bays/loading-bays.service.ts:123
|
|
Returns :
unknown
|
| Async update | ||||||||||||
update(organizationId: string, id: string, dto: UpdateLoadingBayDto)
|
||||||||||||
|
Defined in src/loading-bays/loading-bays.service.ts:115
|
||||||||||||
|
Parameters :
Returns :
unknown
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateLoadingBayDto } from './dto/create-loading-bay.dto';
import { UpdateLoadingBayDto } from './dto/update-loading-bay.dto';
import {
buildPaginationQuery,
buildPaginationMeta,
PaginationParams,
} from '../common/utils/pagination.util';
import { BulkUploadService, LOADING_BAY_MAPPINGS, BulkUploadResult } from '../common/services/bulk-upload.service';
/**
* Service responsible for loading bay CRUD, occupancy management, and
* capacity planning within a tenant.
*
* @dependencies
* - {@link PrismaService} — tenant-aware database access
* - {@link BulkUploadService} — Excel/CSV file parsing for bulk import
*/
@Injectable()
export class LoadingBaysService {
private readonly logger = new Logger(LoadingBaysService.name);
private readonly bulkUpload = new BulkUploadService();
constructor(private prisma: PrismaService) {}
/**
* Create a new loading bay.
*
* @param organizationId - The tenant's organisation ID.
* @param dto - Bay configuration including name, code, GPS coordinates, capacity, and category.
* @returns The created loading bay record.
*/
async create(organizationId: string, dto: CreateLoadingBayDto) {
return this.prisma.loadingBay.create({
data: {
organizationId,
name: dto.name,
code: dto.code,
siteId: dto.siteId,
siteName: dto.siteName,
capacity: dto.capacity,
shiftCapacity: dto.shiftCapacity,
category: dto.category,
latitude: dto.latitude,
longitude: dto.longitude,
radiusMeters: dto.radiusMeters ?? 50,
},
});
}
/**
* List loading bays with pagination and optional search by name or code.
*
* @param organizationId - The tenant's organisation ID.
* @param params - Pagination, sorting, and search parameters.
* @returns Paginated result with `data` array and `meta` pagination info.
*/
async findAll(organizationId: string, params: PaginationParams) {
const { skip, take, orderBy, page, limit } = buildPaginationQuery(params);
const where: any = { organizationId };
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ code: { contains: params.search, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
this.prisma.loadingBay.findMany({
where,
skip,
take,
orderBy,
}),
this.prisma.loadingBay.count({ where }),
]);
return { data: items, meta: buildPaginationMeta(total, page, limit) };
}
/** Org-scoped lookup. Cross-tenant ID-guessing impossible. */
async findOne(organizationId: string, id: string) {
const bay = await this.prisma.loadingBay.findFirst({
where: { id, organizationId },
});
if (!bay) throw new NotFoundException('Loading bay not found');
return bay;
}
async update(organizationId: string, id: string, dto: UpdateLoadingBayDto) {
await this.findOne(organizationId, id);
return this.prisma.loadingBay.update({
where: { id },
data: dto,
});
}
async remove(organizationId: string, id: string) {
const bay = await this.findOne(organizationId, id);
if (bay.status === 'OCCUPIED') {
throw new BadRequestException(
`Bay ${bay.name} is currently occupied. Release it before deleting.`,
);
}
await this.prisma.loadingBay.delete({ where: { id } });
return { deleted: true };
}
/**
* Assign a vehicle to a loading bay, marking it as OCCUPIED.
*
* Uses serialisable transaction isolation to prevent two dispatchers
* from simultaneously assigning the same bay. Validates that the bay
* is AVAILABLE (not OCCUPIED or under MAINTENANCE) and the vehicle
* belongs to this organisation.
*
* @param organizationId - The tenant's organisation ID.
* @param id - The loading bay's database ID.
* @param vehicleId - The vehicle to assign.
* @returns The updated loading bay record.
* @throws {BadRequestException} Bay is already occupied or under maintenance.
* @throws {NotFoundException} Bay or vehicle not found.
*/
async assignVehicle(organizationId: string, id: string, vehicleId: string) {
// Wrap in serializable transaction so two dispatchers can't both
// win the race to assign the same bay simultaneously.
return this.prisma.$transaction(async (tx) => {
const bay = await tx.loadingBay.findFirst({ where: { id, organizationId } });
if (!bay) throw new NotFoundException('Loading bay not found');
if (bay.status === 'OCCUPIED') {
throw new BadRequestException(
`Bay ${bay.name} is already occupied by vehicle ${bay.currentVehicleId}`,
);
}
if (bay.status === 'MAINTENANCE') {
throw new BadRequestException(`Bay ${bay.name} is under maintenance`);
}
// Vehicle must belong to the same org
const vehicle = await tx.vehicle.findFirst({
where: { id: vehicleId, organizationId },
});
if (!vehicle) throw new NotFoundException('Vehicle not found');
return tx.loadingBay.update({
where: { id },
data: {
status: 'OCCUPIED',
currentVehicleId: vehicleId,
},
});
}, { isolationLevel: 'Serializable' });
}
/**
* Release an occupied bay, setting it back to AVAILABLE.
*
* @param organizationId - The tenant's organisation ID.
* @param id - The loading bay's database ID.
* @returns The updated loading bay record.
* @throws {BadRequestException} Bay is not currently occupied.
*/
async releaseBay(organizationId: string, id: string) {
const bay = await this.findOne(organizationId, id);
if (bay.status !== 'OCCUPIED') {
throw new BadRequestException(`Bay ${bay.name} is not currently occupied`);
}
return this.prisma.loadingBay.update({
where: { id },
data: {
status: 'AVAILABLE',
currentVehicleId: null,
},
});
}
/**
* Get the real-time status of all active loading bays with enriched data.
*
* Each bay is enriched with:
* - The currently assigned vehicle (plate, type, driver name/phone)
* - The active trip (number, status, order list with loading progress)
* - A `loadingConfirmed` flag indicating all orders are loaded
*
* Also returns a summary with total/available/occupied/maintenance counts
* for dashboard KPIs.
*
* @param organizationId - The tenant's organisation ID.
* @returns Object with `bays` (enriched array) and `summary` counts.
*/
async getStatus(organizationId: string) {
const bays = await this.prisma.loadingBay.findMany({
where: { organizationId, isActive: true },
orderBy: { name: 'asc' },
});
// Enrich bays: OCCUPIED bays get full trip/vehicle data.
// AVAILABLE bays only get enriched if they have a DISPATCHED trip
// assigned (not PLANNED — those are just reservations).
const enrichedBays = await Promise.all(
bays.map(async (bay) => {
// Skip enrichment for AVAILABLE bays with no active trip
if (bay.status !== 'OCCUPIED') {
// Check if there's a DISPATCHED/IN_TRANSIT trip for this bay
const scheduledTrip = await this.prisma.trip.findFirst({
where: {
loadingBayId: bay.id,
status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] },
},
select: { id: true, tripNumber: true, status: true, vehicleId: true },
});
if (!scheduledTrip) return bay;
// Fall through to full enrichment for dispatched trips
}
// currentVehicleId may be null in the brief window after dispatch before first confirm
const vehicleId = bay.currentVehicleId;
const vehicle = vehicleId ? await this.prisma.vehicle.findUnique({
where: { id: vehicleId },
include: {
driver: { select: { id: true, firstName: true, lastName: true, phone: true, licenseNumber: true } },
},
}) : null;
// If vehicle not found from currentVehicleId, try to get it from the active trip
let resolvedVehicleId = vehicleId;
// Find the active trip for this bay — query by loadingBayId first (most reliable),
// fall back to vehicleId so bays occupied before the fix still work
let activeTrip: any = await this.prisma.trip.findFirst({
where: {
loadingBayId: bay.id,
status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP', 'PLANNED'] },
},
include: {
orders: {
select: {
id: true, orderNumber: true, status: true, loadingConfirmed: true,
destName: true, destAddress: true, weight: true, commodity: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
if (!activeTrip) {
activeTrip = await this.prisma.trip.findFirst({
where: {
vehicleId: bay.currentVehicleId,
status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP', 'PLANNED'] },
},
include: {
orders: {
select: {
id: true, orderNumber: true, status: true, loadingConfirmed: true,
destName: true, destAddress: true, weight: true, commodity: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
// If currentVehicleId was null but trip has a vehicle, sync it back
if (!vehicle && activeTrip?.vehicleId) {
resolvedVehicleId = activeTrip.vehicleId;
// Patch the bay so future reads are correct
await this.prisma.loadingBay.update({
where: { id: bay.id },
data: { currentVehicleId: activeTrip.vehicleId },
}).catch(() => {});
}
// Load vehicle from trip if not already loaded
const resolvedVehicle = vehicle ?? (resolvedVehicleId && resolvedVehicleId !== vehicleId
? await this.prisma.vehicle.findUnique({
where: { id: resolvedVehicleId },
include: {
driver: { select: { id: true, firstName: true, lastName: true, phone: true, licenseNumber: true } },
},
})
: null);
const tripOrders: any[] = activeTrip?.orders ?? [];
const loadingConfirmed = tripOrders.length > 0 && tripOrders.every((o: any) => o.loadingConfirmed);
return {
...bay,
vehicle: resolvedVehicle
? {
id: resolvedVehicle.id,
plateNumber: resolvedVehicle.licensePlate,
unitNumber: resolvedVehicle.unitNumber,
type: resolvedVehicle.type,
driverName: resolvedVehicle.driver
? `${resolvedVehicle.driver.firstName} ${resolvedVehicle.driver.lastName}`
: null,
driverPhone: resolvedVehicle.driver?.phone ?? null,
driverId: resolvedVehicle.driver?.id ?? null,
}
: null,
trip: activeTrip
? {
id: activeTrip.id,
tripNumber: activeTrip.tripNumber ?? activeTrip.id.slice(0, 8).toUpperCase(),
status: activeTrip.status,
orders: tripOrders.map((o: any) => ({
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
loadingConfirmed: o.loadingConfirmed,
clientName: o.destName,
deliveryAddress: o.destAddress,
totalWeight: o.weight,
commodity: o.commodity,
})),
totalOrders: tripOrders.length,
loadedOrders: tripOrders.filter((o: any) => o.loadingConfirmed).length,
}
: null,
loadingConfirmed,
};
}),
);
const summary = {
total: bays.length,
available: bays.filter((b) => b.status === 'AVAILABLE').length,
occupied: bays.filter((b) => b.status === 'OCCUPIED').length,
maintenance: bays.filter((b) => b.status === 'MAINTENANCE').length,
};
return { bays: enrichedBays, summary };
}
/**
* Calculate the organisation's loading bay shift capacity.
*
* Returns aggregate metrics for capacity planning: total shift capacity
* (configured per bay), total tonnage per hour, number of active and
* available bays, and an estimated trucks-per-shift figure based on a
* 45-minute average load time over an 8-hour shift.
*
* @param organizationId - The tenant's organisation ID.
* @returns Capacity metrics for the loading bay dashboard.
*/
async getShiftCapacity(organizationId: string) {
const bays = await this.prisma.loadingBay.findMany({
where: { organizationId, isActive: true, status: { not: 'MAINTENANCE' } },
});
const totalShiftCapacity = bays.reduce(
(sum, bay) => sum + (bay.shiftCapacity || 0),
0,
);
const totalTonnagePerHour = bays.reduce(
(sum, bay) => sum + (bay.capacity || 0),
0,
);
const activeBays = bays.length;
const availableBays = bays.filter((b) => b.status === 'AVAILABLE').length;
// Estimate: average 45min per truck load
const avgLoadTimeMinutes = 45;
const shiftHours = 8;
const estimatedTrucksPerShift = Math.floor(
(activeBays * shiftHours * 60) / avgLoadTimeMinutes,
);
return {
activeBays,
availableBays,
totalShiftCapacity,
totalTonnagePerHour,
estimatedTrucksPerShift,
avgLoadTimeMinutes,
shiftHours,
};
}
/**
* Bulk import loading bays from an Excel or CSV file.
*
* Parses the file using the standard column mappings, skips rows without
* a code and rows that already exist (by code, case-insensitive), and
* creates new bay records for the rest.
*
* @param organizationId - The tenant's organisation ID.
* @param file - The uploaded file (Multer format with buffer).
* @returns Bulk upload result with counts of created, skipped, and errored rows.
*/
async bulkUploadFromFile(organizationId: string, file: Express.Multer.File): Promise<BulkUploadResult> {
const { rows, errors } = this.bulkUpload.parseFile(file.buffer, file.originalname, LOADING_BAY_MAPPINGS);
const created: Record<string, unknown>[] = [];
let skipped = 0;
for (const row of rows) {
const code = String(row.code || '').trim().toUpperCase();
if (!code) { skipped++; continue; }
const existing = await this.prisma.loadingBay.findFirst({
where: { organizationId, code },
});
if (existing) { skipped++; continue; }
const status = String(row.status || 'AVAILABLE').toUpperCase();
const validStatuses = ['AVAILABLE', 'OCCUPIED', 'MAINTENANCE'];
const loadingBay = await this.prisma.loadingBay.create({
data: {
organizationId,
name: String(row.name || '').trim(),
code,
siteName: (row.siteName as string) || undefined,
status: validStatuses.includes(status) ? status : 'AVAILABLE',
},
});
created.push(loadingBay);
}
this.logger.log(`Bulk loading bay upload: ${created.length} created, ${skipped} skipped, ${errors.length} errors`);
return { created: created.length, skipped, errors, records: created, totalRows: rows.length };
}
}