This commit is contained in:
natib21
2026-06-29 14:45:22 +00:00
parent 7dba546254
commit 6fa315db0f
28 changed files with 1227 additions and 8 deletions

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create the freight.last_mile_container_allocations table — container allocation
* records linking last-mile deliveries with containers and vehicles.
*/
export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.last_mile_container_allocations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'last_mile_id', type: 'uuid', isNullable: false },
{ name: 'container_id', type: 'uuid', isNullable: false },
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
{
name: 'container_type',
type: 'text',
isNullable: false,
},
{
name: 'quantity',
type: 'integer',
default: 1,
isNullable: false,
},
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.last_mile_container_allocations',
new TableForeignKey({
columnNames: ['last_mile_id'],
referencedTableName: 'freight.last_mile',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile_container_allocations',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
if (exists) {
await queryRunner.dropTable('freight.last_mile_container_allocations');
}
}
}

View File

@@ -0,0 +1,80 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create the freight.booking_container_allocations table — container-to-vehicle
* allocation mapping for flexible routing of containers across available vehicles.
*/
export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface {
name = 'CreateBookingContainerAllocations1825000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.booking_container_allocations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'booking_id', type: 'uuid', isNullable: false },
{ name: 'container_id', type: 'uuid', isNullable: false },
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
{
name: 'container_type',
type: 'text',
isNullable: false,
},
{
name: 'quantity',
type: 'integer',
default: 1,
isNullable: false,
},
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.booking_container_allocations',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'freight.bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.booking_container_allocations',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
if (exists) {
await queryRunner.dropTable('freight.booking_container_allocations');
}
}
}

View File

@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create freight.first_mile_container_allocations table — tracks
* container allocations per first-mile shipment with optional vehicle assignment.
*/
export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.first_mile_container_allocations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'first_mile_id', type: 'uuid', isNullable: false },
{ name: 'container_id', type: 'uuid', isNullable: false },
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
{ name: 'container_type', type: 'text', isNullable: false },
{
name: 'quantity',
type: 'int',
default: 1,
isNullable: false,
},
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.first_mile_container_allocations',
new TableForeignKey({
columnNames: ['first_mile_id'],
referencedTableName: 'freight.first_mile',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.first_mile_container_allocations',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
if (exists) {
await queryRunner.dropTable('freight.first_mile_container_allocations');
}
}
}

View File

@@ -0,0 +1,20 @@
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { AllocateContainersDto } from './dto/allocate-containers.dto';
@ApiTags('bookings')
@Controller('bookings')
@ApiBearerAuth()
export class BookingAllocationController {
constructor(private readonly bookingsService: BookingsService) {}
@Post(':bookingId/allocate-containers')
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AllocateContainersDto,
) {
return this.bookingsService.allocateContainers(bookingId, dto.allocations);
}
}

View File

@@ -16,6 +16,7 @@ import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { BookingAllocationController } from './booking-allocation.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -28,6 +29,7 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
@@ -47,6 +49,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
]),
PaymentModule,
forwardRef(() => TrainSchedulingModule),
@@ -63,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [BookingsController, PayController],
controllers: [BookingsController, BookingAllocationController, PayController],
providers: [
BookingsService,
BookingsRepository,

View File

@@ -43,6 +43,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
@@ -1305,4 +1306,35 @@ export class BookingsService {
createdAt: b.createdAt,
}));
}
async allocateContainers(
bookingId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const booking = await this.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
});
await manager.insert(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class ContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateContainersDto {
allocations!: ContainerAllocationDto[];
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'booking_container_allocations' })
@Index(['bookingId'])
@Index(['vehicleId'])
export class BookingContainerAllocation extends BaseEntity {
@ManyToOne(() => Booking, (b) => b.containerAllocations)
@JoinColumn({ name: 'booking_id' })
booking!: Booking;
@Column('uuid', { name: 'booking_id' })
bookingId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string;
@Column('text')
containerType!: string; // CONTAINER, BULK_DRY, etc
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingContainerAllocation } from './booking-container-allocation.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
import { BookingReviewNote } from './booking-review-note.entity';
@@ -441,6 +442,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
containerAllocations?: BookingContainerAllocation[];
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];

View File

@@ -0,0 +1,8 @@
export class FirstMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateFirstMileContainersDto {
allocations!: FirstMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { FirstMile } from './first-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'first_mile_container_allocations', schema: 'freight' })
@Index(['firstMileId'])
@Index(['vehicleId'])
export class FirstMileContainerAllocation extends BaseEntity {
@Column({ name: 'first_mile_id', type: 'uuid' })
firstMileId!: string;
@ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, {
nullable: false,
eager: false,
})
@JoinColumn({ name: 'first_mile_id' })
firstMile?: FirstMile;
@Column({ name: 'container_id', type: 'uuid' })
containerId!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column({ name: 'container_type', type: 'text' })
containerType!: string;
@Column({ name: 'quantity', type: 'int', default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -49,4 +50,11 @@ export class FirstMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(
() => FirstMileContainerAllocation,
(containerAllocation) => containerAllocation.firstMile,
{ eager: false },
)
containerAllocations!: FirstMileContainerAllocation[];
}

View File

@@ -17,6 +17,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
@@ -83,4 +84,14 @@ export class FirstMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.remove(id);
}
@Post(':firstMileId/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
allocateContainers(
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
@Body() dto: AllocateFirstMileContainersDto,
) {
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
}
}

View File

@@ -6,13 +6,14 @@ import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile]),
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,

View File

@@ -1,5 +1,7 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
@@ -8,6 +10,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileRepository } from './first-mile.repository';
type FirstMileListFilter = {
@@ -32,6 +35,7 @@ export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
@@ -273,4 +277,35 @@ export class FirstMileService {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
async allocateContainers(
firstMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const firstMile = await this.findById(firstMileId);
if (!firstMile) {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
});
await manager.insert(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class LastMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateLastMileContainersDto {
allocations!: LastMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { LastMile } from './last-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'last_mile_container_allocations' })
@Index(['lastMileId'])
@Index(['vehicleId'])
export class LastMileContainerAllocation extends BaseEntity {
@ManyToOne(() => LastMile, (lm) => lm.containerAllocations)
@JoinColumn({ name: 'last_mile_id' })
lastMile!: LastMile;
@Column('uuid', { name: 'last_mile_id' })
lastMileId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string | null;
@Column('text')
containerType!: string;
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -49,4 +50,7 @@ export class LastMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
containerAllocations?: LastMileContainerAllocation[];
}

View File

@@ -17,6 +17,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
@@ -83,4 +84,14 @@ export class LastMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
@Post(':id/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AllocateLastMileContainersDto,
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
}
}

View File

@@ -6,13 +6,14 @@ import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile]),
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
@@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileRepository } from './last-mile.repository';
type LastMileListFilter = {
@@ -37,6 +38,7 @@ export class LastMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -206,4 +208,35 @@ export class LastMileService {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
async allocateContainers(
lastMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const lastMile = await this.findById(lastMileId);
if (!lastMile) {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
});
await manager.insert(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface ContainerAllocationTableProps {
bookingId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for freight bookings.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function ContainerAllocationTable({
bookingId,
containers,
onSave,
}: ContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface FirstMileContainerAllocationTableProps {
firstMileId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for first-mile pickups.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function FirstMileContainerAllocationTable({
firstMileId,
containers,
onSave,
}: FirstMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface LastMileContainerRow {
id: string;
type: string;
qty: number;
}
export interface LastMileContainerAllocationTableProps {
lastMileId: string;
containers: LastMileContainerRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for last-mile deliveries.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function LastMileContainerAllocationTable({
lastMileId,
containers,
onSave,
}: LastMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -1,6 +1,6 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -1,5 +1,7 @@
import { Container, Grid, Stack } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
BookingApprovalCard,
@@ -16,10 +18,26 @@ import {
type BookingDetailView,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const allocateMutation = useMutation({
mutationFn: (data: any) =>
api.post(`/bookings/${id}/allocate-containers`, data),
onSuccess: () => {
toast.success("Containers allocated");
qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") });
},
onError: () => {
toast.error("Failed to allocate containers");
},
});
// Mock data - replace with actual API call
const booking: BookingDetailView = {
@@ -134,6 +152,17 @@ const BookingDetailPage = () => {
<BookingContainersCard
containers={booking.bookingContainers ?? []}
/>
<ContainerAllocationTable
bookingId={booking.id}
containers={(booking.bookingContainers ?? []).map((c) => ({
id: c.id,
type: c.containerType?.label ?? "Unknown",
qty: c.quantity,
}))}
onSave={(allocations) =>
allocateMutation.mutateAsync({ allocations })
}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}

View File

@@ -30,9 +30,11 @@ import {
Text,
TextInput,
UnstyledButton,
Alert,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import {
@@ -44,6 +46,7 @@ import {
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { apiClient } from "@/services/api-client";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -336,6 +339,9 @@ const FirstMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
queryFn: async () => {
@@ -434,6 +440,19 @@ const FirstMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") });
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -508,6 +527,16 @@ const FirstMilePage = () => {
setInvoiceRecord(null);
};
const openContainerAllocation = (firstMileId: string) => {
setContainerAllocationFirstMileId(firstMileId);
setContainerAllocationOpen(true);
};
const closeContainerAllocation = () => {
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -1272,6 +1301,56 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={containerAllocationOpen}
onClose={closeContainerAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
{/* Capacity guidance */}
{activeRecord.booking?.cargoType?.label === "BULK" ? (
<Alert color="blue" title="Bulk Cargo Allocation">
<Text size="sm">
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
</Text>
<Text size="xs" c="dimmed" mt="xs">
Capacity: TBD TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
</Alert>
) : (
<Alert color="blue">
<Text size="sm">
One vehicle per container. Each container will be assigned to a single vehicle.
</Text>
</Alert>
)}
<Divider />
{/* Container table */}
<FirstMileContainerAllocationTable
firstMileId={activeRecord.id}
containers={[
// TODO: Get containers from booking/first-mile data
// For now placeholder with TODO comment
]}
onSave={async (allocations) => {
await allocateMutation.mutateAsync(allocations);
}}
/>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -45,6 +45,8 @@ import {
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -321,6 +323,9 @@ const LastMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const [allocationOpen, setAllocationOpen] = useState(false);
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -385,6 +390,19 @@ const LastMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated", variant: "default" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") });
closeAllocation();
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -477,6 +495,18 @@ const LastMilePage = () => {
setInvoiceRecord(null);
};
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
setActiveId(id);
setAllocationContainers(containers ?? []);
setAllocationOpen(true);
};
const closeAllocation = () => {
setAllocationOpen(false);
setActiveId(null);
setAllocationContainers([]);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -1243,6 +1273,76 @@ const LastMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={allocationOpen}
onClose={closeAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
</Stack>
</Group>
</Stack>
</Card>
{/* Capacity logic based on cargo type */}
{activeRecord.booking?.cargoType?.name === "BULK" ? (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
</Group>
<Stack gap={2}>
<Text size="sm">Capacity: TBD</Text>
<Text size="xs" c="dimmed">
TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
<Text size="xs" c="dimmed">
TODO: add container weight to booking if missing
</Text>
</Stack>
<Text size="sm" fw={500} mt="xs">
Select multiple containers per vehicle based on capacity
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" fw={500}>One vehicle per container</Text>
</Card>
)}
</>
)}
<LastMileContainerAllocationTable
lastMileId={activeId ?? ""}
containers={allocationContainers}
onSave={async (mappings) => {
await allocateMutation.mutateAsync(mappings);
}}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};