diff --git a/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts b/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts new file mode 100644 index 000000000..dd232b28a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Consolidation is now system-managed: the backend consolidates partial-wagon + * container bookings automatically, derived from the container quantities. The + * `allow_consolidation` opt-in flag is therefore redundant and is dropped. + * `consolidation_partner_id` (the actual pairing link) is unaffected. + */ +export class DropAllowConsolidation1820000000000 implements MigrationInterface { + name = 'DropAllowConsolidation1820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS allow_consolidation;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS allow_consolidation BOOLEAN NOT NULL DEFAULT false;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts b/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts new file mode 100644 index 000000000..23eda1730 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Multi-route general contracts: a contract may reserve quantity across several + * routes. Each (contract, route, container type) is a row here; drawdown orders + * reference the route line they drew from via booking_orders.route_line_id. + */ +export class CreateContractRouteLines1820000000001 + implements MigrationInterface +{ + name = 'CreateContractRouteLines1820000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'contract_route_lines', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'contract_booking_id', type: 'uuid' }, + { name: 'origin_yard_id', type: 'uuid' }, + { name: 'destination_yard_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: ['contract_booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.contract_route_lines', + new TableIndex({ + name: 'idx_contract_route_lines_contract', + columnNames: ['contract_booking_id'], + }), + ); + + await queryRunner.query( + `ALTER TABLE freight.booking_orders ADD COLUMN IF NOT EXISTS route_line_id uuid;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_orders DROP COLUMN IF EXISTS route_line_id;`, + ); + await queryRunner.dropTable('freight.contract_route_lines', true); + } +} 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 index 6821c4e7d..b05895407 100644 --- 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 @@ -45,6 +45,15 @@ export class BookingOrdersController { return this.generalContractService.getQuantityLines(id); } + @Get('contract/:id/routes') + @ApiOperation({ + summary: + 'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.', + }) + async routes(@Param('id', ParseUUIDPipe) id: string) { + return this.generalContractService.getRouteLines(id); + } + @Get(':id') @ApiOperation({ summary: 'Get a single booking order' }) async findOne(@Param('id', ParseUUIDPipe) id: string) { 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 index c8ea869be..3347bef97 100644 --- 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 @@ -9,11 +9,12 @@ 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 { ContractRouteLine } from './entities/contract-route-line.entity'; import { GeneralContractService } from './general-contract.service'; @Module({ imports: [ - TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]), + TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]), BookingsModule, CompaniesModule, DropdownSettingsModule, 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 index 1a75c7914..c306d420b 100644 --- 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 @@ -79,12 +79,38 @@ export class BookingOrdersService { throw new BadRequestException('You do not have access to this contract'); } + // Resolve the route the order ships on: a chosen contract route line for a + // multi-route contract, else the contract's own origin/destination. + const routeLines = await this.generalContractService.getRouteLines( + contract.id, + ); + let originYardId = contract.originYardId; + let destinationYardId = contract.destinationYardId; + let routeLineId: string | null = null; + + if (routeLines.length > 0) { + if (!dto.routeLineId) { + throw new BadRequestException( + 'This contract has multiple routes — select a route to draw from', + ); + } + const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId); + if (!chosen) { + throw new BadRequestException( + 'Selected route is not part of this contract', + ); + } + originYardId = chosen.originYardId; + destinationYardId = chosen.destinationYardId; + routeLineId = chosen.routeLineId; + } + // 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, + originYardId, + destinationYardId, day, ); if (!hasDeparture) { @@ -93,41 +119,64 @@ export class BookingOrdersService { ); } - // 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 orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + + if (routeLineId) { + // Multi-route: validate against the chosen route line's remaining pool. + 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) { + const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!; + if (orderTotal > chosen.remainingQuantity) { throw new BadRequestException( - isContainer - ? `Container type ${line.containerTypeId} is not part of this contract` - : 'This contract has no matching quantity pool', + `Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`, ); } - if (line.quantity > poolLine.remainingQuantity) { - throw new BadRequestException( - `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + - (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), - ); + } else { + // Single-route: validate each line against the per-container-type pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + 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 childBooking = await this.spawnChildBooking( + contract, + dto, + { originYardId, destinationYardId }, + manager, + ); const reference = await this.generateReference(); const orderRow = manager.create(BookingOrder, { reference, contractBookingId: contract.id, bookingId: childBooking.id, + routeLineId, companyId: contract.companyId ?? null, scheduledDate: new Date(dto.scheduledDate), status: 'PAID', @@ -150,8 +199,8 @@ export class BookingOrdersService { // 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, + originYardId, + destinationYardId, day, }); } catch (err) { @@ -180,6 +229,7 @@ export class BookingOrdersService { private async spawnChildBooking( contract: Booking, dto: CreateBookingOrderDto, + route: { originYardId: string; destinationYardId: string }, manager: import('typeorm').EntityManager, ): Promise { const reference = await this.generateChildBookingReference(); @@ -213,8 +263,8 @@ export class BookingOrdersService { firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, equipmentReturn: contract.equipmentReturn, - originYardId: contract.originYardId, - destinationYardId: contract.destinationYardId, + originYardId: route.originYardId, + destinationYardId: route.destinationYardId, tradeDirection: contract.tradeDirection, freightType: contract.freightType, cargoTypeId: contract.cargoTypeId ?? null, @@ -233,7 +283,6 @@ export class BookingOrdersService { customerSignedAt: now, priorityScore: contract.priorityScore, totalAmount: 0, - allowConsolidation: false, schedulingStatus: 'NOT_SCHEDULED', }); const savedChild = await manager.save(child); 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 index 6c0c86669..fc2cc0325 100644 --- 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 @@ -21,3 +21,36 @@ export class ContractQuantityLineView { @ApiProperty() remainingQuantity!: number; } + +/** A contracted/ordered/remaining pool line for one route of a general contract. */ +export class ContractRouteLineView { + @ApiProperty({ description: 'Contract route line id' }) + routeLineId!: string; + + @ApiProperty() + originYardId!: string; + + @ApiProperty({ nullable: true }) + originYardName!: string | null; + + @ApiProperty() + destinationYardId!: string; + + @ApiProperty({ nullable: true }) + destinationYardName!: string | null; + + @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) + containerTypeId!: string | null; + + @ApiProperty({ nullable: true }) + containerTypeName!: string | 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 index 7043e9704..c7712b5b9 100644 --- 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 @@ -32,6 +32,16 @@ export class CreateBookingOrderDto { @IsUUID() contractBookingId!: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'For multi-route contracts: the contract route line being drawn from. ' + + 'Determines the shipment origin/destination. Omit for single-route contracts.', + }) + @IsOptional() + @IsUUID() + routeLineId?: string; + @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) @IsDateString() scheduledDate!: string; 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 index 5d6857051..610496d79 100644 --- 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 @@ -40,6 +40,14 @@ export class BookingOrder extends BaseEntity { @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The contract route line this order drew down (multi-route general contracts). + * Null for legacy/single-route contracts that have no route lines — the order + * then uses the contract's own origin/destination. + */ + @Column({ name: 'route_line_id', type: 'uuid', nullable: true }) + routeLineId?: string | null; + @Column({ name: 'scheduled_date', type: 'timestamptz' }) scheduledDate!: Date; diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts new file mode 100644 index 000000000..0bac5bbd6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts @@ -0,0 +1,53 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; + +/** + * One contracted route+quantity line of a GENERAL contract. A general contract + * may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each + * route reserves its own quantity pool. Drawdown orders pick one of these routes + * and decrement that route's pool. One-time bookings do not use this — they keep + * the single origin/destination on the booking itself. + */ +@Entity({ schema: 'freight', name: 'contract_route_lines' }) +@Index(['contractBookingId']) +export class ContractRouteLine extends BaseEntity { + /** 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; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + /** + * Container type this route line reserves (CONTAINER contracts); null for + * BULK/BREAK_BULK, where the quantity is tons/items. + */ + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + /** Contracted quantity for this (route, container type): containers, tons, or items. */ + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) + quantity!: number; +} 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 index ba382d085..107addc9c 100644 --- 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 @@ -4,7 +4,11 @@ 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'; +import { ContractRouteLine } from './entities/contract-route-line.entity'; +import { + ContractQuantityLineView, + ContractRouteLineView, +} 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'; @@ -120,6 +124,69 @@ export class GeneralContractService { ]; } + /** + * Per-route drawdown pool for a multi-route general contract: contracted vs. + * ordered vs. remaining, one entry per contracted route line. Returns [] for + * single-route contracts (no route lines) — callers fall back to + * {@link getQuantityLines}. + */ + async getRouteLines( + contractBookingId: string, + ): Promise { + const routeLines = await this.dataSource + .getRepository(ContractRouteLine) + .find({ + where: { contractBookingId }, + relations: { + originYard: true, + destinationYard: true, + containerType: true, + }, + order: { createdAt: 'ASC' }, + }); + if (routeLines.length === 0) return []; + + const ordered = await this.orderedByRouteLine(contractBookingId); + + return routeLines.map((rl) => { + const orderedQty = ordered.get(rl.id) ?? 0; + const contracted = Number(rl.quantity); + return { + routeLineId: rl.id, + originYardId: rl.originYardId, + originYardName: rl.originYard?.label ?? null, + destinationYardId: rl.destinationYardId, + destinationYardName: rl.destinationYard?.label ?? null, + containerTypeId: rl.containerTypeId ?? null, + containerTypeName: rl.containerType?.label ?? null, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }; + }); + } + + /** Sum of non-cancelled order quantities, keyed by route_line_id. */ + private async orderedByRouteLine( + contractBookingId: string, + ): Promise> { + const rows = await this.dataSource + .getRepository(BookingOrder) + .createQueryBuilder('o') + .innerJoin('o.lines', 'line') + .select('o.route_line_id', 'key') + .addSelect('SUM(line.quantity)', 'total') + .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) + .andWhere('o.route_line_id IS NOT NULL') + .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) + .groupBy('o.route_line_id') + .getRawMany<{ key: string; total: string }>(); + + const map = new Map(); + for (const row of rows) if (row.key) map.set(row.key, Number(row.total)); + return map; + } + /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ private async orderedByContainerType( contractBookingId: string, @@ -155,6 +222,12 @@ export class GeneralContractService { /** True once every contracted line is fully drawn down. */ async isExhausted(contractBookingId: string): Promise { + // Multi-route contracts are exhausted when every route line is drawn down; + // single-route contracts fall back to the per-container-type pool. + const routeLines = await this.getRouteLines(contractBookingId); + if (routeLines.length > 0) { + return routeLines.every((l) => l.remainingQuantity <= 0); + } const lines = await this.getQuantityLines(contractBookingId); return lines.every((l) => l.remainingQuantity <= 0); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 746e7d4f3..c06981cce 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -10,6 +10,10 @@ import { RuleEngineService, } from '../rule-engine/rule-engine.service'; import { BookingsRepository } from './bookings.repository'; +import { + containersPerWagon, + wagonRemainder, +} from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -32,6 +36,29 @@ type StoredPricingBreakdown = { generatedAt?: string; } | null; +/** Friendly labels for the per-unit rate card shown at the confirm step. */ +const SURCHARGE_LABELS: Record = { + HAZARD_SURCHARGE: 'Hazardous cargo', + HAZARDOUS_CARGO: 'Hazardous cargo', + REEFER_SURCHARGE: 'Refrigerated (reefer)', + REEFER_CARGO: 'Refrigerated (reefer)', + OVERWEIGHT_PER_TON: 'Overweight excess', + DOUBLE_HANDLING: 'Double handling', + LASHING: 'Lashing', + PIL_EXTRA_FEE: 'Shipping line fee', +}; + +function surchargeLabel(code: string): string { + return ( + SURCHARGE_LABELS[code] ?? + code + .toLowerCase() + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') + ); +} + @Injectable() export class BookingPricingService { constructor( @@ -101,16 +128,32 @@ export class BookingPricingService { for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + + const rate = rateById.get(mod.rateId); + const unit = rate?.rateUnit ?? 'FLAT'; + const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + // Per-unit count: explicit trigger (e.g. overweight tons) when present, + // otherwise derived from total ÷ unit price (FLAT surcharges → 1). + const quantity = + mod.triggerValue != null && mod.triggerValue > 0 + ? mod.triggerValue + : unitUsd > 0 + ? Math.max(1, Math.round(usdAmount / unitUsd)) + : 1; + const item: PriceLineItemDto = { code: mod.surchargeTypeCode, - description: `Surcharge: ${mod.surchargeTypeCode}`, + description: surchargeLabel(mod.surchargeTypeCode), amount: convertedAmount, + unitAmount, + unit, + quantity, currency: paymentCurrency, }; lineItems.push(item); total += convertedAmount; - const rate = rateById.get(mod.rateId); if (rate) usedRatesMap.set(rate.id, rate); } @@ -164,7 +207,7 @@ export class BookingPricingService { } async buildEvalInputForBooking(booking: Booking): Promise { - const containers = await Promise.all( + const lines = await Promise.all( (booking.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map(async (bc) => { @@ -172,14 +215,19 @@ export class BookingPricingService { const vgm = Number(bc.vgmPerUnitTons); const qty = bc.quantity; return { - containerTypeId: bc.containerTypeId, + container: { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }, + perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), quantity: qty, - vgmPerUnitTons: vgm, - totalVgmTons: qty * vgm, - isReefer: ct.isReefer, }; }), ); + const containers = lines.map((l) => l.container); // Wagon count is persisted per container line at booking creation; sum it. const totalWagons = booking.freightType === 'CONTAINER' @@ -191,6 +239,13 @@ export class BookingPricingService { ) : 0; + // Consolidation is system-managed: the CONSOLIDATION_ENABLED surcharge fires + // whenever any container line leaves a wagon partially filled. Derived from + // the container quantities — there is no persisted opt-in flag. + const allowConsolidation = + booking.freightType === 'CONTAINER' && + lines.some((l) => wagonRemainder(l.quantity, l.perWagon) > 0); + return { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId ?? null, @@ -199,7 +254,7 @@ export class BookingPricingService { tradeDirection: booking.tradeDirection, isHazardous: booking.isHazardous, isGovernment: booking.isGovernment, - allowConsolidation: booking.allowConsolidation, + allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, containers, @@ -238,6 +293,9 @@ export class BookingPricingService { code: 'TOTAL', description: 'Contract total', amount: total, + unitAmount: total, + unit: 'FLAT', + quantity: 1, currency: booking.paymentCurrency, }, ], @@ -301,10 +359,15 @@ export class BookingPricingService { usedRatesMap.set(rate.id, rate); const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = Number(rate.rateValue); + const label = await this.containerTypeLabel(container.containerTypeId); lines.push({ code: rateType, - description: `Base rail (${rateType})`, + description: `${label} rail freight`, amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: rate.rateUnit, + quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } @@ -320,10 +383,14 @@ export class BookingPricingService { isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; const usdAmount = this.amountForRate(fallback, quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = Number(fallback.rateValue); lines.push({ code: rateType, - description: `Base rail (${rateType})`, + description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: fallback.rateUnit, + quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); } @@ -332,6 +399,34 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ + private async containerTypeLabel(containerTypeId: string): Promise { + try { + const ct = await this.containerTypesService?.findById?.(containerTypeId); + return ct?.label ?? 'Container'; + } catch { + return 'Container'; + } + } + + /** How many units a rate's total is divided into, by rate unit (for the per-unit card). */ + private effectiveUnitQuantity( + rateUnit: string, + quantity: number, + wagonCount: number, + ): number { + switch (rateUnit) { + case 'PER_WAGON': + return wagonCount; + case 'FLAT': + return 1; + case 'PER_CONTAINER': + case 'PER_TON': + default: + return quantity; + } + } + private pickRate( rates: Rate[], rateType: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index c41013ddb..38cf0fca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -420,6 +420,32 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + /** + * Customer rejects the priced booking at the confirm step. The booking becomes + * REJECTED (terminal) — the customer starts a new booking rather than editing + * this one. Only a not-yet-committed booking can be rejected this way. + */ + async reject(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'DRAFT', + 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', + 'PENDING_CONSOLIDATION', + ]); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason?.trim() || 'Customer rejected the price estimate.', + 'REJECTION', + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + async enrichBookingResponse(booking: Booking): Promise { .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') .where('b.id != :bookingId', { bookingId: booking.id }) - .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') // Only pair bookings the customer has committed (SUBMITTED) or that are // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so @@ -650,11 +648,6 @@ export class BookingsRepository extends BaseRepository { excludePaymentStatus: options.excludePaymentStatus, }); } - if (options.allowConsolidation !== undefined) { - qb.andWhere('booking.allow_consolidation = :allowConsolidation', { - allowConsolidation: options.allowConsolidation, - }); - } if (options.consolidationPaired === 'true') { qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); } else if (options.consolidationPaired === 'false') { 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 cac19f111..21b145774 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -126,7 +127,6 @@ export class BookingsService { tradeDirection: string; isHazardous?: boolean; isGovernment?: boolean; - allowConsolidation?: boolean; shippingLineId?: string | null; containers: CreateBookingContainerDto[]; }): Promise { @@ -151,6 +151,14 @@ export class BookingsService { containers.reduce((sum, c) => sum + c.wagonsRequired, 0), ); + // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger + // fires whenever a container line leaves a wagon partially filled. There is + // no customer opt-in — partial-wagon cargo always consolidates. + const allowConsolidation = + dto.freightType === 'CONTAINER' + ? await this.needsConsolidation(dto.containers) + : false; + return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, @@ -159,8 +167,7 @@ export class BookingsService { tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, isGovernment: dto.isGovernment ?? false, - allowConsolidation: - dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, + allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, containers, @@ -168,26 +175,20 @@ export class BookingsService { } /** - * Enable consolidation when any container line leaves a wagon partially filled - * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). - * - * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a - * half-empty wagon, so `explicit === false` is ignored when consolidation is - * actually needed. The opt-in flag only matters for cargo that already fills - * whole wagons (where consolidation is moot anyway). + * True when any container line leaves a wagon partially filled (e.g. 1×20ft on + * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize; + * cargo that already fills whole wagons never does. This is computed from the + * container quantities alone — there is no customer-facing opt-in flag. */ - private async resolveConsolidation( + private async needsConsolidation( containers: CreateBookingContainerDto[], - explicit?: boolean, ): Promise { - const needs = await this.consolidationService.needsConsolidation( + return this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, })), ); - if (needs) return true; - return explicit ?? false; } /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ @@ -197,10 +198,12 @@ export class BookingsService { }> { const messages: string[] = []; - if (!booking.allowConsolidation || booking.consolidationPartnerId) { + if (booking.consolidationPartnerId) { return { booking, messages }; } + // Only partial-wagon container lines produce slots; full-wagon (and bulk) + // bookings return none and need no consolidation. const slots = await this.consolidationService.slotsFromBooking(booking); if (slots.length === 0) { return { booking, messages }; @@ -370,9 +373,9 @@ export class BookingsService { ); } - const allowConsolidation = + const needsConsolidation = dto.freightType === 'CONTAINER' - ? await this.resolveConsolidation(containers, dto.allowConsolidation) + ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ @@ -383,7 +386,6 @@ export class BookingsService { tradeDirection, isHazardous: dto.isHazardous, isGovernment, - allowConsolidation, shippingLineId: dto.shippingLineId, containers, }); @@ -423,7 +425,6 @@ export class BookingsService { startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', - allowConsolidation, priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', @@ -443,6 +444,24 @@ export class BookingsService { warnings.push(`Estimated wagons required: ${wagonCount}`); } + // Multi-route general contracts: persist the contracted routes + quantities. + // Each drawdown order later draws from one of these route lines. + if (isGeneralContract && dto.routes?.length) { + const routeRepo = this.dataSource.getRepository(ContractRouteLine); + await routeRepo.save( + dto.routes.map((r) => + routeRepo.create({ + contractBookingId: booking.id, + originYardId: r.originYardId, + destinationYardId: r.destinationYardId, + containerTypeId: + dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null, + quantity: r.quantity, + }), + ), + ); + } + if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); @@ -451,9 +470,36 @@ export class BookingsService { } } + // Reuse the booking profile's onboarding documents instead of asking the + // customer to re-upload. Snapshot them onto the booking now (by reference), + // so a later active-profile switch never changes this booking's documents. + if (companyProfileId) { + try { + const onboardingFiles = + await this.companiesService.getProfileOnboardingFiles(companyProfileId); + if (onboardingFiles.length > 0) { + await this.filesService.attachExistingFiles( + booking.id, + 'bookings', + onboardingFiles.map((f, i) => ({ + code: `onboarding_document_${i + 1}`, + name: f.name, + url: f.url, + size: f.size, + mimeType: f.mimeType, + })), + ); + } + } catch { + warnings.push( + 'Could not attach onboarding documents — they can be added from the booking page.', + ); + } + } + let full = await this.findById(booking.id); - if (allowConsolidation) { + if (needsConsolidation) { const consolidation = await this.tryAutoConsolidate(full); full = consolidation.booking; warnings.push(...consolidation.messages); @@ -512,12 +558,9 @@ export class BookingsService { dto.tradeDirection, ); - const allowConsolidation = + const needsConsolidation = freightType === 'CONTAINER' - ? await this.resolveConsolidation( - containers, - dto.allowConsolidation ?? existing.allowConsolidation, - ) + ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ @@ -527,7 +570,6 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, - allowConsolidation, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, containers, }); @@ -536,12 +578,11 @@ export class BookingsService { this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); - const pricingFieldsChanged = this.pricingRelevantFieldsChanged( + const pricingFieldsChanged = await this.pricingRelevantFieldsChanged( existing, dto, freightType, cargoTypeId, - allowConsolidation, containers, ); @@ -549,7 +590,6 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, - allowConsolidation, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -599,7 +639,7 @@ export class BookingsService { let booking = await this.findById(id); - if (allowConsolidation && !booking.consolidationPartnerId) { + if (needsConsolidation && !booking.consolidationPartnerId) { const consolidation = await this.tryAutoConsolidate(booking); booking = consolidation.booking; warnings.push(...consolidation.messages); @@ -678,7 +718,6 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, - allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -846,7 +885,6 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, - allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, }; @@ -955,10 +993,6 @@ export class BookingsService { }> { const booking = await this.findById(id); - if (!booking.allowConsolidation) { - throw new BadRequestException('Booking is not eligible for consolidation'); - } - const needs = await this.consolidationService.needsConsolidationFromBooking( booking, ); @@ -1042,14 +1076,13 @@ export class BookingsService { }; } - private pricingRelevantFieldsChanged( + private async pricingRelevantFieldsChanged( existing: Booking, dto: UpdateBookingDto, freightType: FreightType, cargoTypeId: string | null | undefined, - allowConsolidation: boolean, containers: CreateBookingContainerDto[], - ): boolean { + ): Promise { if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { return true; } @@ -1062,18 +1095,14 @@ export class BookingsService { if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { return true; } - if ( - dto.allowConsolidation !== undefined && - dto.allowConsolidation !== existing.allowConsolidation - ) { - return true; - } if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { return true; } if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { return true; } + // Container lines drive both the base price and the consolidation surcharge + // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices. if (dto.containers !== undefined) { const existingContainers = (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) @@ -1088,8 +1117,7 @@ export class BookingsService { } if ( freightType !== existing.freightType || - (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) || - allowConsolidation !== existing.allowConsolidation + (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ) { return true; } 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 b7d5ea14d..7a8468dcb 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 @@ -53,6 +53,30 @@ export class CreateBookingContainerDto { vgmPerUnitTons!: number; } +export class CreateContractRouteDto { + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Container type for CONTAINER contracts; omit for BULK', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + quantity!: number; +} + export class CreateBookingDto { /** Class-level freight shape check (not a request field). */ @Validate(BookingFreightShapeConstraint) @@ -161,6 +185,21 @@ export class CreateBookingDto { @IsUUID() destinationYardId!: string; + /** + * GENERAL_CONTRACT only: the routes this contract reserves quantity across. + * Each entry has its own origin/destination and quantity; the first entry also + * matches the booking's originYardId/destinationYardId. Omitted for one-time + * bookings, which use the single origin/destination above. + */ + @ApiPropertyOptional({ type: [CreateContractRouteDto] }) + @ValidateIf((o) => o.bookingType === 'GENERAL_CONTRACT') + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateContractRouteDto) + routes?: CreateContractRouteDto[]; + @ApiProperty({ enum: TRADE_DIRECTIONS }) @IsIn([...TRADE_DIRECTIONS]) tradeDirection!: string; @@ -233,10 +272,4 @@ export class CreateBookingDto { @ValidateNested({ each: true }) @Type(() => CreateBookingContainerDto) containers?: CreateBookingContainerDto[]; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === 'true' || value === true) - allowConsolidation?: boolean; } 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 ee5099c52..d189f5448 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 @@ -96,11 +96,6 @@ export class FilterBookingDto { @IsIn([...PAYMENT_STATUSES]) paymentStatus?: string; - @ApiPropertyOptional() - @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) - allowConsolidation?: boolean; - @ApiPropertyOptional({ description: 'true | false — filter paired consolidation' }) @IsOptional() consolidationPaired?: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 3474bec74..532d6b1a7 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -7,9 +7,22 @@ export class PriceLineItemDto { @ApiProperty() description!: string; + /** Computed line total (unitAmount × quantity). Retained for totals elsewhere. */ @ApiProperty() amount!: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + @ApiProperty() + unitAmount!: number; + + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + @ApiProperty() + unit!: string; + + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + @ApiProperty() + quantity!: number; + @ApiProperty() currency!: string; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 99855d49f..698c5f98c 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -34,3 +34,12 @@ export class CancelBookingDto { @MinLength(1) reason!: string; } + +export class RejectBookingDto { + @ApiPropertyOptional({ + description: 'Optional reason the customer rejected the price estimate', + }) + @IsOptional() + @IsString() + reason?: string; +} 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 d5f358e5f..1b5a9acae 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 @@ -294,9 +294,6 @@ export class Booking extends BaseEntity { @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; - @Column({ name: 'allow_consolidation', type: 'boolean', default: false }) - allowConsolidation!: boolean; - @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) consolidationPartnerId?: string | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e57d3b5f8..77973d357 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -914,6 +914,18 @@ export class CompaniesService { return profile.businessLicenseFiles ?? []; } + /** + * Onboarding documents stored on a company profile, fetched by profile id. + * Internal helper (no ownership check) used when a booking reuses the active + * profile's onboarding documents. Returns [] when the profile is unknown. + */ + async getProfileOnboardingFiles( + profileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(profileId); + return profile?.businessLicenseFiles ?? []; + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 97a5e9e34..31cf87dd7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -54,6 +54,38 @@ export class FilesService { ); } + /** + * Attach already-stored files (e.g. a company profile's onboarding documents) + * to a resource by reference — creates FileRecord rows pointing at the existing + * object-storage URLs, without re-uploading bytes. The snapshot is fixed at call + * time, so later changes to the source documents never alter what was attached. + */ + async attachExistingFiles( + resourceId: string, + resource: string, + files: Array<{ + code: string; + name: string; + url: string; + size: number; + mimeType?: string; + }>, + ): Promise { + return Promise.all( + files.map((f) => + this.filesRepository.create({ + resourceId, + resource, + code: f.code, + name: f.name, + url: f.url, + size: f.size, + mimeType: f.mimeType ?? "application/octet-stream", + }), + ), + ); + } + async findById(id: string): Promise { const record = await this.filesRepository.findById(id); if (!record) throw new NotFoundException(`File ${id} not found`); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 736c1fbf9..9670243c9 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -386,9 +386,7 @@ export class DemoBookingsSeeder { shippingLineId: null, cargoTotalWeightVgm: demoBooking.totalWeightTons, isHazardous: false, - paymentCurrency: "ETB", - allowConsolidation: false, - priorityScore: 0, + paymentCurrency: "ETB", priorityScore: 0, versionNumber: 1, }, { conflictPaths: { reference: true } }, @@ -459,9 +457,7 @@ export class DemoBookingsSeeder { shippingLineId: null, cargoTotalWeightVgm: demoBulk.totalWeightTons, isHazardous: false, - paymentCurrency: "USD", - allowConsolidation: false, - priorityScore: 10, + paymentCurrency: "USD", priorityScore: 10, schedulingStatus: "HOLDING", versionNumber: 1, }, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 28cebc9e7..cddc34757 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -643,9 +643,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 250, containers: [ @@ -663,9 +661,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: true, - allowConsolidation: false, - shippingLineId: null, + isHazardous: true, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 135, containers: [ @@ -683,9 +679,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: maersk.id, + isHazardous: false, shippingLineId: maersk.id, cargoTypeId: null, cargoTotalWeightVgm: 480, containers: [ @@ -703,9 +697,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: true, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 224, containers: [ @@ -723,9 +715,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railBulk.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: grain.id, cargoTotalWeightVgm: 500, containers: [], @@ -741,9 +731,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 75, containers: [ @@ -761,9 +749,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 300, containers: [ diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 660ca7e74..da5d37cec 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -126,7 +126,6 @@ export interface BookingDetail { tradeDirection: string; cargoTotalWeightVgm: number; isHazardous: boolean; - allowConsolidation: boolean; consolidationPartnerId?: string | null; consolidationPartner?: BookingNamedRef & { reference?: string } | null; priorityScore: number; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index e7dbdd54c..da39659d2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -113,6 +113,12 @@ function mapBookingToFormValues( ): BookingFormInputValues { const vals = { ...initialBookingFormValues, + operationType: + booking.tradeDirection === "IMPORT" + ? "import" + : booking.tradeDirection === "EXPORT" + ? "export" + : "intercity", contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", previousContractRef: booking.previousContractId ?? "", @@ -137,7 +143,6 @@ function mapBookingToFormValues( isHazardous: booking.isHazardous ?? false, isRefrigerated: booking.isRefrigerated ?? false, shippingLine: (booking as any).shippingLine?.id ?? "", - consolidationEnabled: booking.allowConsolidation ?? false, paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD", scheduledDate: booking.scheduledDate @@ -432,7 +437,6 @@ export default function EditBookingPage() { cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, paymentCurrency: data.paymentCurrency, - allowConsolidation: data.consolidationEnabled, freightType: data.cargoType === "container" ? ("CONTAINER" as const) 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 55fa6d0c2..2e2cc3e85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,6 +1,5 @@ import { api } from "@/services/api"; import { Freight } from "@edr/types"; -import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import type { CreateBookingPayload, GeneratePriceResponse, @@ -34,15 +33,19 @@ import useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, STEPS, + allowedOperationsForProfiles, bookingFormSchema, getRouteDirection, initialBookingFormValues, + operationToProfileType, stepFields, type BookingDocuments, type BookingFormValues, + type OperationType, } from "./new-booking-form/schema"; import { StepIndicator } from "./new-booking-form/StepIndicator"; import { + Step0OperationType, Step1ContractType, Step2ServiceType, Step4Route, @@ -54,10 +57,26 @@ import { type PriceModalMode = "submit" | "draft"; +/** Suffix for a per-unit rate, e.g. "each" for a per-container price. */ +function unitRateLabel(unit?: string): string { + switch (unit) { + case "PER_CONTAINER": + return "each"; + case "PER_TON": + return "per ton"; + case "PER_WAGON": + return "per wagon"; + case "PER_KM": + return "per km"; + default: + return ""; + } +} + export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); - const [step, setStep] = useState(1); + const [step, setStep] = useState(0); const auth = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), @@ -191,6 +210,20 @@ export default function NewBookingPage() { }, }); + // Customer rejects the priced booking → it becomes REJECTED (terminal) and the + // customer starts a fresh booking. + const rejectMutation = useMutation({ + mutationFn: async () => { + if (!priceBookingId) throw new Error("No booking to reject"); + return api.bookings.reject.call({ id: priceBookingId }); + }, + onSuccess: () => { + setPriceModalMode(null); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + navigate("/bookings"); + }, + }); + const abortMutation = useMutation({ mutationFn: async (reason: string) => { if (!priceBookingId) throw new Error("No booking to abort"); @@ -245,6 +278,34 @@ export default function NewBookingPage() { return route; }, [originYard, destinationYard]); + // Operations the customer may book, gated by the company's onboarded profiles. + const allowedOperations = useMemo(() => { + const profileTypes = (auth.company?.company?.companyProfiles ?? []).map( + (p) => p.type, + ); + return allowedOperationsForProfiles(profileTypes); + }, [auth.company]); + + // Stamp the booking to the right operational profile. Import/Export switch the + // active mode so the matching onboarding documents are attached; Intercity uses + // whatever profile is already active. + const handleOperationSelect = (op: OperationType) => { + if (op === "intercity") return; + const target = operationToProfileType(op); + if (auth.activeProfileType !== target) { + void auth.switchMode(target as never); + } + }; + + // Onboarding documents for the active profile — shown read-only in the + // Documents step and attached to the booking on submit by the backend. + const onboardingDocs = useMemo(() => { + const profiles = auth.company?.company?.companyProfiles ?? []; + const active = + profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; + return active?.licenseFiles ?? []; + }, [auth.company, auth.activeCompanyProfileId]); + const [pricingData, setPricingData] = useState( null, ); @@ -261,14 +322,8 @@ export default function NewBookingPage() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; - if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) { - form.setError("documents", { - type: "manual", - message: "Upload all four required documents.", - }); - return; - } - + // Documents (step 6) is read-only — the active profile's onboarding files are + // attached automatically, so there is nothing to validate here. goToStep(1); } @@ -348,7 +403,6 @@ export default function NewBookingPage() { // engine assigns the train, so no trainScheduleId is sent. cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, - allowConsolidation: data.consolidationEnabled, freightType: data.cargoType === "container" ? ("CONTAINER" as const) @@ -377,6 +431,37 @@ export default function NewBookingPage() { ? { shippingLineId: data.shippingLine } : {}), ...(cargoFreeText ? { cargoFreeText } : {}), + // Multi-route general contracts: route #1 is the primary origin/destination + // carrying the full contracted quantity; each extra route reserves its own. + ...(isContract + ? { + routes: [ + { + originYardId: data.originYard, + destinationYardId: data.destinationYard, + quantity: + data.cargoType === "container" + ? data.containers.reduce( + (sum, c) => sum + Number(c.qty || 0), + 0, + ) + : totalWeight, + }, + ...(data.extraRoutes ?? []) + .filter( + (r) => + r.originYard && + r.destinationYard && + Number(r.quantity) > 0, + ) + .map((r) => ({ + originYardId: r.originYard, + destinationYardId: r.destinationYard, + quantity: Number(r.quantity), + })), + ], + } + : {}), }; } @@ -394,14 +479,8 @@ export default function NewBookingPage() { }); const handleSubmitBooking = form.handleSubmit((data) => { - if (!hasAllRequiredDocuments(data.documents)) { - form.setError("documents", { - type: "manual", - message: "Upload all four required documents.", - }); - setStep(6); - return; - } + // Documents are reused from onboarding and attached by the backend, so there + // is no upload requirement to enforce here. try { const apiPayload = buildApiPayload(data); persistAndPriceMutation.mutate({ @@ -498,6 +577,13 @@ export default function NewBookingPage() { )} + {step === 0 && ( + + )} {step === 1 && ( )} @@ -522,13 +608,14 @@ export default function NewBookingPage() { {step === 5 && ( )} - {step === 6 && } + {step === 6 && } {step === 7 && ( {priceModalMode === "submit" - ? "Review the price estimate below. Confirm to submit your booking for EDR staff review." - : "Your booking has been saved as a draft. Here is the estimated price."} + ? "These are the unit rates that apply to your booking. Confirm to submit for EDR staff review, or reject to discard this booking." + : "Your booking has been saved as a draft. These are the unit rates that apply."} {pricingData.lineItems.map((item) => ( - + {item.description} + {item.quantity && item.quantity > 1 ? ( + + {" "} + × {item.quantity.toLocaleString()} + + ) : null} - {item.amount.toLocaleString()} {item.currency} + {(item.unitAmount ?? item.amount).toLocaleString()}{" "} + {item.currency} {unitRateLabel(item.unit)} ))} - - - Total - - - {pricingData.totalAmount.toLocaleString()} {pricingData.currency} - - {pricingData.warnings.length > 0 && ( {pricingData.warnings.join(", ")} @@ -645,12 +731,15 @@ export default function NewBookingPage() { {priceModalMode === "submit" ? ( <> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index da8f1837e..11291b3aa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -3,6 +3,7 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; export const STEPS = [ + { id: 0, label: "Operation Type", short: "Operation" }, { id: 1, label: "Contract Type", short: "Contract" }, { id: 2, label: "Service Type & Mile", short: "Service" }, { id: 3, label: "Route", short: "Route" }, @@ -12,6 +13,9 @@ export const STEPS = [ { id: 7, label: "Review & Submit", short: "Submit" }, ] as const; +export const OPERATION_TYPES = ["import", "export", "intercity"] as const; +export type OperationType = (typeof OPERATION_TYPES)[number]; + /** * Shipment documents collected during booking creation. The fileKeys mirror * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts. @@ -86,6 +90,10 @@ export type BookingTypeOption = (typeof BOOKING_TYPES)[number]; export const bookingFormSchema = z .object({ + // Operation the booking is for, gated by the company's onboarded profiles. + // Drives trade direction (import/export → IMPORT/EXPORT; intercity → DOMESTIC) + // and the active company profile the booking is stamped to. + operationType: z.enum(OPERATION_TYPES, "Select an operation type."), // One-time booking vs. a general contract (umbrella, drawn down by orders). bookingType: z.enum(BOOKING_TYPES).default("one_time"), contractType: z.enum(["new", "renewal"], "Select a contract type."), @@ -117,6 +125,18 @@ export const bookingFormSchema = z customsClearingEnabled: z.boolean().default(false), originYard: z.string().min(1, "Select an origin yard."), destinationYard: z.string().min(1, "Select a destination yard."), + // Additional routes for a GENERAL contract (the primary origin/destination + // above is route #1). Each adds another (origin, destination, quantity) pool. + // Ignored for one-time bookings. + extraRoutes: z + .array( + z.object({ + originYard: z.string(), + destinationYard: z.string(), + quantity: z.string(), + }), + ) + .default([]), shippingLine: z.string(), // Day-level pool: the customer selects only a DAY. The batch engine assigns // the specific train later, so no trainScheduleId is collected here. @@ -145,10 +165,9 @@ export const bookingFormSchema = z .refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"), }), ), - // Consolidation is system-managed, not a customer choice. The backend only - // consolidates partial-wagon bookings, so this is always allowed; the - // customer neither sees nor toggles it. - consolidationEnabled: z.boolean().default(true), + // Consolidation is system-managed, not a customer choice: the backend + // consolidates partial-wagon container bookings automatically, derived from + // the container quantities. The customer neither sees nor toggles it. documents: z.record(z.string(), z.any()).default({}), notes: z.string(), }) @@ -254,6 +273,7 @@ export const initialBookingFormValues: DeepPartial = { customsClearingEnabled: false, originYard: "", destinationYard: "", + extraRoutes: [], shippingLine: "", scheduledDate: "", cargoWeight: "", @@ -262,12 +282,12 @@ export const initialBookingFormValues: DeepPartial = { isHazardous: false, isRefrigerated: false, containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], - consolidationEnabled: true, documents: {}, notes: "", }; export const stepFields: Record>> = { + 0: ["operationType"], 1: ["bookingType", "contractType", "previousContractRef"], 2: [ "serviceTypeId", @@ -280,6 +300,7 @@ export const stepFields: Record>> = { 3: [ "originYard", "destinationYard", + "extraRoutes", "isHazardous", "isRefrigerated", "shippingLine", @@ -330,6 +351,44 @@ export function getRouteDirection( return "DOMESTIC"; } +/** + * Operations a company may book, derived from its onboarded profile types. + * - freight forwarder (or DJ forwarder) → import, export, intercity + * - importer → import, intercity + * - exporter → export, intercity + * - importer + exporter → import, export, intercity + * Intercity (DOMESTIC) is always available to any customer-side profile. + */ +export function allowedOperationsForProfiles( + profileTypes: string[], +): OperationType[] { + const has = (t: string) => profileTypes.includes(t); + const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder"); + const ops = new Set(); + if (isForwarder || has("importer")) ops.add("import"); + if (isForwarder || has("exporter")) ops.add("export"); + // Any importer/exporter/forwarder profile can also run domestic (intercity). + if (isForwarder || has("importer") || has("exporter")) ops.add("intercity"); + // Preserve a stable display order. + return OPERATION_TYPES.filter((o) => ops.has(o)); +} + +/** Trade direction the backend will derive for a given operation type. */ +export function operationToTradeDirection( + op: OperationType, +): Freight.ScheduleTradeDirection { + if (op === "import") return "IMPORT"; + if (op === "export") return "EXPORT"; + return "DOMESTIC"; +} + +/** The company_profile type a booking for this operation should be stamped to. */ +export function operationToProfileType(op: OperationType): string { + if (op === "import") return "importer"; + if (op === "export") return "exporter"; + return "freight_forwarder"; +} + export function calcWagons(containers: ContainerConfig[]) { const Ft20Wagons = containers .filter((c) => c.type === "20ft") diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index 07a6d197c..7cb091d68 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -1,40 +1,36 @@ -import { Box, Group, Text } from "@mantine/core"; -import { SmartFileInput } from "@edr/ui-common"; -import { CheckCircle2, FileUp } from "lucide-react"; -import { Controller, type UseFormReturn } from "react-hook-form"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import { CheckCircle2, FileText, FileUp } from "lucide-react"; -import { - BOOKING_DOCS_SETTING, - BookingFormInputValues, - type BookingDocuments, - type BookingFormValues, -} from "./schema"; import { StepCard, StepHeader } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; - -function countAttached(documents: BookingDocuments): number { - return BOOKING_DOCS_SETTING.fields.filter((f) => { - const value = documents[f.fileKey]; - return Array.isArray(value) ? value.length > 0 : Boolean(value); - }).length; +export interface OnboardingDoc { + name: string; + url: string; + size: number; + mimeType?: string; } -export function StepDocuments({ form }: { form: BookingForm }) { - const documents = (form.watch("documents") ?? {}) as BookingDocuments; - const attached = countAttached(documents); - const total = BOOKING_DOCS_SETTING.fields.length; +function formatSize(bytes: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Read-only documents step: lists the documents the company uploaded during + * onboarding for the active operational profile. These are attached to the + * booking automatically at submission — the customer is never asked to re-upload. + */ +export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) { + const total = documents.length; return ( } - title="Shipment Documents" - description="Attach your shipment documents now, or skip and upload them later from the booking page." + title="Documents" + description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed." /> 0 ? "#ECF6F1" : "#FBECEC", + color: total > 0 ? "#0A6F4D" : "#B42318", }} > - {attached === total ? ( - - ) : ( - `${attached}/${total}` - )} + {total > 0 ? : } - {attached === 0 - ? "All documents are optional here — you can upload them later from the booking page." - : `${attached} of ${total} attached. You can finish the rest later from the booking page.`} + {total > 0 + ? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.` + : "No onboarding documents found on your active profile. You can add documents later from the booking page."} - ( - field.onChange(value)} - /> - )} - /> + {total > 0 && ( + + {documents.map((doc, i) => ( + + + + + + + {doc.name} + + {doc.size ? ( + + {formatSize(doc.size)} + + ) : null} + + + + + Uploaded + + + + ))} + + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx new file mode 100644 index 000000000..6bb1643ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx @@ -0,0 +1,119 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { ArrowDownToLine, ArrowUpFromLine, Truck } from "lucide-react"; +import { Text } from "@mantine/core"; +import { + BookingFormInputValues, + type BookingFormValues, + type OperationType, +} from "./schema"; +import { + AlertBox, + OptionCard, + OptionFieldError, + StepCard, + StepHeader, +} from "./shared"; + +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; + +const OPTIONS: Array<{ + value: OperationType; + title: string; + description: string; + icon: React.ReactNode; + iconBg: string; + iconColor: string; +}> = [ + { + value: "import", + title: "Import", + description: "Cargo arriving into Ethiopia via Djibouti.", + icon: , + iconBg: "#ECF6F1", + iconColor: "#0A6F4D", + }, + { + value: "export", + title: "Export", + description: "Cargo leaving Ethiopia bound for Djibouti.", + icon: , + iconBg: "#EAF1FB", + iconColor: "#2E5B96", + }, + { + value: "intercity", + title: "Intercity", + description: "Domestic movement between Ethiopian yards.", + icon: , + iconBg: "#F1ECFB", + iconColor: "#6A40B8", + }, +]; + +export function Step0OperationType({ + form, + allowedOperations, + onSelect, +}: { + form: BookingForm; + allowedOperations: OperationType[]; + onSelect?: (op: OperationType) => void; +}) { + return ( + + } + title="Operation Type" + description="Choose what this booking is for. The options available reflect the operations your company is registered for." + /> + + {allowedOperations.length === 0 && ( + + Your company has no operational profile yet. Complete onboarding to + register as an importer, exporter, or freight forwarder. + + )} + + ( +
+
+ {OPTIONS.map((opt) => { + const enabled = allowedOperations.includes(opt.value); + return ( + { + if (!enabled) return; + field.onChange(opt.value); + onSelect?.(opt.value); + }} + /> + ); + })} +
+ +
+ )} + /> + + + Import and Export are stamped to your matching company profile; their + documents are attached automatically at submission. + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 40fc1f061..410c2b48f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,8 +1,29 @@ import type { Freight } from "@edr/types"; -import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core"; -import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react"; +import { + Box, + Button, + Divider, + Group, + NumberInput, + Skeleton, + Stack, + Switch, + Text, +} from "@mantine/core"; +import { + Flame, + MapPin, + Plus, + Route as RouteIcon, + Snowflake, + Trash2, +} from "lucide-react"; import { useEffect, useMemo } from "react"; -import { Controller, type UseFormReturn } from "react-hook-form"; +import { + Controller, + useFieldArray, + type UseFormReturn, +} from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues, @@ -27,6 +48,13 @@ export function Step4Route({ }) { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const isGeneralContract = form.watch("bookingType") === "general_contract"; + + const { + fields: extraRoutes, + append: appendRoute, + remove: removeRoute, + } = useFieldArray({ control: form.control, name: "extraRoutes" }); const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; @@ -144,6 +172,105 @@ export function Step4Route({ )} + {isGeneralContract && !isLoading && ( + + + Additional contract routes + + + + A general contract can reserve quantity across several routes. The + route above is your primary route; add more routes and the quantity + reserved for each. + + + {extraRoutes.map((rf, i) => ( + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + field.onChange(String(v ?? ""))} + radius="md" + /> + )} + /> + + + + ))} + + + )} + {direction && direction !== "DOMESTIC" && ( void; direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; + onboardingDocs?: Array<{ name: string; size?: number }>; onSaveDraft?: () => void; onSubmit?: () => void; saveDraftPending?: boolean; @@ -171,12 +170,8 @@ export function Step8Review({ ) : Number(values.cargoWeight || 0); - const documents = (values.documents ?? {}) as BookingDocuments; - const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => { - const value = documents[f.fileKey]; - return Array.isArray(value) ? value.length > 0 : Boolean(value); - }).length; - const allDocsReady = hasAllRequiredDocuments(documents); + // Documents are reused from onboarding (read-only) and attached on submit. + const onboardingDocsCount = onboardingDocs.length; const cargoValue = (() => { if (values.cargoType === "container") return "Container freight"; @@ -374,35 +369,35 @@ export function Step8Review({ onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)} > - {BOOKING_DOCS_SETTING.fields.map((field) => { - const file = documents[field.fileKey]; - const attached = Array.isArray(file) - ? file.length > 0 - : Boolean(file); - const fileName = attached - ? Array.isArray(file) - ? file[0]?.name - : (file as File)?.name - : null; - return ( - + {onboardingDocsCount > 0 ? ( + onboardingDocs.map((doc, i) => ( + - {attached ? ( - - ) : ( - - )} - {field.fileLabel} + + + {doc.name} + - - {fileName ?? "Missing"} + + Uploaded - ); - })} + )) + ) : ( + + + + No onboarding documents found on your active profile. + + + )} - {docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached + Documents from your onboarding will be attached to this booking. @@ -451,17 +446,16 @@ export function Step8Review({ label="Cargo details complete" /> 0} + label="Onboarding documents attached" /> - {allDocsReady - ? "Ready to submit. You'll review the price estimate before final submission." - : "Upload all four documents to enable submission."} + Ready to submit. You'll review the unit rates before final + submission. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx index 2532da237..7ed47c1dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx @@ -1,3 +1,4 @@ +export { Step0OperationType } from "./step0-operation-type"; export { Step1ContractType } from "./step1-contract-type"; export { Step2ServiceType } from "./step2-service-type"; export { Step4Route } from "./step4-route"; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx index 7ad77f6eb..730b31b98 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -41,15 +41,33 @@ export function PlaceOrderDialog({ const [scheduledDate, setScheduledDate] = useState(null); const [quantities, setQuantities] = useState>({}); + const [routeLineId, setRouteLineId] = useState(null); + + // Multi-route contracts expose route lines; single-route contracts return []. + const { data: routeLines = [] } = useQuery({ + ...api.bookingOrders.routes.queryOptions({ + input: { contractBookingId: contract.id }, + }), + enabled: opened, + }); + const isMultiRoute = routeLines.length > 0; + const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId); + + // The route the order ships on drives both the available-days query and the + // remaining-quantity check: the chosen route line for multi-route contracts, + // else the contract's own origin/destination. + const originYardId = isMultiRoute + ? selectedRoute?.originYardId + : contract.originYard?.id; + const destinationYardId = isMultiRoute + ? selectedRoute?.destinationYardId + : contract.destinationYard?.id; const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.bookings.getAvailableDays.queryOptions({ - input: { - originYardId: contract.originYard?.id, - destinationYardId: contract.destinationYard?.id, - }, + input: { originYardId, destinationYardId }, }), - enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id, + enabled: opened && !!originYardId && !!destinationYardId, }); const dayOptions = useMemo( @@ -82,6 +100,11 @@ export function PlaceOrderDialog({ contractBookingId: contract.id, }), }); + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.routes.queryKey({ + contractBookingId: contract.id, + }), + }); queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: contract.id }), }); @@ -93,6 +116,7 @@ export function PlaceOrderDialog({ function reset() { setScheduledDate(null); setQuantities({}); + setRouteLineId(null); } function handleClose() { @@ -103,6 +127,28 @@ export function PlaceOrderDialog({ function handleSubmit() { if (!scheduledDate) return; + + if (isMultiRoute) { + if (!selectedRoute) return; + const raw = quantities["__route__"]; + const qty = typeof raw === "number" ? raw : 0; + if (qty <= 0) return; + createMutation.mutate({ + contractBookingId: contract.id, + routeLineId: selectedRoute.routeLineId, + scheduledDate: new Date(scheduledDate).toISOString(), + lines: [ + { + containerTypeId: isContainer + ? (selectedRoute.containerTypeId ?? null) + : null, + quantity: qty, + }, + ], + }); + return; + } + const lines: Freight.CreateBookingOrderLineDto[] = pool .map((line) => { const raw = quantities[lineKey(line)]; @@ -124,11 +170,27 @@ export function PlaceOrderDialog({ } 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; + const routeQtyRaw = quantities["__route__"]; + const hasQuantity = isMultiRoute + ? typeof routeQtyRaw === "number" && routeQtyRaw > 0 + : pool.some((l) => { + const raw = quantities[lineKey(l)]; + return typeof raw === "number" && raw > 0; + }); + const canSubmit = + !!scheduledDate && + hasQuantity && + (!isMultiRoute || !!selectedRoute) && + !createMutation.isPending; + + const routeOptions = routeLines.map((r) => ({ + value: r.routeLineId, + label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity( + r.remainingQuantity, + null, + isContainer, + )} remaining`, + })); return ( - Draw down from contract {contract.reference}. Route, - cargo and service are inherited — just pick a shipment date and - quantity. + Draw down from contract {contract.reference}. Cargo + and service are inherited — pick {isMultiRoute ? "a route, " : ""}a + shipment date and quantity. + {isMultiRoute && ( + } nothingFoundMessage="No departures on this route" @@ -168,6 +247,52 @@ export function PlaceOrderDialog({ styles={{ input: { height: 44 } }} /> + {isMultiRoute ? ( + + + Quantity + + {!selectedRoute ? ( + + Select a route to draw down from. + + ) : selectedRoute.remainingQuantity <= 0 ? ( + }> + This route is fully drawn down — no quantity remains. + + ) : ( + +
+ + {selectedRoute.containerTypeName ?? + (isContainer ? "Containers" : "Tons")} + + + {formatQuantity( + selectedRoute.remainingQuantity, + null, + isContainer, + )}{" "} + remaining + +
+ + setQuantities({ __route__: v === "" ? "" : Number(v) }) + } + min={0} + max={selectedRoute.remainingQuantity} + step={isContainer ? 1 : 0.5} + clampBehavior="strict" + radius="md" + w={130} + placeholder="0" + /> +
+ )} +
+ ) : ( Quantity @@ -219,6 +344,7 @@ export function PlaceOrderDialog({ ); })} + )} {createMutation.isError && ( }> diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index bf170cfac..6048be28b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -225,6 +225,12 @@ export const api = { ({ id, reason }) => bookingsService.cancel(id, reason), ), + reject: endpoint<{ id: string; reason?: string }, Freight.IBooking>( + "bookings", + "reject", + ({ id, reason }) => bookingsService.reject(id, reason), + ), + generatePrice: endpoint<{ id: string }, GeneratePriceResponse>( "bookings", "generatePrice", @@ -292,6 +298,13 @@ export const api = { bookingOrdersService.pool(contractBookingId), ), + routes: endpoint< + { contractBookingId: string }, + Freight.ContractRouteLine[] + >("booking-orders", "routes", ({ contractBookingId }) => + bookingOrdersService.routes(contractBookingId), + ), + create: endpoint( "booking-orders", "create", diff --git a/apps/edr-freight-web/portal/src/services/booking-orders.service.ts b/apps/edr-freight-web/portal/src/services/booking-orders.service.ts index fc1490531..a920414a7 100644 --- a/apps/edr-freight-web/portal/src/services/booking-orders.service.ts +++ b/apps/edr-freight-web/portal/src/services/booking-orders.service.ts @@ -24,6 +24,16 @@ export const bookingOrdersService = { return data.data ?? data; }, + /** Per-route contracted / ordered / remaining quantities (multi-route contracts). */ + routes: async ( + contractBookingId: string, + ): Promise => { + const { data } = await client.get( + `/api/booking-orders/contract/${contractBookingId}/routes`, + ); + return data.data ?? data; + }, + /** Place a drawdown order against a contract. */ create: async ( payload: CreateBookingOrderPayload, diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 0631a49aa..e74f8281c 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -35,7 +35,14 @@ export interface ContractView { export interface PriceLineItem { code: string; description: string; + /** Computed line total (unitAmount × quantity). */ amount: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + unitAmount?: number; + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + unit?: string; + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + quantity?: number; currency: string; } @@ -136,6 +143,11 @@ export const bookingsService = { return data.data; }, + reject: async (id: string, reason?: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/reject`, { reason }); + return data.data; + }, + generatePrice: async (id: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/generate-price`); return data.data; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 4b1ef3ebd..b86e64dd8 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -387,7 +387,6 @@ export interface IBooking extends BaseEntity { tradeDirection: "IMPORT" | "EXPORT"; paymentCurrency: string; - allowConsolidation: boolean; consolidationPartnerId?: string | null; startDate?: string | null; @@ -430,7 +429,14 @@ export interface IBooking extends BaseEntity { export interface PricingBreakdownLineItem { code: string; + /** Computed line total (unitAmount × quantity). */ amount: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + unitAmount?: number; + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + unit?: string; + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + quantity?: number; currency: string; description: string; } @@ -577,6 +583,14 @@ export interface CreateBookingContainerDto { vgmPerUnitTons: number; } +/** A contracted route+quantity line for a GENERAL contract. */ +export interface CreateContractRouteDto { + originYardId: string; + destinationYardId: string; + containerTypeId?: string | undefined; + quantity: number; +} + export interface CreateBookingDto { freightShapeValidation?: boolean | undefined; reference?: string | undefined; @@ -610,7 +624,8 @@ export interface CreateBookingDto { endDate?: string | undefined; financialTerms?: string | undefined; containers?: CreateBookingContainerDto[]; - allowConsolidation?: boolean; + /** GENERAL_CONTRACT only: routes the contract reserves quantity across. */ + routes?: CreateContractRouteDto[]; } // ── General Contracts & Booking Orders ────────────────────────────────────────── @@ -651,9 +666,25 @@ export interface CreateBookingOrderLineDto { quantity: number; } +/** Per-route contracted / ordered / remaining pool line (multi-route contracts). */ +export interface ContractRouteLine { + routeLineId: string; + originYardId: string; + originYardName?: string | null; + destinationYardId: string; + destinationYardName?: string | null; + containerTypeId?: string | null; + containerTypeName?: string | null; + contractedQuantity: number; + orderedQuantity: number; + remainingQuantity: number; +} + export interface CreateBookingOrderDto { /** The general contract (booking) this order draws down from. */ contractBookingId: string; + /** For multi-route contracts: the route line being drawn from. */ + routeLineId?: string; /** The shipment day the customer wants for this order. */ scheduledDate: string; lines: CreateBookingOrderLineDto[];