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

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