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:
Marshal
2026-06-29 09:30:44 +00:00
parent aeb5e0046e
commit 0f7cac2b68
46 changed files with 2665 additions and 992 deletions

View File

@@ -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',
}),
);
});
});

View File

@@ -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.
*

View File

@@ -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' })

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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')

View File

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

View File

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

View File

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

View File

@@ -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[];
}

View File

@@ -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" })

View File

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