mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
add booking request functionality for GENERAL customs contracts
- Create migration for booking_requests table with necessary fields and indexes. - Implement BookingRequestRepository for database operations related to booking requests. - Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests. - Create DTOs for creating booking requests and reviewing them. - Define BookingRequest entity to map to the booking_requests table. - Add UI components for managing shipment requests, including detail and list pages. - Implement OperationDatePicker component for selecting available shipment days.
This commit is contained in:
@@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service';
|
||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
||||
*/
|
||||
describe('BookingTransitionService — operation review', () => {
|
||||
function makeService(serviceTypeCode: string) {
|
||||
@@ -78,18 +77,4 @@ describe('BookingTransitionService — operation review', () => {
|
||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
|
||||
amount: 1500,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({
|
||||
adjustedTotalAmount: 1500,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -477,30 +477,6 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
|
||||
* that supersedes the computed total for the customer, who sees an
|
||||
* "Adjusted by EDR" badge. Passing null clears the adjustment.
|
||||
*/
|
||||
async adjustPrice(
|
||||
bookingId: string,
|
||||
amount: number | null,
|
||||
staffId: string,
|
||||
reason?: string,
|
||||
): Promise<Booking> {
|
||||
await this.bookingsService.findById(bookingId);
|
||||
if (amount != null && amount < 0) {
|
||||
throw new BadRequestException('Adjusted amount cannot be negative');
|
||||
}
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
adjustedTotalAmount: amount,
|
||||
adjustedByStaffId: amount == null ? null : staffId,
|
||||
adjustedAt: amount == null ? null : new Date(),
|
||||
adjustmentReason: amount == null ? null : (reason ?? null),
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
||||
|
||||
/**
|
||||
@@ -861,17 +837,17 @@ export class BookingTransitionService {
|
||||
|
||||
/**
|
||||
* Operations team reviews a pending operation request (capacity, documents,
|
||||
* route). Three outcomes:
|
||||
* route). Two outcomes:
|
||||
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
||||
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
||||
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
|
||||
* before the booking can enter the pool.
|
||||
*
|
||||
* The booking price is computed from the contract and is never adjusted here.
|
||||
*/
|
||||
async reviewOperationRequest(
|
||||
bookingId: string,
|
||||
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
|
||||
decision: 'ACCEPT' | 'REQUEST_CHANGES',
|
||||
actorId: string,
|
||||
options: { note?: string; amount?: number } = {},
|
||||
options: { note?: string } = {},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
||||
@@ -894,48 +870,10 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
if (decision === 'ADJUST_PRICE') {
|
||||
if (options.amount == null || options.amount < 0) {
|
||||
throw new BadRequestException(
|
||||
'A non-negative adjusted amount is required to adjust the price',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
adjustedTotalAmount: options.amount,
|
||||
adjustedByStaffId: actorId,
|
||||
adjustedAt: new Date(),
|
||||
adjustmentReason: options.note ?? null,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
// ACCEPT — enter the batch holding pool.
|
||||
return this.acceptOperationRequest(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
|
||||
* pushes the booking into the pool; rejecting returns it to the customer as an
|
||||
* operation change request so they can resubmit or cancel.
|
||||
*/
|
||||
async confirmOperationPrice(
|
||||
bookingId: string,
|
||||
accept: boolean,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
|
||||
|
||||
if (!accept) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
return this.acceptOperationRequest(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reviewed operation request forward after Marketing accepts.
|
||||
*
|
||||
|
||||
@@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
AdjustPriceDto,
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectBookingDto,
|
||||
@@ -52,7 +51,6 @@ import {
|
||||
ReviewDocumentDto,
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
ConfirmOperationPriceDto,
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
@@ -404,24 +402,7 @@ export class BookingsController {
|
||||
id,
|
||||
dto.decision,
|
||||
resolveAuthUserId(user),
|
||||
{ note: dto.note, amount: dto.amount },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/confirm-price')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer confirms or rejects an operations price adjustment ' +
|
||||
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
|
||||
})
|
||||
async confirmOperationPrice(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ConfirmOperationPriceDto,
|
||||
) {
|
||||
const booking = await this.transitionService.confirmOperationPrice(
|
||||
id,
|
||||
dto.accept,
|
||||
{ note: dto.note },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
@@ -521,25 +502,6 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/adjust-price')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({
|
||||
summary: 'Staff adjust booking total price (override; null clears it)',
|
||||
})
|
||||
async adjustPrice(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdjustPriceDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.adjustPrice(
|
||||
id,
|
||||
dto.amount ?? null,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
|
||||
@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||||
// Customs applies when EITHER the service type bundles it OR the booking was
|
||||
// created with customsClearingEnabled (copied from the contract). Contract
|
||||
// bookings carry customsClearingEnabled even when the serviceType relation
|
||||
// isn't loaded / has includesCustoms=false — without this the per-booking
|
||||
// clearance grid would resolve empty.
|
||||
const includesCustoms =
|
||||
Boolean(booking.serviceType?.includesCustoms) ||
|
||||
Boolean(booking.customsClearingEnabled);
|
||||
return {
|
||||
inputCode: clearanceSettingCode(
|
||||
booking.tradeDirection,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
@@ -70,22 +68,6 @@ export class RejectBookingDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class AdjustPriceDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'New total price. Omit or send null to clear a previous adjustment.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ReviewDocumentDto {
|
||||
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||
@IsString()
|
||||
@@ -117,12 +99,12 @@ export class OperationReviewDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
|
||||
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
|
||||
'customer must re-confirm before it proceeds.',
|
||||
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
|
||||
'returns it to the customer with a note. The booking price is computed ' +
|
||||
'from the contract and cannot be adjusted by staff.',
|
||||
enum: ['ACCEPT', 'REQUEST_CHANGES'],
|
||||
})
|
||||
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
|
||||
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
|
||||
@IsIn(['ACCEPT', 'REQUEST_CHANGES'])
|
||||
decision!: 'ACCEPT' | 'REQUEST_CHANGES';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
|
||||
@@ -130,22 +112,4 @@ export class OperationReviewDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'New total price — required for ADJUST_PRICE.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export class ConfirmOperationPriceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'true to accept the operations price adjustment and proceed to the ' +
|
||||
'batch pool; false to reject it (returns to operation changes requested).',
|
||||
})
|
||||
@IsBoolean()
|
||||
accept!: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
constructor(
|
||||
@InjectRepository(BookingRequest)
|
||||
repository: Repository<BookingRequest>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** All requests on a contract, newest first. */
|
||||
async findForContract(contractId: string): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { contractId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** GL queue: pending requests across all contracts, oldest first. */
|
||||
async findPending(): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { status: 'PENDING' },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: { contract: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BookingRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { contract: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Total rows — used to mint the next sequential reference. */
|
||||
async count(): Promise<number> {
|
||||
return this.repository.count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
||||
|
||||
/**
|
||||
* Customer shipment requests on GENERAL customs (Path B) contracts. The customer
|
||||
* submits a request (date + quantities); GL reviews the queue and, on accept,
|
||||
* creates the booking — after which per-booking clearance begins. ONE_TIME and
|
||||
* Path A do not use this flow.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingRequestService {
|
||||
constructor(
|
||||
private readonly repo: BookingRequestRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
) {}
|
||||
|
||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
||||
private assertGeneralCustoms(contract: Contract): void {
|
||||
if (
|
||||
contract.contractKind !== 'GENERAL' ||
|
||||
!contract.customsClearingEnabled
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Shipment requests apply only to general customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer submits a shipment request. */
|
||||
async submit(
|
||||
contractId: string,
|
||||
dto: CreateBookingRequestDto,
|
||||
userId?: string,
|
||||
): Promise<BookingRequest> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||
this.assertGeneralCustoms(contract);
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'The contract must be active before requesting a shipment.',
|
||||
);
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
const hasLines = isContainer
|
||||
? (dto.containers?.length ?? 0) > 0
|
||||
: Boolean(dto.bulk);
|
||||
if (!hasLines) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? 'Add at least one container line.'
|
||||
: 'Enter the bulk cargo amount.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate requested container sizes against the contract cargo scope and
|
||||
// remaining draw-down capacity (reuses the booking quantity-cap check).
|
||||
if (isContainer) {
|
||||
const allowed = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
);
|
||||
for (const line of dto.containers ?? []) {
|
||||
if (allowed.size && !allowed.has(line.containerSize)) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is not in this contract's scope.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||
containers: dto.containers,
|
||||
bulk: dto.bulk,
|
||||
});
|
||||
|
||||
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
||||
? {
|
||||
containers: (dto.containers ?? []).map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.quantity,
|
||||
hazardousQuantity: l.hazardousQuantity,
|
||||
reeferQuantity: l.reeferQuantity,
|
||||
})),
|
||||
}
|
||||
: {
|
||||
bulk: {
|
||||
cargoTypeId: dto.bulk?.cargoTypeId ?? null,
|
||||
cargoWeightTons: dto.bulk?.cargoWeightTons,
|
||||
itemCount: dto.bulk?.itemCount,
|
||||
hazardousQuantity: dto.bulk?.hazardousQuantity,
|
||||
},
|
||||
};
|
||||
|
||||
const reference = await this.generateReference();
|
||||
return this.repo.create({
|
||||
reference,
|
||||
contractId,
|
||||
requestedByUserId: userId ?? null,
|
||||
contractRouteId: dto.contractRouteId ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
status: 'PENDING',
|
||||
requestedLines,
|
||||
notes: dto.notes ?? null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
listForContract(contractId: string): Promise<BookingRequest[]> {
|
||||
return this.repo.findForContract(contractId);
|
||||
}
|
||||
|
||||
async findOne(requestId: string): Promise<BookingRequest> {
|
||||
const request = await this.repo.findById(requestId);
|
||||
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
|
||||
return request;
|
||||
}
|
||||
|
||||
queue(): Promise<BookingRequest[]> {
|
||||
return this.repo.findPending();
|
||||
}
|
||||
|
||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||
const request = await this.repo.findById(requestId);
|
||||
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
|
||||
if (request.status !== 'PENDING') {
|
||||
throw new ConflictException(
|
||||
`This request is already ${request.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL accepts a request. The booking itself is created via the GL booking form
|
||||
* (POST /contracts/:id/bookings) which carries the per-unit container data the
|
||||
* request omits; this endpoint records the acceptance + links the created
|
||||
* booking. `bookingId` is supplied by the GL form on success.
|
||||
*/
|
||||
async accept(
|
||||
requestId: string,
|
||||
bookingId: string,
|
||||
staffId?: string,
|
||||
): Promise<BookingRequest> {
|
||||
const request = await this.findPending(requestId);
|
||||
await this.repo.update(requestId, {
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: bookingId,
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
/** GL rejects a request with a note. */
|
||||
async reject(
|
||||
requestId: string,
|
||||
note?: string,
|
||||
staffId?: string,
|
||||
): Promise<BookingRequest> {
|
||||
const request = await this.findPending(requestId);
|
||||
await this.repo.update(requestId, {
|
||||
status: 'REJECTED',
|
||||
reviewNote: note ?? null,
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
/** Customer cancels their own pending request. */
|
||||
async cancel(requestId: string, userId?: string): Promise<BookingRequest> {
|
||||
const request = await this.findPending(requestId);
|
||||
if (request.requestedByUserId && request.requestedByUserId !== userId) {
|
||||
throw new ForbiddenException('You can only cancel your own requests.');
|
||||
}
|
||||
await this.repo.update(requestId, { status: 'CANCELLED' } as never);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const count = await this.repo.count();
|
||||
const seq = String(count + 1).padStart(6, '0');
|
||||
return `SR-${seq}`;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,12 @@ export class ContractBookingService {
|
||||
const reference = await this.generateReference();
|
||||
const freightType = contract.freightType;
|
||||
|
||||
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
|
||||
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
|
||||
// operations, and there is NO contract-level clearance cycle to link.
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
@@ -105,7 +111,7 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -165,9 +171,11 @@ export class ContractBookingService {
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Path B side effects: link the clearance cycle, seed post-booking
|
||||
// milestones onto the booking, and advance the contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
@@ -180,6 +188,14 @@ export class ContractBookingService {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed post-booking milestones on the booking (no
|
||||
// cycle needed) and leave the contract active. The booking now drives its
|
||||
// own clearance via the booking-level pipeline.
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
@@ -192,14 +208,24 @@ export class ContractBookingService {
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer
|
||||
// once GL has finalized the pre-booking clearance. The customer never
|
||||
// books a customs contract himself.
|
||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
|
||||
// The customer never books a customs contract himself.
|
||||
if (!isGlActor) {
|
||||
throw new ForbiddenException(
|
||||
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
||||
);
|
||||
}
|
||||
if (contract.contractKind === 'GENERAL') {
|
||||
// GENERAL customs has NO contract clearance cycle — GL books per accepted
|
||||
// shipment request while the contract is active; clearance is per booking.
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new BadRequestException(
|
||||
'Contract must be active to book a shipment.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
@@ -233,6 +259,42 @@ export class ContractBookingService {
|
||||
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
|
||||
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
|
||||
*/
|
||||
/**
|
||||
* Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors
|
||||
* {@link assertWithinQuantityCap} but reads the request's quantity shape.
|
||||
*/
|
||||
async assertRequestWithinCapacity(
|
||||
contract: Contract,
|
||||
lines: {
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
bulk?: { cargoWeightTons?: number; itemCount?: number };
|
||||
},
|
||||
): Promise<void> {
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
if (capacity.length === 0) return; // uncapped contract
|
||||
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
for (const line of lines.containers ?? []) {
|
||||
const cap = capacity.find((c) => c.containerSize === line.containerSize);
|
||||
if (!cap || cap.remaining == null) continue;
|
||||
if (line.quantity > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const requested =
|
||||
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
|
||||
const cap = capacity.find((c) => c.cap != null);
|
||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWithinQuantityCap(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
@@ -27,6 +28,14 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/**
|
||||
* Dropdown-settings code holding the admin-configured contract validity options
|
||||
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||
* code, so accept can only use a configured duration. See the seed migration
|
||||
* `SeedContractValidityPeriods`.
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -46,6 +55,7 @@ export class ContractTransitionService {
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly approvalRulesService: ApprovalRulesService,
|
||||
private readonly cargoTypesService: CargoTypesService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
@@ -101,6 +111,8 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertValidityDaysConfigured(validityDays);
|
||||
|
||||
const validFrom = new Date();
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
@@ -118,6 +130,37 @@ export class ContractTransitionService {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||
* or has no options yet, fall back to the DTO range check (already applied) so
|
||||
* acceptance is never hard-blocked before an admin configures the list.
|
||||
*/
|
||||
private async assertValidityDaysConfigured(validityDays: number): Promise<void> {
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.dropdownSettingsService.getByCode(
|
||||
CONTRACT_VALIDITY_PERIODS_CODE,
|
||||
);
|
||||
} catch {
|
||||
// Not configured yet — keep the flow working with the DTO range only.
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = (setting.children ?? [])
|
||||
.map((o) => Number(o.value))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (allowed.length === 0) return;
|
||||
|
||||
if (!allowed.includes(validityDays)) {
|
||||
throw new BadRequestException(
|
||||
`Validity ${validityDays} days is not a configured option. Allowed: ${allowed
|
||||
.sort((a, b) => a - b)
|
||||
.join(', ')} days.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build contract approval steps from the system approval_rules chain (US-06:
|
||||
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
||||
@@ -520,7 +563,16 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
|
||||
if (clearanceCode) {
|
||||
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
||||
// level: there is no contract clearance cycle. The contract just becomes
|
||||
// active; the customer then files shipment requests and GL books + clears
|
||||
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
||||
// cycle below.
|
||||
const isGeneralCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
|
||||
if (clearanceCode && !isGeneralCustoms) {
|
||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||
// distinction is enforced at the review/finalize endpoints, not here.
|
||||
@@ -531,7 +583,8 @@ export class ContractTransitionService {
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No clearance gate (DOMESTIC) — ready for the customer to book directly.
|
||||
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
||||
// clears per booking). Ready for shipment requests / direct booking.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
@@ -58,6 +59,10 @@ import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CreateBookingRequestDto,
|
||||
ReviewBookingRequestDto,
|
||||
} from './dto/create-booking-request.dto';
|
||||
import {
|
||||
AdviseDutyDto,
|
||||
AssignRiskDto,
|
||||
@@ -78,9 +83,78 @@ export class ContractsController {
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly bookingRequestService: BookingRequestService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
) {}
|
||||
|
||||
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||
// STATIC routes declared before any `:id`-param route so Nest matches them
|
||||
// (mirrors the clearance/queue ordering below).
|
||||
|
||||
@Get('booking-requests/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
||||
bookingRequestQueue() {
|
||||
return this.bookingRequestService.queue();
|
||||
}
|
||||
|
||||
@Get('booking-requests/:reqId')
|
||||
@ApiOperation({ summary: 'A single shipment request' })
|
||||
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
|
||||
return this.bookingRequestService.findOne(reqId);
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' })
|
||||
acceptBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@Body() body: { bookingId: string },
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.accept(
|
||||
reqId,
|
||||
body.bookingId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL rejects a shipment request' })
|
||||
rejectBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@Body() dto: ReviewBookingRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/cancel')
|
||||
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
|
||||
cancelBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/booking-requests')
|
||||
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
|
||||
submitBookingRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get(':id/booking-requests')
|
||||
@ApiOperation({ summary: 'List the shipment requests on a contract' })
|
||||
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingRequestService.listForContract(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
|
||||
@@ -20,6 +21,8 @@ import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
@@ -32,6 +35,7 @@ import { ContractClearanceCycle } from './entities/contract-clearance-cycle.enti
|
||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { ClearanceIncident } from './entities/clearance-incident.entity';
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
|
||||
@@ -54,11 +58,13 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractDocumentReview,
|
||||
ClearanceMilestone,
|
||||
ClearanceIncident,
|
||||
BookingRequest,
|
||||
Booking,
|
||||
BookingContainerUnit,
|
||||
]),
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
SignaturesModule,
|
||||
@@ -82,6 +88,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
BookingRequestService,
|
||||
BookingRequestRepository,
|
||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||
// helpers reused from src/contracts/.
|
||||
ContractTemplateResolver,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** A requested container line (no per-unit data — GL enters that at booking). */
|
||||
export class RequestContainerLineDto {
|
||||
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
export class RequestBulkLineDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
itemCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
}
|
||||
|
||||
/** Customer's shipment request on a GENERAL customs contract. */
|
||||
export class CreateBookingRequestDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Required for multi-route GENERAL.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
contractRouteId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Preferred shipment day (informational).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [RequestContainerLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RequestContainerLineDto)
|
||||
containers?: RequestContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: RequestBulkLineDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => RequestBulkLineDto)
|
||||
bulk?: RequestBulkLineDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ReviewBookingRequestDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* A customer's request to ship under a GENERAL customs (Path B) contract. The
|
||||
* customer cannot book directly; they submit the date + quantities here, Global
|
||||
* Logistics reviews the queue, then creates the booking on their behalf — after
|
||||
* which per-booking customs clearance begins. ONE_TIME contracts do not use this
|
||||
* (they keep contract-level clearance). See plan: per-booking clearance.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_requests' })
|
||||
@Index(['contractId'])
|
||||
@Index(['status'])
|
||||
@Index(['contractId', 'status'])
|
||||
export class BookingRequest extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 40, default: '' })
|
||||
reference!: string;
|
||||
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||
contractRouteId?: string | null;
|
||||
|
||||
/** Customer's preferred shipment day — informational; GL sets the binding date. */
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'PENDING' })
|
||||
status!: Freight.BookingRequestStatus;
|
||||
|
||||
/** Requested quantities (container lines or one bulk line) — no per-unit data. */
|
||||
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
requestedLines!: Freight.RequestedShipmentLines;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
/** Set when GL accepts the request and creates the booking. */
|
||||
@Column({ name: 'created_booking_id', type: 'uuid', nullable: true })
|
||||
createdBookingId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'review_note', type: 'text', nullable: true })
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export interface CargoContainerLine {
|
||||
containerSize: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo-aware availability query. Beyond the route yards, it carries the cargo
|
||||
* sizing so the service can check matching-wagon + train capacity per day. The
|
||||
* `containers` array is passed as a JSON string in the query string (GET) and
|
||||
* parsed here.
|
||||
*/
|
||||
export class AvailableDaysForCargoQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
|
||||
@ApiProperty({ enum: ['CONTAINER', 'BULK'] })
|
||||
@IsEnum(['CONTAINER', 'BULK'])
|
||||
freightType!: 'CONTAINER' | 'BULK';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bulk cargo type code (e.g. COFFEE).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
totalWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Container lines as a JSON string: [{containerSize,quantity}].',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value == null || value === '') return undefined;
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
@IsArray()
|
||||
containers?: CargoContainerLine[];
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
@@ -123,6 +124,24 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("available-days-for-cargo")
|
||||
// No staff guard: customers (portal) and GL (backoffice) both hit this while
|
||||
// creating a booking to find which DAYS are feasible for THIS cargo — i.e. the
|
||||
// route has a train with remaining capacity AND enough matching-type wagons.
|
||||
@ApiOperation({
|
||||
summary: "Days bookable for a specific cargo (wagon + train capacity aware)",
|
||||
})
|
||||
getAvailableDaysForCargo(@Query() query: AvailableDaysForCargoQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableDaysForCargo({
|
||||
originYardId: query.originYardId,
|
||||
destinationYardId: query.destinationYardId,
|
||||
freightType: query.freightType,
|
||||
cargoTypeCode: query.cargoTypeCode,
|
||||
totalWeightTons: query.totalWeightTons,
|
||||
containers: query.containers,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("container/eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List eligible container bookings" })
|
||||
|
||||
@@ -2074,7 +2074,18 @@ export class TrainSchedulingService {
|
||||
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
||||
* returns schedules whose route passes through both yards in the correct order.
|
||||
*/
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
/**
|
||||
* Raw OPEN same-route schedule entities a new booking may target (with the
|
||||
* relations needed for capacity/fleet checks). Shared by getBookableSchedules
|
||||
* (which maps to list items) and getAvailableDaysForCargo (which needs the raw
|
||||
* originStationId / scheduledDepartureDate / trainSet).
|
||||
*/
|
||||
private async getBookableScheduleEntities(
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<
|
||||
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
||||
> {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
@@ -2089,7 +2100,7 @@ export class TrainSchedulingService {
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
|
||||
const filteredSchedules = schedules
|
||||
return schedules
|
||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||
.filter((s) => {
|
||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||
@@ -2128,10 +2139,15 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((s) => this.mapScheduleListItem(s));
|
||||
});
|
||||
}
|
||||
|
||||
return filteredSchedules;
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2151,6 +2167,93 @@ export class TrainSchedulingService {
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given
|
||||
* cargo. A day is selectable only when ≥1 OPEN schedule on the route that day
|
||||
* has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that
|
||||
* schedule's origin yard, and (b) remaining train capacity (not fully
|
||||
* allocated). Days with trains but not enough matching wagons are excluded.
|
||||
* Same `{ days: string[] }` shape as getAvailableDays — the customer still
|
||||
* picks a DAY, not a train.
|
||||
*/
|
||||
async getAvailableDaysForCargo(input: {
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeCode?: string | null;
|
||||
totalWeightTons?: number;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
}): Promise<{ days: string[] }> {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
input.originYardId,
|
||||
input.destinationYardId,
|
||||
);
|
||||
if (schedules.length === 0) return { days: [] };
|
||||
|
||||
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
|
||||
|
||||
// Resolve the wagon type this cargo needs.
|
||||
const requiredType =
|
||||
input.freightType === 'BULK'
|
||||
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
|
||||
: wagonTypes.find(
|
||||
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
|
||||
);
|
||||
if (!requiredType) return { days: [] };
|
||||
|
||||
// How many wagons of that type the cargo needs.
|
||||
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
||||
|
||||
// AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
const availableByYard = new Map<string, number>();
|
||||
const availableAt = async (yardId: string): Promise<number> => {
|
||||
const cached = availableByYard.get(yardId);
|
||||
if (cached !== undefined) return cached;
|
||||
const counts = await this.countFleetAvailability(yardId);
|
||||
const n =
|
||||
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
availableByYard.set(yardId, n);
|
||||
return n;
|
||||
};
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
if (!enoughWagons) continue;
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight /
|
||||
* capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per
|
||||
* wagon. Mirrors wagon-plan.util without fabricating Booking entities.
|
||||
*/
|
||||
private wagonsNeededForCargo(
|
||||
input: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
totalWeightTons?: number;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
},
|
||||
wagonType: WagonType,
|
||||
): number {
|
||||
if (input.freightType === 'BULK') {
|
||||
const capacity = Number(wagonType.capacityTons) || 1;
|
||||
const weight = Number(input.totalWeightTons ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
}
|
||||
const teu = (input.containers ?? []).reduce((sum, c) => {
|
||||
const per = c.containerSize === '40ft' ? 2 : 1;
|
||||
return sum + per * Math.max(0, Number(c.quantity ?? 0));
|
||||
}, 0);
|
||||
return Math.max(1, Math.ceil(teu / 2));
|
||||
}
|
||||
|
||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
|
||||
Reference in New Issue
Block a user