mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user