diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 20048c94d..1a7db810d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -66,6 +66,19 @@ export class FirstMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a first-mile record, for the history timeline. */ private async resolveBookingRef(record: FirstMile): Promise { const loaded = (record as FirstMile & { booking?: { reference?: string } }) @@ -297,6 +310,18 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -396,6 +421,15 @@ export class FirstMileService { async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index ade7402a9..884a02aae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -65,6 +65,19 @@ export class LastMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a last-mile record, for the history timeline. * Uses the already-loaded relation when present, else looks it up. */ private async resolveBookingRef( @@ -218,6 +231,18 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index cec4d4351..bbd5acc0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -879,10 +879,14 @@ const FirstMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label}