feat: enhance booking flow with multi-route support and onboarding document integration

- Introduced multi-route functionality in the booking process, allowing users to add additional routes for general contracts.
- Updated the StepDocuments component to display onboarding documents automatically attached to bookings.
- Refactored Step4Route to manage extra routes and quantities dynamically.
- Improved Step8Review to reflect the new onboarding document handling and updated submission readiness checks.
- Added new API endpoints and services for managing contract route lines and rejecting bookings.
- Removed the allowConsolidation field from the booking model as it is now managed by the system.
- Created migrations for the new contract route lines table and updated related entities.
This commit is contained in:
Marshal
2026-06-23 20:40:48 +00:00
parent eb85f8d32d
commit 080a08229d
38 changed files with 1457 additions and 286 deletions

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS allow_consolidation;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS allow_consolidation BOOLEAN NOT NULL DEFAULT false;`,
);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_orders DROP COLUMN IF EXISTS route_line_id;`,
);
await queryRunner.dropTable('freight.contract_route_lines', true);
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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<Booking> {
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);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<ContractRouteLineView[]> {
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<Map<string, number>> {
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<string, number>();
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<boolean> {
// 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);
}

View File

@@ -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<string, string> = {
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<BookingEvaluationInput> {
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<string> {
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,

View File

@@ -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<Booking> {
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<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;

View File

@@ -44,6 +44,7 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
RejectStepDto,
RequestChangesDto,
StaffRejectDto,
@@ -313,6 +314,20 @@ export class BookingsController {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/reject')
@ApiOperation({
summary: 'Customer reject price estimate',
description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
})
async reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })

View File

@@ -37,7 +37,6 @@ export interface BookingListFilterOptions {
excludePaymentStatus?: string;
createdFrom?: string;
createdTo?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@@ -179,7 +178,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.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<Booking> {
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') {

View File

@@ -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<BookingEvaluationInput> {
@@ -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<boolean> {
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<boolean> {
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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<BusinessLicenseFile[]> {
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 →

View File

@@ -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<FileRecord[]> {
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<FileRecord> {
const record = await this.filesRepository.findById(id);
if (!record) throw new NotFoundException(`File ${id} not found`);

View File

@@ -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,
},

View File

@@ -643,9 +643,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
isHazardous: false, shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 300,
containers: [

View File

@@ -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;

View File

@@ -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)

View File

@@ -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<OperationType[]>(() => {
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<GeneratePriceResponse | null>(
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() {
</Alert>
)}
{step === 0 && (
<Step0OperationType
form={form}
allowedOperations={allowedOperations}
onSelect={handleOperationSelect}
/>
)}
{step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
@@ -522,13 +608,14 @@ export default function NewBookingPage() {
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments form={form} />}
{step === 6 && <StepDocuments documents={onboardingDocs} />}
{step === 7 && (
<Step8Review
form={form}
setStep={setStep}
direction={direction!}
referenceData={referenceData}
onboardingDocs={onboardingDocs}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmitBooking}
saveDraftPending={
@@ -613,29 +700,28 @@ export default function NewBookingPage() {
<Stack gap="md">
<Text size="sm" c="dimmed">
{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."}
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Group key={item.code} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{item.description}
{item.quantity && item.quantity > 1 ? (
<Text span size="xs" c="dimmed">
{" "}
× {item.quantity.toLocaleString()}
</Text>
) : null}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
{(item.unitAmount ?? item.amount).toLocaleString()}{" "}
{item.currency} {unitRateLabel(item.unit)}
</Text>
</Group>
))}
</Stack>
<Group justify="space-between" pt="xs">
<Text fw={800} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
{pricingData.warnings.join(", ")}
@@ -645,12 +731,15 @@ export default function NewBookingPage() {
{priceModalMode === "submit" ? (
<>
<Button
variant="default"
variant="outline"
color="red"
radius="md"
onClick={closePriceModal}
leftSection={<XCircle size={16} />}
onClick={() => rejectMutation.mutate()}
loading={rejectMutation.isPending}
disabled={confirmMutation.isPending}
>
Cancel
Reject
</Button>
<Button
color="edr-green"
@@ -658,6 +747,7 @@ export default function NewBookingPage() {
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
disabled={rejectMutation.isPending}
>
Confirm & submit
</Button>

View File

@@ -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<BookingFormValues> = {
customsClearingEnabled: false,
originYard: "",
destinationYard: "",
extraRoutes: [],
shippingLine: "",
scheduledDate: "",
cargoWeight: "",
@@ -262,12 +282,12 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: true,
documents: {},
notes: "",
};
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
0: ["operationType"],
1: ["bookingType", "contractType", "previousContractRef"],
2: [
"serviceTypeId",
@@ -280,6 +300,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
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<OperationType>();
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")

View File

@@ -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 (
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
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."
/>
<Group
@@ -57,36 +53,76 @@ export function StepDocuments({ form }: { form: BookingForm }) {
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
backgroundColor: attached === total ? "#ECF6F1" : "#EAF1FB",
color: attached === total ? "#0A6F4D" : "#2E5B96",
backgroundColor: total > 0 ? "#ECF6F1" : "#FBECEC",
color: total > 0 ? "#0A6F4D" : "#B42318",
}}
>
{attached === total ? (
<CheckCircle2 size={16} />
) : (
`${attached}/${total}`
)}
{total > 0 ? <CheckCircle2 size={16} /> : <FileUp size={16} />}
</Box>
<Text size="sm" c="dimmed">
{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."}
</Text>
</Group>
<Controller
name="documents"
control={form.control}
render={({ field }) => (
<SmartFileInput
file={BOOKING_DOCS_SETTING}
value={(field.value ?? {}) as BookingDocuments}
onChange={(value) => field.onChange(value)}
/>
)}
/>
{total > 0 && (
<Stack gap={10} mt={4}>
{documents.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{doc.name}
</Text>
{doc.size ? (
<Text size="xs" c="dimmed">
{formatSize(doc.size)}
</Text>
) : null}
</Box>
<Box
style={{
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 6,
color: "#0A6F4D",
}}
>
<CheckCircle2 size={15} />
<Text size="xs" fw={600} c="#0A6F4D">
Uploaded
</Text>
</Box>
</Group>
))}
</Stack>
)}
</StepCard>
);
}

View File

@@ -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: <ArrowDownToLine className="h-5 w-5" />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
},
{
value: "export",
title: "Export",
description: "Cargo leaving Ethiopia bound for Djibouti.",
icon: <ArrowUpFromLine className="h-5 w-5" />,
iconBg: "#EAF1FB",
iconColor: "#2E5B96",
},
{
value: "intercity",
title: "Intercity",
description: "Domestic movement between Ethiopian yards.",
icon: <Truck className="h-5 w-5" />,
iconBg: "#F1ECFB",
iconColor: "#6A40B8",
},
];
export function Step0OperationType({
form,
allowedOperations,
onSelect,
}: {
form: BookingForm;
allowedOperations: OperationType[];
onSelect?: (op: OperationType) => void;
}) {
return (
<StepCard>
<StepHeader
icon={<Truck size={22} />}
title="Operation Type"
description="Choose what this booking is for. The options available reflect the operations your company is registered for."
/>
{allowedOperations.length === 0 && (
<AlertBox tone="error">
Your company has no operational profile yet. Complete onboarding to
register as an importer, exporter, or freight forwarder.
</AlertBox>
)}
<Controller
name="operationType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-3">
{OPTIONS.map((opt) => {
const enabled = allowedOperations.includes(opt.value);
return (
<OptionCard
key={opt.value}
selected={field.value === opt.value}
disabled={!enabled}
icon={opt.icon}
iconBg={opt.iconBg}
iconColor={opt.iconColor}
title={opt.title}
description={opt.description}
onClick={() => {
if (!enabled) return;
field.onChange(opt.value);
onSelect?.(opt.value);
}}
/>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
<Text fz={12} c="edr-muted" mt={14}>
Import and Export are stamped to your matching company profile; their
documents are attached automatically at submission.
</Text>
</StepCard>
);
}

View File

@@ -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({
</div>
)}
{isGeneralContract && !isLoading && (
<Box mt={18}>
<Group justify="space-between" align="center" mb={8}>
<StepLabel>Additional contract routes</StepLabel>
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Plus size={14} />}
disabled={stationSelectDisabled}
onClick={() =>
appendRoute({
originYard: "",
destinationYard: "",
quantity: "",
})
}
>
Add route
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
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.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ width: 120 }}>
<Controller
name={`extraRoutes.${i}.quantity`}
control={form.control}
render={({ field }) => (
<NumberInput
label="Quantity"
placeholder="0"
min={0}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
))}
</Stack>
</Box>
)}
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"

View File

@@ -24,10 +24,7 @@ import {
Truck,
} from "lucide-react";
import type { Freight } from "@/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
@@ -133,6 +130,7 @@ export function Step8Review({
setStep,
direction,
referenceData,
onboardingDocs = [],
onSaveDraft,
onSubmit,
saveDraftPending = false,
@@ -142,6 +140,7 @@ export function Step8Review({
setStep: (step: number) => 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)}
>
<Stack gap="xs">
{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 (
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
{onboardingDocsCount > 0 ? (
onboardingDocs.map((doc, i) => (
<Group
key={`${doc.name}-${i}`}
justify="space-between"
wrap="nowrap"
>
<Group gap="xs" wrap="nowrap">
{attached ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : (
<Circle size={16} className="text-red-400 shrink-0" />
)}
<Text size="sm">{field.fileLabel}</Text>
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<Text size="sm" className="truncate max-w-[60%]">
{doc.name}
</Text>
</Group>
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
{fileName ?? "Missing"}
<Text size="xs" c="dimmed">
Uploaded
</Text>
</Group>
);
})}
))
) : (
<Group gap="xs" wrap="nowrap">
<Circle size={16} className="text-gray-300 shrink-0" />
<Text size="sm" c="dimmed">
No onboarding documents found on your active profile.
</Text>
</Group>
)}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
Documents from your onboarding will be attached to this booking.
</Text>
</OverviewSection>
@@ -451,17 +446,16 @@ export function Step8Review({
label="Cargo details complete"
/>
<ReadinessItem
done={allDocsReady}
label="All 4 documents attached"
done={onboardingDocsCount > 0}
label="Onboarding documents attached"
/>
</Stack>
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{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.
</Text>
<Stack gap="sm">
<Button
@@ -473,7 +467,7 @@ export function Step8Review({
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={!allDocsReady || submitPending}
disabled={submitPending}
>
Submit
</Button>

View File

@@ -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";

View File

@@ -41,15 +41,33 @@ export function PlaceOrderDialog({
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [quantities, setQuantities] = useState<Record<string, number | "">>({});
const [routeLineId, setRouteLineId] = useState<string | null>(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 (
<Modal
@@ -148,18 +210,35 @@ export function PlaceOrderDialog({
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Draw down from contract <strong>{contract.reference}</strong>. Route,
cargo and service are inherited just pick a shipment date and
quantity.
Draw down from contract <strong>{contract.reference}</strong>. Cargo
and service are inherited pick {isMultiRoute ? "a route, " : ""}a
shipment date and quantity.
</Text>
{isMultiRoute && (
<Select
label="Route"
placeholder="Select a contracted route"
data={routeOptions}
value={routeLineId}
onChange={(v) => {
setRouteLineId(v);
setScheduledDate(null);
setQuantities({});
}}
radius="md"
comboboxProps={{ withinPortal: true }}
styles={{ input: { height: 44 } }}
/>
)}
<Select
label="Shipment date"
placeholder={daysLoading ? "Loading available days…" : "Select a day"}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
disabled={daysLoading}
disabled={daysLoading || (isMultiRoute && !selectedRoute)}
radius="md"
leftSection={<CalendarDays size={16} />}
nothingFoundMessage="No departures on this route"
@@ -168,6 +247,52 @@ export function PlaceOrderDialog({
styles={{ input: { height: 44 } }}
/>
{isMultiRoute ? (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{!selectedRoute ? (
<Text fz={13} c="dimmed">
Select a route to draw down from.
</Text>
) : selectedRoute.remainingQuantity <= 0 ? (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This route is fully drawn down no quantity remains.
</Alert>
) : (
<Group justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{selectedRoute.containerTypeName ??
(isContainer ? "Containers" : "Tons")}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
selectedRoute.remainingQuantity,
null,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities["__route__"] ?? ""}
onChange={(v) =>
setQuantities({ __route__: v === "" ? "" : Number(v) })
}
min={0}
max={selectedRoute.remainingQuantity}
step={isContainer ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
)}
</Stack>
) : (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
@@ -219,6 +344,7 @@ export function PlaceOrderDialog({
);
})}
</Stack>
)}
{createMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>

View File

@@ -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<CreateBookingOrderPayload, Freight.IBookingOrder>(
"booking-orders",
"create",

View File

@@ -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<Freight.ContractRouteLine[]> => {
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,

View File

@@ -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<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
},
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
return data.data;

View File

@@ -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[];