src/orders/orders.service.ts
Service responsible for the full order lifecycle within a tenant.
Provides CRUD operations, state machine transitions, geofence-validated loading confirmation, missing-field waiver workflow, and vehicle assignment with weight capacity checks.
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Defined in src/orders/orders.service.ts:51
|
||||||
|
Parameters :
|
| Async assignVehicle | |||||||||||||||
assignVehicle(orderId: string, vehicleId: string, organizationId: string, userId: string)
|
|||||||||||||||
|
Defined in src/orders/orders.service.ts:669
|
|||||||||||||||
|
Manually assign a vehicle to an order. Creates a trip if needed.
Parameters :
Returns :
unknown
|
| Async bulkCreate | ||||||||||||||||
bulkCreate(organizationId: string, orders: CreateOrderDto[], userId?: string)
|
||||||||||||||||
|
Defined in src/orders/orders.service.ts:143
|
||||||||||||||||
|
Create multiple orders sequentially. Each order is individually validated and assigned an order number.
Parameters :
Returns :
unknown
Array of created orders. |
| Async bulkSendToJobs |
bulkSendToJobs(organizationId: string, userId: string)
|
|
Defined in src/orders/orders.service.ts:805
|
|
Bulk send all eligible orders to Jobs. Only orders with 0 missing fields get sent. The rest are rejected with reasons.
Returns :
unknown
|
| Async confirmLoading | ||||||||||||||||||||||||
confirmLoading(id: string, userId: string, organizationId: string, latitude?: number, longitude?: number)
|
||||||||||||||||||||||||
|
Defined in src/orders/orders.service.ts:428
|
||||||||||||||||||||||||
|
Confirm that an order has been physically loaded onto a vehicle. Enforces GPS geofence validation: the driver must be within the loading bay's configured radius (default 50m) to confirm. This prevents remote confirmation fraud. Side effects on success:
Parameters :
Returns :
unknown
The updated order. |
| Async create | ||||||||||||||||
create(organizationId: string, dto: CreateOrderDto, userId?: string)
|
||||||||||||||||
|
Defined in src/orders/orders.service.ts:67
|
||||||||||||||||
|
Create a new order within the given organisation. Automatically detects missing required fields (origin, destination,
commodity, weight/pieces, delivery date) and marks the order as
Parameters :
Returns :
unknown
The created order with client relation included. |
| Async findAll | ||||||||||||
findAll(organizationId: string, filters: OrderFilterDto)
|
||||||||||||
|
Defined in src/orders/orders.service.ts:167
|
||||||||||||
|
List orders with pagination, filtering, and full-text search. Supports filtering by status, priority, source, clientId, date range, and a free-text search across orderNumber, referenceNumber, originCity, destCity, and commodity fields.
Parameters :
Returns :
unknown
Paginated result with |
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/orders/orders.service.ts:222
|
|
Org-scoped lookup. findFirst with organizationId in the where clause makes cross-tenant ID-guessing physically impossible.
Returns :
unknown
|
| Async getJobs | ||||||||
getJobs(organizationId: string)
|
||||||||
|
Defined in src/orders/orders.service.ts:615
|
||||||||
|
Retrieve orders ready for the Jobs/Tasks planner board. Returns orders with
Parameters :
Returns :
unknown
Object with |
| Async getStats | ||||||||
getStats(organizationId: string)
|
||||||||
|
Defined in src/orders/orders.service.ts:390
|
||||||||
|
Get order count statistics grouped by status for the dashboard KPIs.
Parameters :
Returns :
unknown
Object with |
| Async remove | ||||||||||||
remove(organizationId: string, id: string)
|
||||||||||||
|
Defined in src/orders/orders.service.ts:371
|
||||||||||||
|
Delete an order. Blocked if the order is on an active trip (ASSIGNED, LOADED, or IN_TRANSIT) to prevent silent data loss on the dispatch board.
Parameters :
Returns :
unknown
|
| Async sendToJobs |
sendToJobs(organizationId: string, orderId: string, userId: string)
|
|
Defined in src/orders/orders.service.ts:773
|
|
Send order to Jobs — only if all missing fields are filled or waived.
Returns :
unknown
|
| Async update | ||||||||||||||||||||||||
update(organizationId: string, id: string, dto: UpdateOrderDto, userId?: string, role?: string)
|
||||||||||||||||||||||||
|
Defined in src/orders/orders.service.ts:253
|
||||||||||||||||||||||||
|
Update an existing order. Handles status transitions via the order state machine, event sourcing for audit trail, and driver self-service guards that restrict which fields a DRIVER role can modify (only status, jobStatus, loadingConfirmed, specialInstructions on their own trips).
Parameters :
Returns :
unknown
The updated order with client relation. |
| Async updateJobStatus | ||||||||||||||||||||
updateJobStatus(organizationId: string, id: string, newJobStatus: string, userId?: string)
|
||||||||||||||||||||
|
Defined in src/orders/orders.service.ts:642
|
||||||||||||||||||||
|
Advance an order's job status through the planner workflow. Valid transitions:
Parameters :
Returns :
unknown
The updated order with client relation. |
| Async waiveField | |||||||||||||||||||||
waiveField(organizationId: string, orderId: string, field: string, userId: string, userName: string, reason?: string)
|
|||||||||||||||||||||
|
Defined in src/orders/orders.service.ts:729
|
|||||||||||||||||||||
|
Waive a missing field — mark as intentionally skipped with who/when/why.
Parameters :
Returns :
unknown
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
import { OrderFilterDto } from './dto/order-filter.dto';
import {
buildPaginationQuery,
buildPaginationMeta,
} from '../common/utils/pagination.util';
import { assertOrderStatusTransition } from '../common/state-machines';
/**
* Service responsible for the full order lifecycle within a tenant.
*
* Provides CRUD operations, state machine transitions, geofence-validated
* loading confirmation, missing-field waiver workflow, and vehicle assignment
* with weight capacity checks.
*
* @dependencies
* - {@link PrismaService} — tenant-aware database access (RLS-proxied)
*/
@Injectable()
export class OrdersService {
constructor(private prisma: PrismaService) {}
/**
* Create a new order within the given organisation.
*
* Automatically detects missing required fields (origin, destination,
* commodity, weight/pieces, delivery date) and marks the order as
* `INCOMPLETE` if any are absent. This drives the review/waiver UI
* in the web platform.
*
* @param organizationId - The tenant's organisation ID.
* @param dto - Order creation payload.
* @param userId - ID of the user creating the order (for audit trail).
* @returns The created order with client relation included.
*/
async create(organizationId: string, dto: CreateOrderDto, userId?: string) {
const orderNumber = await this.generateOrderNumber(organizationId);
// Compute missing fields so the order enters the review workflow
// with the waive tab and Send to Tasks button visible
const missingFields: string[] = [];
if (!dto.originAddress && !dto.originName) missingFields.push('originAddress');
if (!dto.destAddress && !dto.destName) missingFields.push('destAddress');
if (!dto.commodity) missingFields.push('commodity');
if (!dto.weight && !dto.pieces && !dto.pallets) missingFields.push('weight/pieces');
if (!dto.deliveryDate) missingFields.push('deliveryDate');
const order = await this.prisma.order.create({
data: {
organizationId,
clientId: dto.clientId,
orderNumber,
referenceNumber: dto.referenceNumber,
priority: dto.priority || 'NORMAL',
vehicleType: dto.vehicleType,
originName: dto.originName,
originAddress: dto.originAddress,
originCity: dto.originCity,
originState: dto.originState,
originZip: dto.originZip,
originLat: dto.originLat,
originLng: dto.originLng,
destName: dto.destName,
destAddress: dto.destAddress,
destCity: dto.destCity,
destState: dto.destState,
destZip: dto.destZip,
destLat: dto.destLat,
destLng: dto.destLng,
pickupDate: dto.pickupDate ? new Date(dto.pickupDate) : undefined,
deliveryDate: dto.deliveryDate ? new Date(dto.deliveryDate) : undefined,
commodity: dto.commodity,
weight: dto.weight,
volume: dto.volume,
pieces: dto.pieces,
pallets: dto.pallets,
specialInstructions: dto.specialInstructions,
quoteAmount: dto.quoteAmount,
lpoNumber: dto.lpoNumber,
plantName: dto.plantName,
distributorName: dto.distributorName,
laneId: dto.laneId,
status: 'PENDING_VALIDATION',
jobStatus: missingFields.length > 0 ? 'INCOMPLETE' : 'IMPORTED',
missingFields: missingFields,
},
include: { client: true },
});
// Create initial order event
await this.prisma.orderEvent.create({
data: {
orderId: order.id,
toStatus: 'DRAFT',
userId,
reason: 'Order created',
},
});
return order;
}
/**
* Create multiple orders sequentially. Each order is individually validated
* and assigned an order number.
*
* @param organizationId - The tenant's organisation ID.
* @param orders - Array of order creation payloads.
* @param userId - ID of the user creating the orders.
* @returns Array of created orders.
*/
async bulkCreate(
organizationId: string,
orders: CreateOrderDto[],
userId?: string,
) {
const results = [];
for (const dto of orders) {
const order = await this.create(organizationId, dto, userId);
results.push(order);
}
return results;
}
/**
* List orders with pagination, filtering, and full-text search.
*
* Supports filtering by status, priority, source, clientId, date range,
* and a free-text search across orderNumber, referenceNumber, originCity,
* destCity, and commodity fields.
*
* @param organizationId - The tenant's organisation ID.
* @param filters - Pagination, sorting, and filter parameters.
* @returns Paginated result with `data` array and `meta` pagination info.
*/
async findAll(organizationId: string, filters: OrderFilterDto) {
const { skip, take, orderBy, page, limit } = buildPaginationQuery(filters);
const where: Prisma.OrderWhereInput = { organizationId };
if (filters.status) where.status = filters.status;
if (filters.priority) where.priority = filters.priority;
if (filters.source) where.source = filters.source;
if (filters.clientId) where.clientId = filters.clientId;
if (filters.dateFrom || filters.dateTo) {
where.createdAt = {};
if (filters.dateFrom) where.createdAt.gte = new Date(filters.dateFrom);
if (filters.dateTo) where.createdAt.lte = new Date(filters.dateTo);
}
if (filters.search) {
where.OR = [
{ orderNumber: { contains: filters.search, mode: 'insensitive' } },
{ referenceNumber: { contains: filters.search, mode: 'insensitive' } },
{ originCity: { contains: filters.search, mode: 'insensitive' } },
{ destCity: { contains: filters.search, mode: 'insensitive' } },
{ commodity: { contains: filters.search, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
skip,
take,
orderBy,
include: {
client: { select: { id: true, name: true, code: true } },
trip: {
select: {
id: true,
tripNumber: true,
status: true,
vehicle: { select: { id: true, unitNumber: true, licensePlate: true, status: true } },
driver: { select: { id: true, firstName: true, lastName: true, phone: true } },
},
},
},
}),
this.prisma.order.count({ where }),
]);
return { data: items, meta: buildPaginationMeta(total, page, limit) };
}
/**
* Org-scoped lookup. findFirst with organizationId in the where clause
* makes cross-tenant ID-guessing physically impossible.
*/
async findOne(organizationId: string, id: string) {
const order = await this.prisma.order.findFirst({
where: { id, organizationId },
include: {
client: true,
trip: true,
events: { orderBy: { timestamp: 'desc' } },
documents: true,
},
});
if (!order) throw new NotFoundException('Order not found');
return order;
}
/**
* Update an existing order.
*
* Handles status transitions via the order state machine, event sourcing
* for audit trail, and driver self-service guards that restrict which
* fields a DRIVER role can modify (only status, jobStatus,
* loadingConfirmed, specialInstructions on their own trips).
*
* @param organizationId - The tenant's organisation ID.
* @param id - The order's database ID.
* @param dto - Partial update payload.
* @param userId - ID of the user performing the update.
* @param role - The caller's role (used for driver self-service guard).
* @returns The updated order with client relation.
* @throws {BadRequestException} Invalid status transition, driver permission violation, or order not on a trip.
* @throws {NotFoundException} Order not found in this organisation.
*/
async update(organizationId: string, id: string, dto: UpdateOrderDto, userId?: string, role?: string) {
const existing = await this.findOne(organizationId, id);
// ── Driver self-service guard ─────────────────────────────────────
// When a DRIVER calls this endpoint, two extra rules apply:
// 1. The order must be on a trip whose driverId matches a Driver
// record linked to this user.
// 2. They can only change a small whitelist: status, jobStatus,
// loadingConfirmed, specialInstructions.
if (role === 'DRIVER') {
const driverAllowedKeys = new Set([
'status',
'jobStatus',
'loadingConfirmed',
'specialInstructions',
]);
const tooMany = Object.keys(dto).filter(
(k) => !driverAllowedKeys.has(k) && (dto as Record<string, unknown>)[k] !== undefined,
);
if (tooMany.length > 0) {
throw new BadRequestException(
`Drivers can only update status / loadingConfirmed / instructions on their own orders. Disallowed fields: ${tooMany.join(', ')}.`,
);
}
if (!existing.tripId) {
throw new BadRequestException(
'This order is not on any trip yet. Wait for dispatch.',
);
}
// Verify the caller is the assigned driver via the trip
const trip = await this.prisma.trip.findFirst({
where: { id: existing.tripId, organizationId },
select: { driverId: true },
});
if (!trip?.driverId) {
throw new BadRequestException('Trip has no driver assigned.');
}
if (!userId) {
throw new BadRequestException('User context missing');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true, firstName: true, lastName: true },
});
if (!user) throw new NotFoundException('User not found');
const matching = await this.prisma.driver.findMany({
where: {
organizationId,
OR: [
...(user.email ? [{ email: user.email }] : []),
{ firstName: user.firstName || '', lastName: user.lastName || '' },
],
},
select: { id: true },
});
const driverIds = new Set(matching.map((d) => d.id));
if (!driverIds.has(trip.driverId)) {
throw new BadRequestException('You can only update orders on your own trips.');
}
}
const data: Record<string, unknown> = {};
const fields = [
'clientId', 'referenceNumber', 'priority', 'vehicleType',
'originName', 'originAddress', 'originCity', 'originState', 'originZip',
'originLat', 'originLng', 'destName', 'destAddress', 'destCity',
'destState', 'destZip', 'destLat', 'destLng', 'commodity', 'weight',
'volume', 'pieces', 'pallets', 'specialInstructions', 'quoteAmount',
'jobStatus', 'lpoNumber', 'deliveryNumber', 'plantName', 'distributorName',
'zoneId', 'laneId', 'loadingBayId', 'loadingConfirmed', 'tripId',
'beerOrderNumber', 'udvOrderNumber', 'deliveryNumberBeer', 'deliveryNumberUdv',
'qtyBeer', 'qtyUdv', 'truckPlate', 'palletization', 'region',
];
for (const field of fields) {
if ((dto as Record<string, unknown>)[field] !== undefined) {
data[field] = (dto as Record<string, unknown>)[field];
}
}
if (dto.pickupDate) data.pickupDate = new Date(dto.pickupDate);
if (dto.deliveryDate) data.deliveryDate = new Date(dto.deliveryDate);
// Handle status transition with event sourcing + state-machine guard.
// The state machine throws BadRequestException with a clear message
// if the transition is not allowed.
if (dto.status && dto.status !== existing.status) {
assertOrderStatusTransition(existing.status, dto.status);
data.status = dto.status;
await this.prisma.orderEvent.create({
data: {
orderId: id,
fromStatus: existing.status,
toStatus: dto.status,
userId,
reason: `Status changed from ${existing.status} to ${dto.status}`,
},
});
}
return this.prisma.order.update({
where: { id },
data,
include: { client: true },
});
}
/**
* Delete an order. Blocked if the order is on an active trip
* (ASSIGNED, LOADED, or IN_TRANSIT) to prevent silent data loss
* on the dispatch board.
*
* @param organizationId - The tenant's organisation ID.
* @param id - The order's database ID.
* @returns `{ deleted: true }` on success.
* @throws {BadRequestException} Order is on an active trip.
* @throws {NotFoundException} Order not found.
*/
async remove(organizationId: string, id: string) {
const order = await this.findOne(organizationId, id);
// Block delete if the order is on an active trip — otherwise the
// dispatch board would silently lose a row mid-trip.
if (order.tripId && ['ASSIGNED', 'LOADED', 'IN_TRANSIT'].includes(order.status)) {
throw new BadRequestException(
`Order is on an active trip (${order.status}). Cancel or unlink before deleting.`,
);
}
await this.prisma.order.delete({ where: { id } });
return { deleted: true };
}
/**
* Get order count statistics grouped by status for the dashboard KPIs.
*
* @param organizationId - The tenant's organisation ID.
* @returns Object with `total` count and `byStatus` breakdown.
*/
async getStats(organizationId: string) {
const results = await this.prisma.order.groupBy({
by: ['status'],
where: { organizationId },
_count: { status: true },
});
const byStatus: Record<string, number> = {};
for (const r of results) {
byStatus[r.status] = r._count.status;
}
const total = await this.prisma.order.count({ where: { organizationId } });
return { total, byStatus };
}
/**
* Confirm that an order has been physically loaded onto a vehicle.
*
* Enforces GPS geofence validation: the driver must be within the
* loading bay's configured radius (default 50m) to confirm. This
* prevents remote confirmation fraud.
*
* Side effects on success:
* - Order status set to `LOADED`, loadingConfirmed set to `true`
* - GPS coordinates and confirmer name appended to specialInstructions
* - If all orders on the trip are loaded, the bay is released to `AVAILABLE`
* - A `BayEvent` (LOADING_COMPLETE) is recorded for bay analytics
*
* @param id - The order's database ID.
* @param userId - ID of the user confirming (for audit trail).
* @param organizationId - The tenant's organisation ID.
* @param latitude - Driver's current GPS latitude (required).
* @param longitude - Driver's current GPS longitude (required).
* @returns The updated order.
* @throws {BadRequestException} GPS not provided, driver not within bay radius, or no bay configured.
*/
async confirmLoading(id: string, userId: string, organizationId: string, latitude?: number, longitude?: number) {
const order = await this.findOne(organizationId, id);
if (order.loadingConfirmed) {
return order; // already confirmed
}
// GPS coordinates are REQUIRED for loading confirmation
if (!latitude || !longitude) {
throw new BadRequestException('GPS location is required to confirm loading. Enable location services.');
}
// Validate GPS against loading bay location — truck must be AT the bay
const tripId = order.tripId;
let bayLat: number | null = null;
let bayLng: number | null = null;
let bayRadius = 50; // 50 meters — must be inside the bay, not 500m away
let bayName = 'loading bay';
if (tripId) {
const trip = await this.prisma.trip.findUnique({
where: { id: tripId },
select: { loadingBayId: true },
});
if (trip?.loadingBayId) {
const bay = await this.prisma.loadingBay.findUnique({
where: { id: trip.loadingBayId },
});
if (bay) {
bayLat = bay.latitude;
bayLng = bay.longitude;
bayRadius = (bay as any).radiusMeters || 50;
bayName = bay.name;
}
}
}
// Also check ALL loading bays in the org if trip has no specific bay
if (!bayLat && organizationId) {
const allBays = await this.prisma.loadingBay.findMany({
where: { organizationId, latitude: { not: null } },
select: { latitude: true, longitude: true, name: true, radiusMeters: true },
});
// Find closest bay
let closestDist = Infinity;
for (const b of allBays) {
if (b.latitude && b.longitude) {
const dist = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
if (dist < closestDist) {
closestDist = dist;
bayLat = b.latitude;
bayLng = b.longitude;
bayRadius = (b as any).radiusMeters || 50;
bayName = b.name;
}
}
}
}
// ENFORCE location check — driver must be at a loading bay
if (bayLat && bayLng) {
const distance = this.calculateDistance(latitude, longitude, bayLat, bayLng);
if (distance > bayRadius) {
throw new BadRequestException(
`You are ${Math.round(distance)}m away from ${bayName}. ` +
`You must be inside the loading bay (within ${bayRadius}m) to confirm loading. ` +
`Drive to the bay and try again.`
);
}
} else {
// No bay with coordinates exists — reject loading confirmation
throw new BadRequestException(
'No loading bay with GPS coordinates is configured. ' +
'Admin must set bay coordinates on the platform before loading can be confirmed.'
);
}
// Look up who confirmed
let confirmedByName = 'System';
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { firstName: true, lastName: true },
});
if (user) confirmedByName = `${user.firstName || ''} ${user.lastName || ''}`.trim();
}
const updated = await this.prisma.order.update({
where: { id },
data: {
loadingConfirmed: true,
loadingConfirmedAt: new Date(),
status: 'LOADED',
jobStatus: 'LOADED',
specialInstructions: [
order.specialInstructions,
`Loading confirmed by ${confirmedByName} at ${latitude?.toFixed(6) || '?'}, ${longitude?.toFixed(6) || '?'} on ${new Date().toISOString()}`,
].filter(Boolean).join(' | '),
},
include: { client: true },
});
// Record bay event and update bay/trip status
if (order.tripId && organizationId) {
const trip = await this.prisma.trip.findUnique({
where: { id: order.tripId },
select: { loadingBayId: true, vehicleId: true },
});
// Find the closest bay the driver is at
// Priority: Trip.loadingBayId → Order.loadingBayId → closest GPS
let matchedBayId = trip?.loadingBayId || (order as any).loadingBayId || null;
if (!matchedBayId) {
const allBays = await this.prisma.loadingBay.findMany({
where: { organizationId, latitude: { not: null } },
});
let closestId: string | null = null;
let closestDist = Infinity;
for (const b of allBays) {
if (b.latitude && b.longitude) {
const d = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
if (d < closestDist) { closestDist = d; closestId = b.id; }
}
}
matchedBayId = closestId;
// Update trip with the dock bay
if (matchedBayId) {
await this.prisma.trip.update({
where: { id: order.tripId },
data: { loadingBayId: matchedBayId },
}).catch(() => {});
}
}
if (matchedBayId) {
// Check if ALL orders on this trip are now confirmed (including the one just updated)
const remainingUnconfirmed = await this.prisma.order.count({
where: {
tripId: order.tripId,
id: { not: id }, // exclude the one we just confirmed
loadingConfirmed: false,
},
});
const allLoaded = remainingUnconfirmed === 0;
if (allLoaded) {
// All orders loaded — release the bay so it's available for the next truck
await this.prisma.loadingBay.update({
where: { id: matchedBayId },
data: { status: 'AVAILABLE', currentVehicleId: null },
}).catch(() => {});
} else {
// Still loading other orders — keep bay OCCUPIED
await this.prisma.loadingBay.update({
where: { id: matchedBayId },
data: { status: 'OCCUPIED', currentVehicleId: trip?.vehicleId || null },
}).catch(() => {});
}
// Create bay event
await this.prisma.bayEvent.create({
data: {
organizationId,
loadingBayId: matchedBayId,
vehicleId: trip?.vehicleId || undefined,
tripId: order.tripId,
eventType: 'LOADING_COMPLETE',
actualAt: new Date(),
status: 'ON_TIME',
notes: `Order ${order.orderNumber} loaded by ${confirmedByName} at GPS: ${latitude?.toFixed(6)}, ${longitude?.toFixed(6)}. ${allLoaded ? 'All orders loaded — bay released.' : `${remainingUnconfirmed} order(s) still pending.`}`,
},
}).catch(() => {});
}
}
return updated;
}
/**
* Retrieve orders ready for the Jobs/Tasks planner board.
*
* Returns orders with `jobStatus` of `VALIDATED` or `READY`, which are
* the states eligible for trip allocation by the planner.
*
* @param organizationId - The tenant's organisation ID.
* @returns Object with `data` array and `total` count.
*/
async getJobs(organizationId: string) {
const orders = await this.prisma.order.findMany({
where: {
organizationId,
jobStatus: { in: ['VALIDATED', 'READY'] },
},
include: {
client: { select: { id: true, name: true, code: true } },
},
orderBy: { createdAt: 'desc' },
});
return { data: orders, total: orders.length };
}
/**
* Advance an order's job status through the planner workflow.
*
* Valid transitions: `PENDING` -> `VALIDATED` -> `READY` -> `ALLOCATED`.
*
* @param organizationId - The tenant's organisation ID.
* @param id - The order's database ID.
* @param newJobStatus - The target job status.
* @param userId - ID of the user (for audit trail).
* @returns The updated order with client relation.
* @throws {BadRequestException} Invalid job status transition.
*/
async updateJobStatus(organizationId: string, id: string, newJobStatus: string, userId?: string) {
const validTransitions: Record<string, string[]> = {
PENDING: ['VALIDATED'],
VALIDATED: ['READY'],
READY: ['ALLOCATED'],
};
const order = await this.findOne(organizationId, id);
const currentJobStatus = order.jobStatus || 'PENDING';
const allowed = validTransitions[currentJobStatus] || [];
if (!allowed.includes(newJobStatus)) {
throw new BadRequestException(
`Invalid job status transition: ${currentJobStatus} -> ${newJobStatus}. Allowed: ${allowed.join(', ')}`,
);
}
return this.prisma.order.update({
where: { id },
data: { jobStatus: newJobStatus },
include: { client: true },
});
}
/**
* Manually assign a vehicle to an order. Creates a trip if needed.
*/
async assignVehicle(orderId: string, vehicleId: string, organizationId: string, userId: string) {
const order = await this.findOne(organizationId, orderId);
if (order.tripId) {
throw new BadRequestException('Order already assigned to a trip. Remove it first.');
}
// Vehicle must belong to the same org — defence in depth even with RLS
const vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, organizationId },
});
if (!vehicle) throw new NotFoundException('Vehicle not found');
// Capacity sanity check — refuse if order weight exceeds vehicle max
if (order.weight && vehicle.maxWeight && order.weight > vehicle.maxWeight) {
throw new BadRequestException(
`Order weight (${order.weight}kg) exceeds vehicle max payload (${vehicle.maxWeight}kg).`,
);
}
// Create a trip for this order
const tripCount = await this.prisma.trip.count({ where: { organizationId } });
const tripNumber = `TRP-${String(tripCount + 1).padStart(6, '0')}`;
const trip = await this.prisma.trip.create({
data: {
organizationId,
tripNumber,
vehicleId,
status: 'PLANNED',
},
});
const updated = await this.prisma.order.update({
where: { id: orderId },
data: {
tripId: trip.id,
status: 'ASSIGNED',
jobStatus: 'ALLOCATED',
truckPlate: vehicle.licensePlate,
},
include: { client: true, trip: { include: { vehicle: true } } },
});
await this.prisma.orderEvent.create({
data: {
orderId,
fromStatus: order.status,
toStatus: 'ASSIGNED',
userId,
reason: `Manually assigned to vehicle ${vehicle.unitNumber} (${vehicle.licensePlate})`,
},
});
return updated;
}
/**
* Waive a missing field — mark as intentionally skipped with who/when/why.
*/
async waiveField(organizationId: string, orderId: string, field: string, userId: string, userName: string, reason?: string) {
const order = await this.findOne(organizationId, orderId);
const currentMissing = order.missingFields || [];
if (!currentMissing.includes(field)) {
throw new BadRequestException(`"${field}" is not in the missing fields list`);
}
// Remove from missingFields
const updatedMissing = currentMissing.filter((f: string) => f !== field);
// Add to waivedFields
const currentWaivers = (order.waivedFields as any[]) || [];
currentWaivers.push({
field,
waivedBy: userId,
waivedByName: userName,
waivedAt: new Date().toISOString(),
reason: reason || 'Waived by user',
});
const updated = await this.prisma.order.update({
where: { id: orderId },
data: {
missingFields: updatedMissing,
waivedFields: currentWaivers as any,
},
});
await this.prisma.orderEvent.create({
data: {
orderId,
toStatus: 'DRAFT',
userId,
reason: `Waived missing field "${field}"${reason ? `: ${reason}` : ''}`,
},
});
return updated;
}
/**
* Send order to Jobs — only if all missing fields are filled or waived.
*/
async sendToJobs(organizationId: string, orderId: string, userId: string) {
const order = await this.findOne(organizationId, orderId);
const remaining = order.missingFields || [];
if (remaining.length > 0) {
throw new BadRequestException(
`Cannot send to Jobs — ${remaining.length} missing fields need to be filled or waived: ${remaining.join(', ')}`,
);
}
const updated = await this.prisma.order.update({
where: { id: orderId },
data: { jobStatus: 'PENDING' },
include: { client: true },
});
await this.prisma.orderEvent.create({
data: {
orderId,
toStatus: 'DRAFT',
userId,
reason: 'Sent to Jobs board — all missing fields resolved',
},
});
return updated;
}
/**
* Bulk send all eligible orders to Jobs.
* Only orders with 0 missing fields get sent. The rest are rejected with reasons.
*/
async bulkSendToJobs(organizationId: string, userId: string) {
const importedOrders = await this.prisma.order.findMany({
where: { organizationId, jobStatus: 'IMPORTED' },
select: { id: true, orderNumber: true, missingFields: true },
});
let sent = 0;
let rejected = 0;
const rejectedOrders: { orderNumber: string; missing: string[] }[] = [];
for (const order of importedOrders) {
if (!order.missingFields || order.missingFields.length === 0) {
await this.prisma.order.update({
where: { id: order.id },
data: { jobStatus: 'PENDING' },
});
sent++;
} else {
rejected++;
rejectedOrders.push({ orderNumber: order.orderNumber, missing: order.missingFields });
}
}
return {
total: importedOrders.length,
sent,
rejected,
rejectedOrders: rejectedOrders.slice(0, 20),
message: rejected > 0
? `${sent} orders sent to Jobs. ${rejected} rejected — fill or waive missing data first.`
: `All ${sent} orders sent to Jobs.`,
};
}
/**
* Calculate distance between two GPS coordinates in meters (Haversine formula).
*/
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371000; // Earth radius in meters
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Generate the next sequential order number for an organisation.
*
* Format: `ORD-NNNNNN` (zero-padded to 6 digits). The number is derived
* from the current order count, so it is unique within the tenant but
* not globally unique across tenants.
*
* @param organizationId - The tenant's organisation ID.
* @returns The generated order number string (e.g., `ORD-000042`).
*/
private async generateOrderNumber(organizationId: string): Promise<string> {
const count = await this.prisma.order.count({ where: { organizationId } });
const num = count + 1;
return `ORD-${String(num).padStart(6, '0')}`;
}
}