mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +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:
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user