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

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