feat: Implement general contract booking orders functionality

- Add DTOs for creating booking orders and viewing contract quantities.
- Create entities for booking orders and booking order lines.
- Implement service for managing general contract operations, including activation after payment and retrieving quantity lines.
- Develop UI components for contract detail and list pages, including order placement dialog.
- Integrate API service for booking orders, enabling listing and creating orders against contracts.
- Enhance contract status display and quantity pool visualization in the UI.
This commit is contained in:
Marshal
2026-06-20 19:31:51 +00:00
parent cc62482d4e
commit b6d5047d27
43 changed files with 2256 additions and 37 deletions

View File

@@ -59,6 +59,7 @@ export function buildCargoTypeTree(
name: child.cargoTypeName,
code: child.code,
show_free_text_box: child.showFreeTextBox,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);

View File

@@ -30,6 +30,7 @@ export interface BookingListFilterOptions {
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -585,6 +586,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
freightType: options.freightType,
});
}
if (options.bookingType) {
qb.andWhere('booking.booking_type = :bookingType', {
bookingType: options.bookingType,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -258,6 +258,7 @@ export class BookingsService {
// }
const isGovernment = dto.isGovernment === true;
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
@@ -292,11 +293,12 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else {
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later.
const day = eatDay(new Date(dto.scheduledDate));
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
@@ -395,7 +397,8 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
scheduledDate: new Date(dto.scheduledDate),
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
@@ -647,6 +650,7 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
@@ -815,6 +819,7 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
export class BookingReferenceYardDto {
@ApiProperty({ format: 'uuid' })
@@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto {
@ApiProperty()
show_free_text_box!: boolean;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
unit_of_measure?: CargoUnitOfMeasure | null;
}
export class BookingReferenceCargoTypeGroupDto {

View File

@@ -17,7 +17,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
@@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
BOOKING_TYPES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
@@ -105,10 +106,24 @@ export class CreateBookingDto {
@IsUUID()
trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@ApiPropertyOptional({
enum: BOOKING_TYPES,
default: 'ONE_TIME',
description:
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
})
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
/**
* The day the customer wants to ship (the pool day key). Required for one-time
* bookings; omitted for general contracts, which pick the date per order.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
@IsDateString()
scheduledDate!: string;
scheduledDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])

View File

@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
@@ -56,6 +57,11 @@ export class FilterBookingDto {
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' })
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -41,10 +41,15 @@ export const BOOKING_STATUSES = [
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const;
export type BookingTypeValue = (typeof BOOKING_TYPES)[number];
export const PAYMENT_STATUSES = [
'PENDING',
'PNR_GENERATED',
@@ -125,8 +130,28 @@ export class Booking extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
/**
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
* umbrella contract that is signed/paid once and then drawn down by many
* orders (each order spawns its own ONE_TIME child booking).
*/
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
bookingType!: string;
/**
* Nullable: general contracts have no shipment date at creation — the date is
* chosen per drawdown order. One-time bookings always set this (the pool day key).
*/
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
/**
* General contracts only: when the ordering window closes, computed from the
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
* bookings and for contracts that are not yet active.
*/
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;