diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 7857d7c7e..eca814b6d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module"; import { SignaturesModule } from "./modules/signatures/signatures.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; @@ -87,6 +88,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, + BookingOrdersModule, SignaturesModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts new file mode 100644 index 000000000..6ec9d60cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUnitOfMeasureToCargoTypes1792000000000 + implements MigrationInterface +{ + name = 'AddUnitOfMeasureToCargoTypes1792000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts new file mode 100644 index 000000000..c55186a38 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingTypeAndContractFields1792000000001 + implements MigrationInterface +{ + name = 'AddBookingTypeAndContractFields1792000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`, + ); + // General contracts have no shipment date at creation — relax the NOT NULL. + await queryRunner.query( + `ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`, + ); + // Reinstate NOT NULL only if no null rows exist (general contracts would block it). + await queryRunner.query( + `ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts new file mode 100644 index 000000000..ceb98b5d1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateBookingOrders1792000000002 implements MigrationInterface { + name = 'CreateBookingOrders1792000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_orders', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'reference', type: 'varchar', length: '64', isUnique: true }, + { name: 'contract_booking_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'company_id', type: 'uuid', isNullable: true }, + { name: 'scheduled_date', type: 'timestamptz' }, + { name: 'status', type: 'varchar', length: '40', default: "'PAID'" }, + { name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" }, + { name: 'train_schedule_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_orders', + new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_orders', + new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }), + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_order_lines', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'order_id', type: 'uuid' }, + { name: 'container_type_id', type: 'uuid', isNullable: true }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 3 }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['order_id'], + referencedSchema: 'freight', + referencedTableName: 'booking_orders', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_order_lines', + new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_order_lines', true); + await queryRunner.dropTable('freight.booking_orders', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts new file mode 100644 index 000000000..e6c0708d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seeds the global "general contract period" setting (months). Stored as a + * dropdown_settings row with a single option whose `value` holds the month count + * so backoffice can manage it through the existing settings UI later. + */ +export class SeedGeneralContractPeriod1792000000003 + implements MigrationInterface +{ + name = 'SeedGeneralContractPeriod1792000000003'; + private readonly code = 'general_contract_period'; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'General Contract Period (months)', + 'How many months a general contract stays open for ordering after activation.', + ], + ); + const settingId = inserted[0].id; + + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, 0);`, + [settingId, '3', '3 months'], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.dropdown_settings WHERE code = $1;`, + [this.code], + ); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts new file mode 100644 index 000000000..6821c4e7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingOrdersService } from './booking-orders.service'; +import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; +import { GeneralContractService } from './general-contract.service'; + +@ApiTags('Booking Orders') +@Controller('booking-orders') +export class BookingOrdersController { + constructor( + private readonly ordersService: BookingOrdersService, + private readonly generalContractService: GeneralContractService, + ) {} + + @Post() + @ApiOperation({ summary: 'Place a drawdown order against a general contract' }) + async create( + @Body() dto: CreateBookingOrderDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.ordersService.create(dto, user?.id); + } + + @Get() + @ApiOperation({ summary: 'List orders placed against a contract' }) + async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) { + return this.ordersService.listByContract(contractBookingId); + } + + @Get('contract/:id/pool') + @ApiOperation({ + summary: 'Contracted / ordered / remaining quantities for a general contract', + }) + async pool(@Param('id', ParseUUIDPipe) id: string) { + return this.generalContractService.getQuantityLines(id); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single booking order' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.ordersService.findById(id); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts new file mode 100644 index 000000000..c8ea869be --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -0,0 +1,30 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; +import { CompaniesModule } from '../companies/companies.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { BookingOrdersController } from './booking-orders.controller'; +import { BookingOrdersRepository } from './booking-orders.repository'; +import { BookingOrdersService } from './booking-orders.service'; +import { BookingOrder } from './entities/booking-order.entity'; +import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { GeneralContractService } from './general-contract.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]), + BookingsModule, + CompaniesModule, + DropdownSettingsModule, + forwardRef(() => TrainSchedulingModule), + ], + controllers: [BookingOrdersController], + providers: [ + BookingOrdersService, + BookingOrdersRepository, + GeneralContractService, + ], + exports: [BookingOrdersService, GeneralContractService], +}) +export class BookingOrdersModule {} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts new file mode 100644 index 000000000..c45029b2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts @@ -0,0 +1,41 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BookingOrder } from './entities/booking-order.entity'; + +@Injectable() +export class BookingOrdersRepository extends BaseRepository { + constructor( + @InjectRepository(BookingOrder) + repository: Repository, + ) { + super(repository); + } + + /** Orders placed against a given contract, newest first, with their lines. */ + findByContract(contractBookingId: string): Promise { + return this.repository.find({ + where: { contractBookingId }, + relations: { lines: { containerType: true }, booking: true }, + order: { createdAt: 'DESC' }, + }); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { lines: { containerType: true }, booking: true, contractBooking: true }, + }); + } + + /** Count this calendar year's orders, for reference generation. */ + async countByYear(year: number): Promise { + const start = new Date(Date.UTC(year, 0, 1)); + const end = new Date(Date.UTC(year + 1, 0, 1)); + return this.repository + .createQueryBuilder('o') + .where('o.createdAt >= :start AND o.createdAt < :end', { start, end }) + .getCount(); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts new file mode 100644 index 000000000..1a75c7914 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -0,0 +1,293 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { CompaniesService } from '../companies/companies.service'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { BookingOrdersRepository } from './booking-orders.repository'; +import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; +import { BookingOrder } from './entities/booking-order.entity'; +import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { GeneralContractService } from './general-contract.service'; + +@Injectable() +export class BookingOrdersService { + private readonly logger = new Logger(BookingOrdersService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly ordersRepository: BookingOrdersRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly companiesService: CompaniesService, + private readonly generalContractService: GeneralContractService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + /** Orders placed against a contract, with their lines and child booking. */ + listByContract(contractBookingId: string): Promise { + return this.ordersRepository.findByContract(contractBookingId); + } + + findById(id: string): Promise { + return this.ordersRepository.findById(id); + } + + /** + * Place a drawdown order against an ACTIVE general contract. + * + * Validates the requested quantities against the remaining pool, then spawns a + * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's + * route/cargo/service) so it flows through the existing train-scheduling + * pipeline. The order row is the ledger entry linking contract → child booking. + */ + async create( + dto: CreateBookingOrderDto, + userId?: string, + ): Promise { + const contract = await this.bookingsRepository.findById(dto.contractBookingId); + if (!contract) { + throw new NotFoundException(`Contract ${dto.contractBookingId} not found`); + } + if (!this.generalContractService.isGeneralContract(contract)) { + throw new BadRequestException('Booking is not a general contract'); + } + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new BadRequestException( + `Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`, + ); + } + if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) { + throw new BadRequestException('Contract ordering window has expired'); + } + + // The customer placing the order must own the contract. + if (userId && !(await this.userOwnsContract(userId, contract))) { + throw new BadRequestException('You do not have access to this contract'); + } + + // Validate the route has a departure on the chosen day. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + contract.originYardId, + contract.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + + // Validate each line against the remaining pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + const isContainer = contract.freightType === 'CONTAINER'; + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } + const key = isContainer ? (line.containerTypeId ?? '') : ''; + const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); + if (!poolLine) { + throw new BadRequestException( + isContainer + ? `Container type ${line.containerTypeId} is not part of this contract` + : 'This contract has no matching quantity pool', + ); + } + if (line.quantity > poolLine.remainingQuantity) { + throw new BadRequestException( + `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + ); + } + } + + // Persist the order + its child shipment booking atomically. + const order = await this.dataSource.transaction(async (manager) => { + const childBooking = await this.spawnChildBooking(contract, dto, manager); + + const reference = await this.generateReference(); + const orderRow = manager.create(BookingOrder, { + reference, + contractBookingId: contract.id, + bookingId: childBooking.id, + companyId: contract.companyId ?? null, + scheduledDate: new Date(dto.scheduledDate), + status: 'PAID', + schedulingStatus: 'NOT_SCHEDULED', + }); + const savedOrder = await manager.save(orderRow); + + const lines = dto.lines.map((l) => + manager.create(BookingOrderLine, { + orderId: savedOrder.id, + containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, + quantity: l.quantity, + }), + ); + await manager.save(lines); + savedOrder.lines = lines; + return savedOrder; + }); + + // Feed the child booking into the day-pool batch so it allocates to a train. + try { + await this.bookingBatchService.processRouteDay({ + originYardId: contract.originYardId, + destinationYardId: contract.destinationYardId, + day, + }); + } catch (err) { + this.logger.error( + `Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Close the contract once its pool is exhausted. + if (await this.generalContractService.isExhausted(contract.id)) { + await this.dataSource + .getRepository(Booking) + .update(contract.id, { status: 'CONTRACT_CLOSED' }); + this.logger.log( + `Contract ${contract.reference} CLOSED — quantity exhausted`, + ); + } + + return (await this.ordersRepository.findById(order.id)) ?? order; + } + + /** + * Create the ONE_TIME child booking for an order, inheriting the contract's + * shipment context and entering the queue already PAID + FULLY_EXECUTED. + */ + private async spawnChildBooking( + contract: Booking, + dto: CreateBookingOrderDto, + manager: import('typeorm').EntityManager, + ): Promise { + const reference = await this.generateChildBookingReference(); + const now = new Date(); + const isContainer = contract.freightType === 'CONTAINER'; + + // Sum line quantities × the contract's per-unit weight for the child total. + const containerByType = new Map( + (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]), + ); + let totalWeight = 0; + if (isContainer) { + for (const line of dto.lines) { + const src = containerByType.get(line.containerTypeId ?? ''); + const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; + totalWeight += vgmPerUnit * line.quantity; + } + } else { + totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + } + + const child = manager.create(Booking, { + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + contractType: contract.contractType, + previousContractId: contract.id, + serviceTypeId: contract.serviceTypeId, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + equipmentReturn: contract.equipmentReturn, + originYardId: contract.originYardId, + destinationYardId: contract.destinationYardId, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: contract.cargoTypeId ?? null, + cargoFreeText: contract.cargoFreeText ?? null, + shippingLineId: contract.shippingLineId ?? null, + cargoTotalWeightVgm: totalWeight, + isHazardous: contract.isHazardous, + paymentCurrency: contract.paymentCurrency, + bookingType: 'ONE_TIME', + scheduledDate: new Date(dto.scheduledDate), + // Already covered by the contract's one-time payment: enter the pool ready + // and paid so the batch engine reserves → allocates it immediately. + status: 'FULLY_EXECUTED', + paymentStatus: 'PAID', + fullyExecutedAt: now, + customerSignedAt: now, + priorityScore: contract.priorityScore, + totalAmount: 0, + allowConsolidation: false, + schedulingStatus: 'NOT_SCHEDULED', + }); + const savedChild = await manager.save(child); + + if (isContainer) { + for (const line of dto.lines) { + const src = containerByType.get(line.containerTypeId ?? ''); + const ct = line.containerTypeId + ? await manager.getRepository(ContainerType).findOne({ + where: { id: line.containerTypeId }, + }) + : null; + const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; + const row = manager.create(BookingContainer, { + bookingId: savedChild.id, + containerTypeId: line.containerTypeId ?? null, + quantity: line.quantity, + vgmPerUnitTons: vgmPerUnit, + totalVgmTons: vgmPerUnit * line.quantity, + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit), + isOverweight: false, + }); + await manager.save(row); + } + } + + return savedChild; + } + + private async userOwnsContract( + userId: string, + contract: Booking, + ): Promise { + if (!contract.companyId) return true; // government / staff-created + try { + const { company } = await this.companiesService.getCompanyInfoByUserId( + userId, + ); + return company.id === contract.companyId; + } catch { + return false; + } + } + + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.ordersRepository.countByYear(year); + return `ORD-${year}-${String(count + 1).padStart(6, '0')}`; + } + + private async generateChildBookingReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts new file mode 100644 index 000000000..6c0c86669 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CargoUnitOfMeasure } from '@edr/types'; + +/** A single contracted/ordered/remaining pool line for a general contract. */ +export class ContractQuantityLineView { + @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) + containerTypeId!: string | null; + + @ApiProperty({ nullable: true }) + containerTypeName!: string | null; + + @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true }) + unitOfMeasure!: CargoUnitOfMeasure | null; + + @ApiProperty() + contractedQuantity!: number; + + @ApiProperty() + orderedQuantity!: number; + + @ApiProperty() + remainingQuantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts new file mode 100644 index 000000000..7043e9704 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class CreateBookingOrderLineDto { + @ApiPropertyOptional({ + format: 'uuid', + description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + quantity!: number; +} + +export class CreateBookingOrderDto { + @ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' }) + @IsUUID() + contractBookingId!: string; + + @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) + @IsDateString() + scheduledDate!: string; + + @ApiProperty({ type: [CreateBookingOrderLineDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBookingOrderLineDto) + lines!: CreateBookingOrderLineDto[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts new file mode 100644 index 000000000..8716cd673 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { BookingOrder } from './booking-order.entity'; + +/** + * One drawn-down quantity line of an order. For CONTAINER contracts there is one + * line per container type (matching the contract's pools); for BULK/BREAK_BULK a + * single line with a null containerTypeId carries the tons/items. + */ +@Entity({ schema: 'freight', name: 'booking_order_lines' }) +export class BookingOrderLine extends BaseEntity { + @Column({ name: 'order_id', type: 'uuid' }) + orderId!: string; + + @ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order?: BookingOrder; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + /** Containers (count), tons, or items depending on the contract's freight/UoM. */ + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts new file mode 100644 index 000000000..5d6857051 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { BookingOrderLine } from './booking-order-line.entity'; + +/** + * A single drawdown against a general contract. Each order spawns its own + * ONE_TIME child Booking (the shipment that enters the train scheduling + * pipeline); this row is the ledger entry linking the contract to that + * shipment and recording the drawn-down quantities. + */ +@Entity({ schema: 'freight', name: 'booking_orders' }) +export class BookingOrder extends BaseEntity { + @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) + reference!: string; + + /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */ + @Column({ name: 'contract_booking_id', type: 'uuid' }) + contractBookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'contract_booking_id' }) + contractBooking?: Booking; + + /** The ONE_TIME child shipment booking spawned for this order. */ + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + /** Denormalized from the contract for fast company-scoped filtering. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'company_id' }) + company?: Company | null; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' }) + status!: string; + + @Column({ + name: 'scheduling_status', + type: 'varchar', + length: 30, + default: SchedulingStatus.NotScheduled, + }) + schedulingStatus!: string; + + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true }) + lines?: BookingOrderLine[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts new file mode 100644 index 000000000..ba382d085 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BookingType, CargoUnitOfMeasure } from '@edr/types'; +import { DataSource } from 'typeorm'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingOrder } from './entities/booking-order.entity'; +import { ContractQuantityLineView } from './dto/contract-view.dto'; + +/** Setting code holding the global ordering window (in months) for general contracts. */ +export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; +/** Fallback when the setting is missing or unparseable. */ +export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; + +/** + * Owns general-contract concerns that sit alongside the generic booking flow: + * the configurable ordering period, post-payment activation, and computing the + * remaining drawdown pool per contract. + */ +@Injectable() +export class GeneralContractService { + private readonly logger = new Logger(GeneralContractService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly dropdownSettings: DropdownSettingsService, + ) {} + + isGeneralContract(booking: Pick): boolean { + return booking.bookingType === BookingType.GeneralContract; + } + + /** The configured ordering window in months (defaults to 3). */ + async getPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode( + CONTRACT_PERIOD_SETTING_CODE, + ); + const raw = setting.children?.[0]?.value; + const months = Number(raw); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded yet — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + + /** + * Called when a general contract's payment succeeds: mark it ACTIVE (instead of + * entering the train queue like a one-time booking) and stamp the ordering + * window. Idempotent. + */ + async activateAfterPayment(bookingId: string): Promise { + const repo = this.dataSource.getRepository(Booking); + const booking = await repo.findOne({ where: { id: bookingId } }); + if (!booking || !this.isGeneralContract(booking)) return; + if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') { + return; + } + + const months = await this.getPeriodMonths(); + const expiresAt = new Date(); + expiresAt.setMonth(expiresAt.getMonth() + months); + + await repo.update(bookingId, { + status: 'CONTRACT_ACTIVE', + paymentStatus: 'PAID', + expiresAt, + }); + this.logger.log( + `General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`, + ); + } + + /** + * The drawdown pool for a contract: contracted vs. ordered vs. remaining, + * per container type for CONTAINER contracts, or a single total line for + * BULK/BREAK_BULK (keyed on a null container type). + */ + async getQuantityLines( + contractBookingId: string, + ): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: contractBookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`); + + const ordered = await this.orderedByContainerType(contractBookingId); + + if (booking.freightType === 'CONTAINER') { + return (booking.bookingContainers ?? []).map((c) => { + const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0; + const contracted = Number(c.quantity); + return { + containerTypeId: c.containerTypeId ?? null, + containerTypeName: c.containerType?.label ?? null, + unitOfMeasure: null, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }; + }); + } + + // BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items. + const orderedQty = ordered.get('') ?? 0; + const contracted = Number(booking.cargoTotalWeightVgm); + const uom: CargoUnitOfMeasure | null = + (booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ?? + CargoUnitOfMeasure.PerTon; + return [ + { + containerTypeId: null, + containerTypeName: null, + unitOfMeasure: uom, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }, + ]; + } + + /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ + private async orderedByContainerType( + contractBookingId: string, + ): Promise> { + const rows = await this.dataSource + .getRepository(BookingOrder) + .createQueryBuilder('o') + .innerJoin('o.lines', 'line') + .select('COALESCE(line.container_type_id::text, :empty)', 'key') + .addSelect('SUM(line.quantity)', 'total') + .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) + .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) + .setParameter('empty', '') + .groupBy('key') + .getRawMany<{ key: string; total: string }>(); + + const map = new Map(); + for (const row of rows) map.set(row.key ?? '', Number(row.total)); + return map; + } + + /** Convenience: how many units remain for a given container type ('' = bulk). */ + async remainingFor( + contractBookingId: string, + containerTypeKey: string, + ): Promise { + const lines = await this.getQuantityLines(contractBookingId); + const line = lines.find( + (l) => (l.containerTypeId ?? '') === containerTypeKey, + ); + return line?.remainingQuantity ?? 0; + } + + /** True once every contracted line is fully drawn down. */ + async isExhausted(contractBookingId: string): Promise { + const lines = await this.getQuantityLines(contractBookingId); + return lines.every((l) => l.remainingQuantity <= 0); + } +} 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 index 8a2d52172..e15fcba0d 100644 --- 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 @@ -59,6 +59,7 @@ export function buildCargoTypeTree( name: child.cargoTypeName, code: child.code, show_free_text_box: child.showFreeTextBox, + unit_of_measure: child.unitOfMeasure ?? null, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index e1e75e049..6c45b1b48 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -30,6 +30,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -585,6 +586,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.booking_type = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 5253ca9bd..90258e47b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -258,6 +258,7 @@ export class BookingsService { // } const isGovernment = dto.isGovernment === true; + const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT'; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { @@ -292,11 +293,12 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } - } else { + } else if (!isGeneralContract) { // Day-level pool: the customer picked a DAY — require that the route has at // least one OPEN departure on that EAT day. The batch engine assigns the - // train later. - const day = eatDay(new Date(dto.scheduledDate)); + // train later. General contracts skip this — they have no shipment date at + // creation; each drawdown order validates its own day. + const day = eatDay(new Date(dto.scheduledDate!)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, @@ -395,7 +397,8 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, - scheduledDate: new Date(dto.scheduledDate), + bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', @@ -647,6 +650,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -815,6 +819,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, 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 index 0dc2bd255..35df9dcd3 100644 --- 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 @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CargoUnitOfMeasure } from '@edr/types'; export class BookingReferenceYardDto { @ApiProperty({ format: 'uuid' }) @@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty() show_free_text_box!: boolean; + + @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) + unit_of_measure?: CargoUnitOfMeasure | null; } export class BookingReferenceCargoTypeGroupDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index fa3bb6f4d..b7d5ea14d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -17,7 +17,7 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity'; import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; @@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; export { BOOKING_STATUSES, + BOOKING_TYPES, CONTRACT_TYPES, EQUIPMENT_RETURNS, FREIGHT_TYPES, @@ -105,10 +106,24 @@ export class CreateBookingDto { @IsUUID() trainScheduleId?: string; - /** The day the customer wants to ship (the pool day key). */ - @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) + @ApiPropertyOptional({ + enum: BOOKING_TYPES, + default: 'ONE_TIME', + description: + 'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.', + }) + @IsOptional() + @IsIn([...BOOKING_TYPES]) + bookingType?: string; + + /** + * The day the customer wants to ship (the pool day key). Required for one-time + * bookings; omitted for general contracts, which pick the date per order. + */ + @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) + @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') @IsDateString() - scheduledDate!: string; + scheduledDate?: string; @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 9ce90d2b9..deee17cf0 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -3,6 +3,7 @@ import { Transform } from 'class-transformer'; import { IsIn, IsOptional, IsUUID } from 'class-validator'; import { BOOKING_STATUSES, + BOOKING_TYPES, FREIGHT_TYPES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS, @@ -56,6 +57,11 @@ export class FilterBookingDto { @IsIn([...FREIGHT_TYPES]) freightType?: string; + @ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' }) + @IsOptional() + @IsIn([...BOOKING_TYPES]) + bookingType?: string; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index d110391b7..d5f358e5f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -41,10 +41,15 @@ export const BOOKING_STATUSES = [ 'CANCELLED', 'PENDING_CONSOLIDATION', 'CONSOLIDATED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; +export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const; +export type BookingTypeValue = (typeof BOOKING_TYPES)[number]; + export const PAYMENT_STATUSES = [ 'PENDING', 'PNR_GENERATED', @@ -125,8 +130,28 @@ export class Booking extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; - @Column({ name: 'scheduled_date', type: 'timestamptz' }) - scheduledDate!: Date; + /** + * ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an + * umbrella contract that is signed/paid once and then drawn down by many + * orders (each order spawns its own ONE_TIME child booking). + */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + + /** + * Nullable: general contracts have no shipment date at creation — the date is + * chosen per drawdown order. One-time bookings always set this (the pool day key). + */ + @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) + scheduledDate?: Date | null; + + /** + * General contracts only: when the ordering window closes, computed from the + * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time + * bookings and for contracts that are not yet active. + */ + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date | null; @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e21ea87b9..e496bb867 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer"; import { InternalPaymentController } from "./internal-payment.controller"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; imports: [ HttpModule.register({ timeout: 10_000 }), ConfigModule, + DropdownSettingsModule, forwardRef(() => TrainSchedulingModule), TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), RabbitMQModule.forRootAsync({ diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 177b7475b..f159b8a0a 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -34,6 +34,11 @@ import { RefundDto, } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; + +/** Setting code holding the global ordering window (months) for general contracts. */ +const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, @@ -54,8 +59,23 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly dropdownSettings: DropdownSettingsService, ) { } + /** Configured general-contract ordering window in months (defaults to 3). */ + private async contractPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode( + CONTRACT_PERIOD_SETTING_CODE, + ); + const months = Number(setting.children?.[0]?.value); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + async getAll(filters: { search?: string; status?: string; @@ -293,15 +313,44 @@ export class PaymentService { const paidAt = input.paidAt ?? new Date(); + // A general contract is paid once, up front; it does NOT enter the train + // queue (nothing has been ordered yet). Instead it becomes ACTIVE and + // opens its ordering window. Orders placed later spawn their own paid + // child bookings that go through the normal pipeline. + const booking = await this.datasource + .getRepository(Booking) + .findOne({ where: { id: input.bookingId } }); + const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT"; + + let contractExpiresAt: Date | null = null; + if (isGeneralContract) { + const months = await this.contractPeriodMonths(); + contractExpiresAt = new Date(paidAt); + contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); + } + await this.datasource.transaction(async (mg) => { await mg.update( PaymentEntity, { id: intent.id }, { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, ); - await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"}); + await mg.update( + Booking, + { id: input.bookingId }, + isGeneralContract + ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } + : { paymentStatus: "PAID", status: "PAID" }, + ); }); + if (isGeneralContract) { + this.logger.log( + `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, + ); + return { alreadyFinalized: false }; + } + try { await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); } catch (err) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 57fe48fed..794f97f83 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { CargoUnitOfMeasure } from '@edr/types'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -7,6 +8,14 @@ export class CreateCargoTypeDto { @MaxLength(255) cargoTypeName!: string; + @ApiPropertyOptional({ + enum: CargoUnitOfMeasure, + description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)', + }) + @IsOptional() + @IsEnum(CargoUnitOfMeasure) + unitOfMeasure?: CargoUnitOfMeasure; + @ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index a0bd9ddaf..2c1ed0e22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { CargoUnitOfMeasure } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; @Entity({ schema: 'freight', name: 'cargo_types' }) @@ -19,6 +20,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) showFreeTextBox!: boolean; + /** + * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM + * (break-bulk). Nullable for container/legacy cargo, which is counted by + * container. Drives the unit shown when ordering against a general contract. + */ + @Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true }) + unitOfMeasure?: CargoUnitOfMeasure | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 634ac5faa..5a343f910 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -82,6 +82,7 @@ export class CargoTypesService { showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, + unitOfMeasure: dto.unitOfMeasure ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts index a7f7350c4..fa8e79037 100644 --- a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -1,7 +1,9 @@ export interface SchedulingPriorityBooking { isGovernment?: boolean; priorityScore?: number | null; - scheduledDate: Date | string; + // One-time bookings always carry a date; general contracts (never scheduled) + // may be null — treated as epoch 0 so they sort last. + scheduledDate?: Date | string | null; } /** Government first, then priority score, then earliest scheduled date. */ @@ -15,5 +17,7 @@ export function compareSchedulingPriority( const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; - return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0; + const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0; + return aTime - bTime; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 2e721825e..3b00abd0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; - return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0; + const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0; + return aTime - bTime; }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 7bfe5811e..3928376ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1880,7 +1880,7 @@ export class TrainSchedulingService { origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', - preferredDepartureDate: booking.scheduledDate.toISOString(), + preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null, status: booking.status, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 89d094734..15137cb26 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -2,6 +2,7 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, Home, + Layers, Loader2, MapPin, Receipt, @@ -35,6 +36,8 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import MyBookings from "./pages/bookings/MyBookings"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import ContractsList from "./pages/contracts/ContractsList"; +import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; @@ -199,6 +202,11 @@ const sidebarItems: SidebarItem[] = [ href: "/bookings", icon: , }, + { + label: "General Contracts", + href: "/contracts", + icon: , + }, { label: "Tracking", href: "/tracking", @@ -287,6 +295,8 @@ const App = () => { path="/bookings/:id/contract" element={} /> + } /> + } /> } /> } /> {/* Profile was merged into Settings — keep old links working. */} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 0a610507d..dd76d893a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,4 +1,5 @@ import { api } from "@/services/api"; +import { Freight } from "@edr/types"; import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import type { CreateBookingPayload, @@ -175,6 +176,30 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const bookingType = form.watch("bookingType"); + const isGeneralContract = bookingType === "general_contract"; + + // General contracts have no shipment date at creation — the Schedule step + // (id 5) is skipped; the date is chosen per order against the contract later. + const visibleSteps = useMemo( + () => STEPS.filter((s) => !(isGeneralContract && s.id === 5)), + [isGeneralContract], + ); + const visibleStepIds = useMemo( + () => visibleSteps.map((s) => s.id), + [visibleSteps], + ); + const currentStepIndex = visibleStepIds.indexOf(step); + const isLastStep = currentStepIndex === visibleStepIds.length - 1; + const isFirstStep = currentStepIndex <= 0; + const goToStep = (delta: number) => { + const idx = visibleStepIds.indexOf(step); + const nextIdx = Math.min( + visibleStepIds.length - 1, + Math.max(0, idx + delta), + ); + setStep(visibleStepIds[nextIdx]); + }; const direction = useMemo(() => { const origin = referenceData?.yard.find((y) => y.id === originYard); @@ -210,7 +235,7 @@ export default function NewBookingPage() { return; } - setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); + goToStep(1); } function buildApiPayload(data: BookingFormValues): CreateBookingPayload { @@ -259,10 +284,20 @@ export default function NewBookingPage() { (s) => s.id === data.serviceTypeId, )!; + const isContract = data.bookingType === "general_contract"; + return { - scheduledDate: data.scheduledDate - ? new Date(data.scheduledDate).toISOString() - : new Date().toISOString(), + bookingType: isContract + ? Freight.BookingType.GeneralContract + : Freight.BookingType.OneTime, + // General contracts omit the shipment date — chosen per order later. + ...(isContract + ? {} + : { + scheduledDate: data.scheduledDate + ? new Date(data.scheduledDate).toISOString() + : new Date().toISOString(), + }), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], serviceTypeId: data.serviceTypeId, @@ -408,7 +443,7 @@ export default function NewBookingPage() { > - + {persistAndPriceMutation.isError && ( @@ -494,13 +529,13 @@ export default function NewBookingPage() { variant="default" radius="md" leftSection={} - onClick={() => setStep((s) => Math.max(1, s - 1))} - disabled={step === 1} + onClick={() => goToStep(-1)} + disabled={isFirstStep} > Back - {step < STEPS.length ? ( + {!isLastStep ? ( + + + ); + } + + const isContainer = contract.freightType === "CONTAINER"; + const isActive = contract.status === "CONTRACT_ACTIVE"; + const awaitingPayment = contract.status === "FULLY_EXECUTED"; + const poolLines = pool ?? []; + + return ( + + + {/* Header */} + + + + + + + +
+ + + {contract.reference} + + + + + General contract · {isContainer ? "Containerised" : "Bulk"} + +
+
+
+ + + {awaitingPayment && } + {isActive && ( + + )} + +
+ + {/* Summary */} + + + } + value={`${contract.originYard?.label ?? "—"} → ${contract.destinationYard?.label ?? "—"}`} + /> + } + value={ + contract.expiresAt + ? new Date(contract.expiresAt).toLocaleDateString() + : "Not active yet" + } + /> + } + value={contract.tradeDirection ?? "—"} + /> + + + + {/* Drawdown pool */} + {contract.status !== "DRAFT" && ( + + + Contracted quantity + + + How much of this contract has been ordered versus what remains. + + + {poolLines.length === 0 && ( + + No quantity pool available. + + )} + {poolLines.map((line, i) => { + const pct = + line.contractedQuantity > 0 + ? Math.min( + 100, + (line.orderedQuantity / line.contractedQuantity) * 100, + ) + : 0; + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( +
+ + + {label} + + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )} + {" "} + remaining of{" "} + {formatQuantity( + line.contractedQuantity, + line.unitOfMeasure, + isContainer, + )} + + + +
+ ); + })} +
+
+ )} + + {/* Orders */} + + + Orders ({orders?.length ?? 0}) + + {!orders || orders.length === 0 ? ( + + {isActive + ? "No orders yet. Use “Place order” to draw down from this contract." + : "Orders can be placed once the contract is active (paid)."} + + ) : ( + + {orders.map((order, idx) => ( + + +
+ + {order.reference} + + + Ship {new Date(order.scheduledDate).toLocaleDateString()} + {" · "} + {order.lines + .map( + (l) => + `${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${ + l.containerTypeName ? ` ${l.containerTypeName}` : "" + }`, + ) + .join(", ")} + +
+ +
+
+ ))} +
+ )} +
+
+ + setOrderOpen(false)} + contract={contract} + pool={poolLines} + onPlaced={() => setOrderOpen(false)} + /> +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx new file mode 100644 index 000000000..0940798ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -0,0 +1,247 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Card, + Group, + Paper, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { Layers, Plus, Search } from "lucide-react"; + +import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; +import { + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, +} from "@edr/ui-common"; +import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; + +export default function ContractsList() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + + const filter: BookingListFilter = useMemo( + () => ({ + bookingType: "GENERAL_CONTRACT", + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + sortBy: "createdAt", + sortOrder: "DESC", + }), + [pagination.pageIndex, pagination.pageSize], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + const rows = useMemo(() => { + const items = data?.items ?? []; + if (!query.trim()) return items; + const q = query.toLowerCase(); + return items.filter( + (b) => + b.reference?.toLowerCase().includes(q) || + b.originYard?.label?.toLowerCase().includes(q) || + b.destinationYard?.label?.toLowerCase().includes(q), + ); + }, [data, query]); + + const activeCount = useMemo( + () => + (data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length, + [data], + ); + + const columns: ColumnDef[] = [ + { + id: "reference", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + + + +
+ + {b.reference} + + + {b.freightType === "CONTAINER" ? "Containerised" : "Bulk"} + +
+
+ ); + }, + }, + { + id: "route", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + {b.originYard?.label ?? "—"}{" "} + + → + {" "} + {b.destinationYard?.label ?? "—"} + + ); + }, + }, + { + id: "expires", + header: () => , + cell: ({ row }) => { + const exp = row.original.expiresAt; + return ( + + {exp ? new Date(exp).toLocaleDateString() : "—"} + + ); + }, + }, + { + id: "status", + header: () => , + cell: ({ row }) => , + }, + ]; + + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const total = data?.meta?.total ?? (data?.items?.length ?? 0); + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + return ( + + + {/* Header */} + + + + General Contracts + + + Reserve a quantity once, then place orders against it until the + contract runs out or its window closes. + + + + + + {/* Summary */} + + + {/* Search */} + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + radius="md" + styles={{ input: { height: 44 } }} + maw={420} + /> + + {/* Table */} + + + navigate(`/contracts/${(row as Freight.IBooking).id}`) + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + footer={DataTableFooter} + emptyMessage="No general contracts yet. Create one from New Booking → General Contract." + /> + + + + ); +} + +function ColHeader({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function SimpleStat({ + label, + value, + hint, +}: { + label: string; + value: number | string; + hint?: string; +}) { + return ( + + + {label} + + + + {value} + + {hint && ( + + {hint} + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx new file mode 100644 index 000000000..7ad77f6eb --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Button, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, +} from "@mantine/core"; +import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; +import { formatQuantity, GREEN, INK } from "./contract-ui"; + +interface PlaceOrderDialogProps { + opened: boolean; + onClose: () => void; + contract: Freight.IBooking; + pool: Freight.ContractQuantityLine[]; + onPlaced: () => void; +} + +/** + * Place a drawdown order against an ACTIVE general contract. The customer picks + * a shipment day (constrained to days with a departure on the contract's route) + * and a quantity per pool line, validated against the remaining quantity. + */ +export function PlaceOrderDialog({ + opened, + onClose, + contract, + pool, + onPlaced, +}: PlaceOrderDialogProps) { + const queryClient = useQueryClient(); + const isContainer = contract.freightType === "CONTAINER"; + + const [scheduledDate, setScheduledDate] = useState(null); + const [quantities, setQuantities] = useState>({}); + + const { data: availableDays, isLoading: daysLoading } = useQuery({ + ...api.bookings.getAvailableDays.queryOptions({ + input: { + originYardId: contract.originYard?.id, + destinationYardId: contract.destinationYard?.id, + }, + }), + enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id, + }); + + const dayOptions = useMemo( + () => + (availableDays ?? []).map((d) => ({ + value: d, + label: new Date(d).toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }), + })), + [availableDays], + ); + + const lineKey = (line: Freight.ContractQuantityLine) => + line.containerTypeId ?? "__bulk__"; + + const createMutation = useMutation({ + ...api.bookingOrders.create.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.listByContract.queryKey({ + contractBookingId: contract.id, + }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.pool.queryKey({ + contractBookingId: contract.id, + }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: contract.id }), + }); + reset(); + onPlaced(); + }, + }); + + function reset() { + setScheduledDate(null); + setQuantities({}); + } + + function handleClose() { + if (createMutation.isPending) return; + reset(); + onClose(); + } + + function handleSubmit() { + if (!scheduledDate) return; + const lines: Freight.CreateBookingOrderLineDto[] = pool + .map((line) => { + const raw = quantities[lineKey(line)]; + const qty = typeof raw === "number" ? raw : 0; + return { + containerTypeId: isContainer ? line.containerTypeId : null, + quantity: qty, + }; + }) + .filter((l) => l.quantity > 0); + + if (lines.length === 0) return; + + createMutation.mutate({ + contractBookingId: contract.id, + scheduledDate: new Date(scheduledDate).toISOString(), + lines, + }); + } + + const orderableLines = pool.filter((l) => l.remainingQuantity > 0); + const hasQuantity = pool.some((l) => { + const raw = quantities[lineKey(l)]; + return typeof raw === "number" && raw > 0; + }); + const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending; + + return ( + + + + Place an order + + + } + radius="lg" + centered + size="md" + > + + + Draw down from contract {contract.reference}. Route, + cargo and service are inherited — just pick a shipment date and + quantity. + + +