diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts new file mode 100644 index 000000000..dc4f292b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -0,0 +1,186 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; + +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../rule-engine/interfaces/cargo-types.repository.interface'; +import { + CONTAINER_TYPES_REPOSITORY, + IContainerTypesRepository, +} from '../rule-engine/interfaces/container-types.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../rule-engine/interfaces/service-types.repository.interface'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from '../rule-engine/interfaces/shipping-lines.repository.interface'; +import { + IYardsRepository, + YARDS_REPOSITORY, +} from '../rule-engine/interfaces/yards.repository.interface'; +import { + BookingReferenceCargoTypeChildDto, + BookingReferenceCargoTypeGroupDto, + BookingReferenceContainerSizeGroupDto, + BookingReferenceContainerTypeDto, + BookingReferenceDataDto, + BookingReferenceServiceDto, + BookingReferenceShippingLineDto, + BookingReferenceYardDto, +} from './dto/booking-reference-data.dto'; + +const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const; + +export function buildCargoTypeTree( + rows: CargoType[], +): BookingReferenceCargoTypeGroupDto[] { + const active = rows.filter((r) => r.isActive); + const parents = active + .filter((r) => !r.parentGroupId) + .sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code)); + + return parents.map((parent) => { + const children = active + .filter((r) => r.parentGroupId === parent.id) + .sort( + (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), + ) + .map( + (child): BookingReferenceCargoTypeChildDto => ({ + id: child.id, + name: child.cargoTypeName, + code: child.code, + show_free_text_box: child.showFreeTextBox, + }), + ); + + const group: BookingReferenceCargoTypeGroupDto = { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + }; + if (children.length > 0) { + group.children = children; + } + return group; + }); +} + +export function groupContainersBySize( + rows: ContainerType[], +): BookingReferenceContainerSizeGroupDto[] { + const active = rows.filter((r) => r.isActive); + const bySize = new Map(); + + for (const ct of active) { + const sizeKey = + ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other'; + const list = bySize.get(sizeKey) ?? []; + list.push(ct); + bySize.set(sizeKey, list); + } + + const sortSizeKey = (key: string): number => { + if (key === 'other') return Number.MAX_SAFE_INTEGER; + const n = parseInt(key, 10); + return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n; + }; + + return [...bySize.entries()] + .sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b)) + .map(([size, types]) => ({ + size, + types: types + .sort( + (a, b) => + (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || + a.code.localeCompare(b.code), + ) + .map( + (ct): BookingReferenceContainerTypeDto => ({ + id: ct.id, + name: ct.label?.trim() ? ct.label : ct.code, + code: ct.code, + is_reefer: ct.isReefer ?? false, + wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), + }), + ), + })); +} + +@Injectable() +export class BookingReferenceDataService { + constructor( + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + @Inject(CONTAINER_TYPES_REPOSITORY) + private readonly containerTypesRepository: IContainerTypesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepository: IServiceTypesRepository, + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly shippingLinesRepository: IShippingLinesRepository, + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepository: ICargoTypesRepository, + ) {} + + async getReferenceData(): Promise { + const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = + await Promise.all([ + this.yardsRepository.findAll({ + where: { + isActive: true, + code: Not(In([...LEGACY_YARD_CODES])), + }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + this.containerTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + this.serviceTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + this.shippingLinesRepository.findAll({ + where: { isActive: true }, + order: { label: 'ASC', code: 'ASC' }, + }), + this.cargoTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + ]); + + return { + yard: yards.map( + (y): BookingReferenceYardDto => ({ + id: y.id, + name: y.label, + code: y.code, + country: y.country, + }), + ), + containers: groupContainersBySize(containerTypes), + service: serviceTypes.map( + (s): BookingReferenceServiceDto => ({ + id: s.id, + name: s.serviceName, + code: s.code, + }), + ), + shipping_line: shippingLines.map( + (sl): BookingReferenceShippingLineDto => ({ + id: sl.id, + name: sl.label, + code: sl.code, + }), + ), + cargo_type: buildCargoTypeTree(cargoTypes), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index a1ca196ab..41a1a09c4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -18,11 +18,14 @@ import { ApiBearerAuth, ApiBody, ApiConsumes, + ApiOkResponse, ApiOperation, ApiTags, } from "@nestjs/swagger"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; import { BookingsService } from "./bookings.service"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; import { UpdateBookingDto } from "./dto/update-booking.dto"; @@ -32,7 +35,10 @@ import { UpdateStatusDto } from "./dto/update-status.dto"; @Controller("bookings") @ApiBearerAuth() export class BookingsController { - constructor(private readonly bookingsService: BookingsService) { } + constructor( + private readonly bookingsService: BookingsService, + private readonly bookingReferenceDataService: BookingReferenceDataService, + ) {} // ── 1. Create booking (multipart/form-data) ────────────────────────── @Post() @@ -100,6 +106,19 @@ export class BookingsController { return this.bookingsService.findAll(filter); } + // ── Booking form catalog (must be before :id) ───────────────────────── + @Get("reference-data") + @ApiOperation({ + summary: "Booking form catalog", + description: + "Returns yards, container types (grouped by size), service types, shipping lines, " + + "and hierarchical cargo types for the booking UI in a single payload.", + }) + @ApiOkResponse({ type: BookingReferenceDataDto }) + getReferenceData(): Promise { + return this.bookingReferenceDataService.getReferenceData(); + } + // ── 5. Lookup by reference (must be before :id to avoid conflict) ───── @Get("by-reference/:reference") @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index b9a457b1b..9785ec270 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -5,6 +5,7 @@ import { CustomersModule } from '../customers/customers.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsController } from './bookings.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -30,7 +31,12 @@ import { Booking } from './entities/booking.entity'; RuleEngineModule, ], controllers: [BookingsController], - providers: [BookingsService, BookingsRepository, ConsolidationService], + providers: [ + BookingsService, + BookingsRepository, + ConsolidationService, + BookingReferenceDataService, + ], exports: [BookingsService], }) export class BookingsModule {} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts new file mode 100644 index 000000000..0dc2bd255 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -0,0 +1,107 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BookingReferenceYardDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Mojo Dry Port' }) + name!: string; + + @ApiProperty({ example: 'MOJO' }) + code!: string; + + @ApiProperty({ example: 'Ethiopia' }) + country!: string; +} + +export class BookingReferenceContainerTypeDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Dry' }) + name!: string; + + @ApiProperty({ example: '20GP' }) + code!: string; + + @ApiProperty() + is_reefer!: boolean; + + @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) + wagons_per_unit!: number; +} + +export class BookingReferenceContainerSizeGroupDto { + @ApiProperty({ example: '20ft' }) + size!: string; + + @ApiProperty({ type: [BookingReferenceContainerTypeDto] }) + types!: BookingReferenceContainerTypeDto[]; +} + +export class BookingReferenceServiceDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Rail Transport Only' }) + name!: string; + + @ApiProperty({ example: 'RAIL' }) + code!: string; +} + +export class BookingReferenceShippingLineDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'MSC' }) + name!: string; + + @ApiProperty({ example: 'MSC' }) + code!: string; +} + +export class BookingReferenceCargoTypeChildDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Coffee' }) + name!: string; + + @ApiProperty({ example: 'BULK_COFFEE' }) + code!: string; + + @ApiProperty() + show_free_text_box!: boolean; +} + +export class BookingReferenceCargoTypeGroupDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Bulk Cargo' }) + name!: string; + + @ApiProperty({ example: 'BULK' }) + code!: string; + + @ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] }) + children?: BookingReferenceCargoTypeChildDto[]; +} + +export class BookingReferenceDataDto { + @ApiProperty({ type: [BookingReferenceYardDto] }) + yard!: BookingReferenceYardDto[]; + + @ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] }) + containers!: BookingReferenceContainerSizeGroupDto[]; + + @ApiProperty({ type: [BookingReferenceServiceDto] }) + service!: BookingReferenceServiceDto[]; + + @ApiProperty({ type: [BookingReferenceShippingLineDto] }) + shipping_line!: BookingReferenceShippingLineDto[]; + + @ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] }) + cargo_type!: BookingReferenceCargoTypeGroupDto[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 018b56e43..9657e6865 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -140,6 +140,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ShippingLinesService, RatesService, ApprovalRulesService, + CARGO_TYPES_REPOSITORY, + CONTAINER_TYPES_REPOSITORY, + SERVICE_TYPES_REPOSITORY, + SHIPPING_LINES_REPOSITORY, + YARDS_REPOSITORY, ], }) export class RuleEngineModule {}