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

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seeds the admin-configurable "contract validity periods" setting (days). Stored
* as a dropdown_settings row whose options each hold a day count in `value`, so
* backoffice manages them through the existing Dropdown Settings UI and the
* contract staff-accept dialog only offers the configured durations.
*/
export class SeedContractValidityPeriods1792000000004
implements MigrationInterface
{
name = 'SeedContractValidityPeriods1792000000004';
private readonly code = 'contract_validity_periods';
private readonly options: Array<{ value: string; label: string }> = [
{ value: '180', label: '6 months' },
{ value: '365', label: '1 year' },
{ value: '730', label: '2 years' },
];
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
VALUES ($1, $2, $3, false)
RETURNING id;`,
[
this.code,
'Contract Validity Periods (days)',
'Validity durations (in days) a staff can choose when accepting a submitted contract.',
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const opt = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, opt.value, opt.label, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
[this.code],
);
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Customer shipment requests for GENERAL customs (Path B) contracts. The customer
* submits date + quantities; Global Logistics reviews, then creates the booking
* on their behalf and per-booking clearance begins. Additive — no change to
* existing tables; ONE_TIME contracts are unaffected.
*/
export class CreateBookingRequests1827000000000 implements MigrationInterface {
name = 'CreateBookingRequests1827000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_requests',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'reference', type: 'varchar', length: '40', default: "''" },
{ name: 'contract_id', type: 'uuid' },
{ name: 'requested_by_user_id', type: 'uuid', isNullable: true },
{ name: 'contract_route_id', type: 'uuid', isNullable: true },
{ name: 'scheduled_date', type: 'timestamptz', isNullable: true },
{ name: 'status', type: 'varchar', length: '16', default: "'PENDING'" },
{ name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" },
{ name: 'notes', type: 'text', isNullable: true },
{ name: 'created_booking_id', type: 'uuid', isNullable: true },
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
{ name: 'review_note', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['contract_id'],
referencedSchema: 'freight',
referencedTableName: 'contracts',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
{
columnNames: ['created_booking_id'],
referencedSchema: 'freight',
referencedTableName: 'bookings',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.booking_requests',
new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }),
);
await queryRunner.createIndex(
'freight.booking_requests',
new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }),
);
await queryRunner.createIndex(
'freight.booking_requests',
new TableIndex({
name: 'idx_booking_requests_contract_status',
columnNames: ['contract_id', 'status'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_requests', true);
}
}

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,

View File

@@ -1,27 +1,16 @@
import { useState } from "react";
import { Banknote, Pencil, Receipt } from "lucide-react";
import {
Button,
Divider,
Group,
NumberInput,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { Banknote, Receipt } from "lucide-react";
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { bookingsService } from "@/services/bookings.service";
import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const qc = useQueryClient();
const computed = Number(booking.totalAmount);
// The booking price is computed from the contract and is NOT staff-editable.
// A historical `adjustedTotalAmount` (from before adjustments were removed)
// is still shown read-only so old records render correctly.
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
@@ -29,21 +18,6 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | "">(effective);
const [reason, setReason] = useState("");
const adjustMutation = useMutation({
mutationFn: (payload: { amount: number | null; reason?: string }) =>
bookingsService.adjustPrice(booking.id, payload.amount, payload.reason),
onSuccess: () => {
toast.success("Price updated");
setEditing(false);
qc.invalidateQueries({ queryKey: ["bookings"] });
},
onError: () => toast.error("Could not update price"),
});
const fmt = (n: number) =>
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
@@ -51,102 +25,23 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
<SectionCard icon={Banknote} title="Pricing & payment">
<Stack gap="md">
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</div>
{!editing && (
<Button
size="compact-xs"
variant="light"
leftSection={<Pencil size={13} />}
onClick={() => {
setAmount(effective);
setEditing(true);
}}
>
Adjust
</Button>
)}
</Group>
{editing && (
<Stack gap="xs" mt="md">
<NumberInput
label="New total"
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
min={0}
radius="md"
prefix={`${booking.paymentCurrency} `}
thousandSeparator=","
/>
<Textarea
label="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
/>
<Group justify="space-between" mt={4}>
{isAdjusted ? (
<Button
size="compact-sm"
variant="subtle"
color="red"
loading={adjustMutation.isPending}
onClick={() =>
adjustMutation.mutate({ amount: null })
}
>
Clear adjustment
</Button>
) : (
<span />
)}
<Group gap="xs">
<Button
size="compact-sm"
variant="default"
onClick={() => setEditing(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
loading={adjustMutation.isPending}
disabled={amount === ""}
onClick={() =>
adjustMutation.mutate({
amount: Number(amount),
reason: reason.trim() || undefined,
})
}
>
Save
</Button>
</Group>
</Group>
</Stack>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</Paper>

View File

@@ -15,13 +15,6 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/** An adjusted price must be a non-negative number. */
function isValidAmount(value: string): boolean {
if (!value.trim()) return false;
const amount = Number(value.trim());
return Number.isFinite(amount) && amount >= 0;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -93,15 +86,6 @@ export function useBookingActionDialog(
{ onSuccess },
);
break;
case "operationAdjustPrice": {
const amount = Number(inputValue.trim());
if (!Number.isFinite(amount) || amount < 0) return;
mutations.reviewOperation.mutate(
{ decision: "ADJUST_PRICE", amount },
{ onSuccess },
);
break;
}
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
@@ -151,8 +135,7 @@ export function useBookingActionDialog(
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
return {
actions,

View File

@@ -1,9 +1,11 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Anchor,
Button,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
@@ -19,9 +21,13 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps {
@@ -49,12 +55,34 @@ export function ContractActionsToolbar({
const { status } = contract;
const [acceptOpen, setAcceptOpen] = useState(false);
const [validityDays, setValidityDays] = useState<number | string>(365);
const [validityDays, setValidityDays] = useState<string | null>(null);
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// Admin-configured validity durations (days) for the accept dialog. Staff can
// only pick one of these — no free-typing. Read-only setting, fetched once.
const { data: validitySetting, isLoading: validityLoading } = useQuery({
...api.dropdownSettings.getByCode.queryOptions({
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
}),
retry: false,
});
const validityOptions = useMemo(
() =>
[...(validitySetting?.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
// Default the selection to the first configured option when the dialog opens.
useEffect(() => {
if (acceptOpen && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [acceptOpen, validityDays, validityOptions]);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}
@@ -189,22 +217,48 @@ export function ContractActionsToolbar({
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Set the contract validity window, then start the approval chain.
Pick the contract validity window, then start the approval chain.
</Text>
<NumberInput
label="Validity (days)"
min={1}
value={validityDays}
onChange={setValidityDays}
/>
{validityOptions.length > 0 ? (
<Select
label="Validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under "}
{!validityLoading && (
<Anchor
href="/dashboard/dropdown-settings"
onClick={(e) => {
e.preventDefault();
navigate("/dashboard/dropdown-settings");
}}
>
Dropdown Settings
</Anchor>
)}
{!validityLoading && "."}
</Text>
)}
<Button
color="edr-green"
loading={mutations.staffAccept.isPending}
onClick={() =>
mutations.staffAccept.mutate(Number(validityDays) || 365, {
disabled={!validityDays}
onClick={() => {
const days = Number(validityDays);
if (!days) return;
mutations.staffAccept.mutate(days, {
onSuccess: () => setAcceptOpen(false),
})
}
});
}}
>
Accept
</Button>

View File

@@ -1,5 +1,10 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import {
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
@@ -22,6 +27,7 @@ import {
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Container as ContainerIcon,
FileText,
@@ -32,10 +38,13 @@ import {
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
import {
useContractCapacity,
useContractDetail,
@@ -74,16 +83,61 @@ function emptyUnit(): UnitDraft {
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
// When GL accepts a shipment request, the form opens with ?requestId=… so it
// can prefill the requested quantities/date and mark the request accepted on
// success.
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const { data: capacity = [] } = useContractCapacity(id);
const mutations = useContractMutations(id ?? "");
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
enabled: Boolean(requestId),
});
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
const [prefilled, setPrefilled] = useState(false);
// Prefill once from an accepted shipment request: size/qty container lines
// (one blank unit per requested container) + bulk + route + notes. GL still
// enters per-unit container numbers + sets the binding shipment date.
useEffect(() => {
if (!bookingRequest || prefilled) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
hazardousQuantity: c.hazardousQuantity ?? "",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
})),
);
} else if (lines.bulk) {
setBulkLines([
{
cargoTypeId: lines.bulk.cargoTypeId ?? "",
cargoWeightTons: lines.bulk.cargoWeightTons ?? "",
itemCount: lines.bulk.itemCount ?? "",
hazardousQuantity: lines.bulk.hazardousQuantity ?? "",
},
]);
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Price-confirm modal — GL reviews the estimate before booking on behalf of
// the customer, mirroring the portal customer flow.
const [priceOpen, setPriceOpen] = useState(false);
@@ -146,6 +200,60 @@ export default function GlCreateBookingForm() {
[contract, quantities],
);
// The route this shipment ships on (for the cargo-aware day list). For a
// single-route contract there's exactly one; for GENERAL multi-route, the
// selected route (defaults to the first).
const selectedRoute = useMemo(
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
[routes, contractRouteId],
);
// Cargo-aware availability query: only days where a train has remaining
// capacity AND enough matching-type wagons for the entered cargo. Null until
// the cargo is entered (so the Schedule section stays empty first).
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId)
return null;
if (isContainer) {
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || 0),
0,
);
if (tons <= 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
}, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.trainScheduling.availableDaysForCargo.queryOptions({
input: cargoQuery ?? {
freightType: "BULK" as const,
},
}),
enabled: cargoQuery !== null,
});
if (isLoading) {
return (
<PageContainer>
@@ -265,8 +373,20 @@ export default function GlCreateBookingForm() {
}
mutations.createBooking.mutate(payload, {
onSuccess: (booking) =>
navigate(`/dashboard/bookings/${booking.id}/milestones`),
onSuccess: async (booking) => {
if (requestId) {
// GENERAL+customs accept flow: mark the request accepted + link the
// booking, then hand off to the per-booking clearance review.
try {
await contractsService.acceptBookingRequest(requestId, booking.id);
} catch {
// Non-fatal — the booking exists; the request link can be retried.
}
navigate(`/dashboard/clearance/${booking.id}`);
} else {
navigate(`/dashboard/bookings/${booking.id}/milestones`);
}
},
});
};
@@ -312,38 +432,60 @@ export default function GlCreateBookingForm() {
</Group>
</Alert>
)}
<SectionCard icon={FileText} title="Schedule">
<Grid gap="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<TextInput
label="Scheduled date"
type="date"
description="Binding shipment day"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.currentTarget.value)}
required
/>
</Grid.Col>
{needsRouteSelect && (
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
</Grid.Col>
)}
</Grid>
{bookingRequest ? (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<FileText size={16} />}
title="From shipment request"
>
Booking on behalf of the customer for request{" "}
<b>{bookingRequest.reference}</b>.
{bookingRequest.scheduledDate ? (
<>
{" "}
Customer requested{" "}
<b>
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}
</b>{" "}
set the binding shipment date below.
</>
) : null}
</Alert>
) : null}
<SectionCard icon={FileText} title="Route">
{needsRouteSelect ? (
<Select
label="Contract route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
) : (
<Text size="sm" c="dimmed">
{selectedRoute
? `${selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "Origin"}${
selectedRoute.destinationYard?.label ??
selectedRoute.destinationYard?.code ??
"Destination"
}`
: "This contract's only route."}
</Text>
)}
</SectionCard>
{isContainer ? (
@@ -610,6 +752,40 @@ export default function GlCreateBookingForm() {
</SectionCard>
)}
<SectionCard icon={FileText} title="Schedule">
{cargoQuery === null ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter the cargo details first available shipment days depend on
the wagons the cargo needs.
</Alert>
) : (
<>
{bookingRequest?.scheduledDate ? (
<Text size="xs" c="dimmed" mb="xs">
Customer requested{" "}
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}{" "}
pick the binding shipment day below.
</Text>
) : null}
<OperationDatePicker
availableDays={availableDays ?? []}
isLoading={daysLoading}
value={scheduledDate}
onChange={setScheduledDate}
/>
</>
)}
</SectionCard>
<SectionCard icon={FileText} title="Notes">
<Textarea
placeholder="Internal GL notes (optional)"

View File

@@ -154,6 +154,15 @@ export const URL_CONSTANTS = {
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
BOOKING_REQUEST_BY_ID: (reqId: string) =>
`/contracts/booking-requests/${reqId}`,
BOOKING_REQUESTS: (id: string) => `/contracts/${id}/booking-requests`,
BOOKING_REQUEST_ACCEPT: (reqId: string) =>
`/contracts/booking-requests/${reqId}/accept`,
BOOKING_REQUEST_REJECT: (reqId: string) =>
`/contracts/booking-requests/${reqId}/reject`,
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>
`/contracts/bookings/${bookingId}/milestones`,
@@ -197,6 +206,7 @@ export const URL_CONSTANTS = {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/train-scheduling/available-days-for-cargo",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
Coins,
FileSignature,
MessageSquareWarning,
Play,
@@ -36,7 +35,6 @@ export type BookingActionId =
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationAdjustPrice"
| "cancel";
export type BookingActionInputKind =
@@ -196,20 +194,6 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
{
id: "operationAdjustPrice",
label: "Adjust price",
shortLabel: "Price",
description: "Set an adjusted total the customer must confirm",
confirmTitle: "Adjust the order price?",
confirmDescription:
"Enter the new total. The customer must confirm it before the order proceeds.",
variant: "outline",
icon: Coins,
input: "amount",
inputLabel: "Adjusted total",
inputPlaceholder: "0.00",
},
];
const CANCEL_ACTION: BookingActionDef = {
@@ -280,7 +264,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};

View File

@@ -63,9 +63,8 @@ export function useBookingMutations(bookingId: string) {
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
decision: "ACCEPT" | "REQUEST_CHANGES";
note?: string;
amount?: number;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),

View File

@@ -0,0 +1,247 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
AlertCircle,
CalendarDays,
PackagePlus,
XCircle,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
function lineRows(lines: Freight.RequestedShipmentLines) {
if (lines.containers?.length) {
return lines.containers.map(
(c) =>
`${c.quantity} × ${c.containerSize}` +
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
);
}
if (lines.bulk) {
const b = lines.bulk;
const parts: string[] = [];
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
if (b.itemCount) parts.push(`${b.itemCount} items`);
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
return [parts.join(" · ") || "Bulk cargo"];
}
return ["—"];
}
export default function ShipmentRequestDetailPage() {
const { id: reqId } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectNote, setRejectNote] = useState("");
const { data: request, isLoading } = useQuery({
queryKey: ["shipment-request", reqId],
queryFn: () => contractsService.getBookingRequest(reqId!),
enabled: Boolean(reqId),
});
const reject = useMutation({
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
navigate("/dashboard/shipment-requests");
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80}>
<Loader color="edr-green" />
</Group>
</PageContainer>
);
}
if (!request) {
return (
<PageContainer>
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldn't load this shipment request.
</Alert>
</PageContainer>
);
}
const isPending = request.status === "PENDING";
const contractRef = request.contract?.reference ?? request.contractId;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
{ label: request.reference },
]}
meta={
<Badge
variant="light"
radius="sm"
color={
request.status === "PENDING"
? "edr-green"
: request.status === "ACCEPTED"
? "blue"
: "gray"
}
>
{request.status}
</Badge>
}
action={
isPending ? (
<Group gap="sm">
<Button
variant="light"
color="red"
radius="md"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
)
}
>
Accept &amp; create booking
</Button>
</Group>
) : request.status === "ACCEPTED" && request.createdBookingId ? (
<Button
variant="light"
color="edr-green"
radius="md"
onClick={() =>
navigate(`/dashboard/clearance/${request.createdBookingId}`)
}
>
View booking clearance
</Button>
) : undefined
}
/>
<SectionCard icon={CalendarDays} title="Requested shipment">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Preferred date (informational)
</Text>
<Text size="sm" fw={600}>
{fmtDate(request.scheduledDate)}
</Text>
</Group>
<Box>
<Text size="sm" c="dimmed" mb={6}>
Quantities
</Text>
<Stack gap={4}>
{lineRows(request.requestedLines ?? {}).map((l, i) => (
<Badge
key={i}
variant="light"
color="edr-green"
radius="sm"
size="lg"
>
{l}
</Badge>
))}
</Stack>
</Box>
{request.notes ? (
<Box>
<Text size="sm" c="dimmed" mb={4}>
Customer note
</Text>
<Text size="sm">{request.notes}</Text>
</Box>
) : null}
{request.reviewNote ? (
<Alert color="red" variant="light" radius="md" mt="sm">
Rejected: {request.reviewNote}
</Alert>
) : null}
</Stack>
</SectionCard>
</Stack>
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
centered
radius="md"
title="Reject shipment request"
>
<Stack gap="md">
<Textarea
label="Reason"
placeholder="Tell the customer why this request can't proceed"
autosize
minRows={3}
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
onClick={() => reject.mutate()}
>
Reject request
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { contractsService } from "@/services/contracts.service";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
/** Summarize requested quantities for the list row. */
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
if (lines.containers?.length) {
return lines.containers
.map((c) => `${c.quantity}× ${c.containerSize}`)
.join(", ");
}
if (lines.bulk) {
const b = lines.bulk;
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
if (b.itemCount) return `${b.itemCount} items`;
return "Bulk";
}
return "—";
}
interface RequestRow {
id: string;
reference: string;
contractReference: string;
scheduledDate?: string | null;
summary: string;
}
export default function ShipmentRequestsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["shipment-request-queue"],
queryFn: () => contractsService.getBookingRequestQueue(),
refetchInterval: 30_000,
});
const rows = useMemo<RequestRow[]>(() => {
const all = (data ?? []).map((r) => ({
id: r.id,
reference: r.reference || r.id.slice(0, 8),
contractReference: r.contract?.reference ?? r.contractId,
scheduledDate: r.scheduledDate,
summary: summarizeLines(r.requestedLines ?? {}),
}));
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.contractReference.toLowerCase().includes(q) ||
r.summary.toLowerCase().includes(q),
);
}, [data, query]);
const columns = useMemo<ColumnDef<RequestRow>[]>(
() => [
{
id: "reference",
header: "Request",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" fw={700} c="dark.5">
{row.original.reference}
</Text>
),
},
{
id: "contract",
header: "Contract",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" c="gray.7">
{row.original.contractReference}
</Text>
),
},
{
id: "summary",
header: "Requested",
meta: cellMeta,
cell: ({ row }) => (
<Badge variant="light" color="edr-green" radius="sm">
{row.original.summary}
</Badge>
),
},
{
id: "date",
header: "Preferred date",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
),
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipment Requests"
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageSearch size={13} />}
>
{rows.length} pending
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<TextInput
radius="md"
maw={360}
placeholder="Search request, contract, cargo…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
{rows.length === 0 && !isLoading ? (
<Box
py={56}
style={{
borderRadius: 14,
border: "1px dashed var(--mantine-color-gray-3)",
textAlign: "center",
}}
>
<Inbox size={26} className="text-muted-foreground" />
<Text c="dimmed" mt="sm">
No pending shipment requests.
</Text>
</Box>
) : (
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) =>
navigate(`/dashboard/shipment-requests/${row.id}`)
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
)}
</Stack>
</PageContainer>
);
}

View File

@@ -284,6 +284,32 @@ export const api = {
],
),
availableDaysForCargo: endpoint<
{
originYardId?: string;
destinationYardId?: string;
freightType: "CONTAINER" | "BULK";
cargoTypeCode?: string;
totalWeightTons?: number;
containers?: { containerSize: string; quantity: number }[];
},
string[]
>(
"train-scheduling",
"available-days-for-cargo",
(input) => trainSchedulingService.getAvailableDaysForCargo(input),
(input) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"available-days-for-cargo",
input.originYardId ?? "",
input.destinationYardId ?? "",
input.freightType,
input.cargoTypeCode ?? "",
input.totalWeightTons ?? 0,
JSON.stringify(input.containers ?? []),
],
),
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
"train-scheduling",
"track",
@@ -1838,13 +1864,12 @@ export const api = {
reviewOperation: endpoint<
{
id: string;
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
decision: "ACCEPT" | "REQUEST_CHANGES";
note?: string;
amount?: number;
},
BookingDetail
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
bookingsService.reviewOperation(id, decision, { note, amount }),
>("bookings", "reviewOperation", ({ id, decision, note }) =>
bookingsService.reviewOperation(id, decision, { note }),
),
approveStep: endpoint<ApproveStepPayload, BookingDetail>(

View File

@@ -212,21 +212,14 @@ export const bookingsService = {
/** Marketing/operations review of a drawdown order's operation request. */
reviewOperation: (
id: string,
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
options: { note?: string; amount?: number } = {},
decision: "ACCEPT" | "REQUEST_CHANGES",
options: { note?: string } = {},
) =>
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
decision,
...options,
}),
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
adjustPrice: (id: string, amount: number | null, reason?: string) =>
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
amount,
reason,
}),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),

View File

@@ -244,6 +244,37 @@ export const contractsService = {
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
},
// ── Shipment requests (GENERAL + customs) ──
/** GL queue of pending shipment requests across contracts. */
getBookingRequestQueue: async (): Promise<Freight.IBookingRequest[]> => {
const response = await client.get(C.BOOKING_REQUEST_QUEUE);
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
},
listBookingRequests: async (
id: string,
): Promise<Freight.IBookingRequest[]> => {
const response = await client.get(C.BOOKING_REQUESTS(id));
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
},
getBookingRequest: async (
reqId: string,
): Promise<Freight.IBookingRequest> => {
const response = await client.get(C.BOOKING_REQUEST_BY_ID(reqId));
return unwrap(response.data) as Freight.IBookingRequest;
},
acceptBookingRequest: (reqId: string, bookingId: string) =>
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_ACCEPT(reqId), {
bookingId,
}),
rejectBookingRequest: (reqId: string, note?: string) =>
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_REJECT(reqId), {
note,
}),
// ── Clearance milestones ──
listMilestonesForContract: async (
id: string,

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -127,6 +128,24 @@ export const trainSchedulingService = {
return unwrap(response.data).days;
},
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
// is serialized as a JSON string param (the server parses it).
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, ...rest } = query;
const response = await client.get<{ days: string[] }>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
},
},
);
return unwrap(response.data).days;
},
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),

View File

@@ -135,6 +135,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
},
PAYMENTS: {

View File

@@ -1,23 +1,5 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
import { OperationDatePicker as DatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
@@ -29,11 +11,9 @@ interface OperationDatePickerProps {
}
/**
* Compact month calendar for picking the binding shipment day at the
* operation-request step. Only days that have an OPEN scheduled departure on the
* booking route are selectable; all other days are disabled.
*
* Shared by the booking detail clearance card and the home-page action modal.
* Route-based day picker for the operation-request step: a thin query wrapper
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
* Only days with an OPEN scheduled departure on the route are selectable.
*/
export function OperationDatePicker({
originYardId,
@@ -41,8 +21,6 @@ export function OperationDatePicker({
value,
onChange,
}: OperationDatePickerProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const { data: availableDays, isLoading } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
@@ -50,170 +28,14 @@ export function OperationDatePicker({
}),
);
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date,
dateString,
day: date.getDate(),
inMonth: isSameMonth(date, month),
today: isToday(date),
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
<DatePicker
availableDays={availableDays ?? []}
isLoading={isLoading}
value={value}
onChange={onChange}
/>
);
}
export default OperationDatePicker;

View File

@@ -285,13 +285,12 @@ export default function NewContractPage() {
const isContainer = data.cargoType === "container";
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
// size (+ optional commodity); bulk: a single commodity row.
// size; bulk: a single commodity row.
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
const isGeneral = data.contractKind === "general_contract";
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
containerSize: size,
cargoTypeId: data.cargoCommodityId || undefined,
quantityCap:
isGeneral && data.containerSizeCaps[size]
? data.containerSizeCaps[size]

View File

@@ -32,8 +32,8 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { OperationDatePicker } from "@/pages/bookings/clearance/OperationDatePicker";
import {
SelectField,
StepCard,
@@ -264,8 +264,8 @@ export default function NewShipmentPage() {
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
<RouteStep form={form} contract={contract} routes={routes} />
<ScheduleStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
<ScheduleStep form={form} contract={contract} routes={routes} />
<NotesSection form={form} />
</Stack>
</Box>
@@ -486,6 +486,7 @@ function RouteStep({
function ScheduleStep({
form,
contract,
routes,
}: {
form: ShipmentForm;
@@ -494,35 +495,96 @@ function ScheduleStep({
}) {
const contractRouteId = form.watch("contractRouteId");
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
// Read the cargo entered in the previous step so the day list reflects what
// can actually be shipped (matching wagons + open train capacity).
const isContainer = contract.freightType === "CONTAINER";
const containerLines = form.watch("containers");
const cargoWeightTons = form.watch("cargoWeightTons");
const itemCount = form.watch("itemCount");
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!route?.originYardId || !route?.destinationYardId) return null;
if (isContainer) {
const containers = (containerLines ?? [])
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity || 0),
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = Number(cargoWeightTons || 0);
if (tons <= 0) return null;
return {
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
// itemCount is referenced so the query refreshes when a PER_ITEM cargo
// amount changes (weight is the sizing input the backend uses).
}, [
route,
isContainer,
containerLines,
cargoWeightTons,
itemCount,
contract.pricingBreakdown,
]);
const { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
}),
enabled: cargoQuery !== null,
});
return (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Pick the binding shipment day. Only days with an open departure on your route can be selected."
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for your cargo can be selected."
/>
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10}>
<OperationDatePicker
originYardId={route?.originYardId}
destinationYardId={route?.destinationYardId}
value={field.value ?? ""}
onChange={(d) => field.onChange(d)}
/>
{cargoQuery === null ? (
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
Enter your cargo details first available shipment days depend on the
wagons your cargo needs.
</Alert>
) : (
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10}>
<OperationDatePicker
availableDays={availableDays ?? []}
isLoading={isLoading}
value={field.value ?? ""}
onChange={(d) => field.onChange(d)}
/>
</Box>
{fieldState.error?.message && (
<Text fz="xs" c="red" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
{fieldState.error?.message && (
<Text fz="xs" c="red" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
)}
/>
)}
/>
)}
</StepCard>
);
}

View File

@@ -158,8 +158,6 @@ export const contractFormSchema = z
// Container scope: the enabled sizes (min 1). Each becomes a
// contract_cargo_scope row.
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
// Optional commodity label for the contract PDF (container scope).
cargoCommodityId: z.string().default(""),
// GENERAL only: per-size container quantity cap (total bookable over the
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
@@ -279,7 +277,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
cargoType: "container",
enabledContainerSizes: ["20ft"],
cargoCommodityId: "",
containerSizeCaps: {},
cargoTypePath: [],
cargoFreeText: "",
@@ -318,7 +315,6 @@ export const contractStepFields: Record<
1: [
"cargoType",
"enabledContainerSizes",
"cargoCommodityId",
"containerSizeCaps",
"cargoTypePath",
"cargoFreeText",

View File

@@ -100,17 +100,6 @@ export function Step3CargoScope({
);
}, [referenceData, parentId]);
// Commodity options for the optional container commodity label.
const containerCommodityOptions = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.flatMap((g) =>
(g.children ?? []).map((c) => ({
value: c.id,
label: `${g.name}${c.name}`,
})),
);
}, [referenceData]);
if (isLoading) {
return (
<div className="space-y-4">
@@ -183,27 +172,6 @@ export function Step3CargoScope({
)}
</div>
{/* Container commodity label (optional). */}
{cargoType === "container" && (
<Stack gap={14}>
{containerCommodityOptions.length > 0 && (
<Controller
name="cargoCommodityId"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Commodity (optional)"
placeholder="Select a commodity for the contract document…"
data={containerCommodityOptions}
/>
)}
/>
)}
</Stack>
)}
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
{cargoType === "bulk" && (
<Stack gap={12} mt={18}>

View File

@@ -4,10 +4,13 @@ import * as z from "zod";
// Shipment booking under a contract (doc §8.1, Path A customer). Captures the
// EXECUTION details the contract scope deliberately omits: a binding scheduled
// date, container quantities + per-unit numbers/seals/VGM, or bulk tonnage.
// Order: Route → Cargo Details → Schedule → Review. Cargo is captured BEFORE
// Schedule so the schedule step can offer only days that are feasible for that
// cargo (enough matching wagons + an open train with capacity).
export const SHIPMENT_STEPS = [
{ id: 0, label: "Route", short: "Route" },
{ id: 1, label: "Schedule", short: "Schedule" },
{ id: 2, label: "Cargo Details", short: "Cargo" },
{ id: 1, label: "Cargo Details", short: "Cargo" },
{ id: 2, label: "Schedule", short: "Schedule" },
{ id: 3, label: "Review", short: "Review" },
] as const;
@@ -82,7 +85,7 @@ export const shipmentStepFields: Record<
Array<Path<ShipmentFormValues>>
> = {
0: ["contractRouteId"],
1: ["scheduledDate"],
2: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
1: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
2: ["scheduledDate"],
3: ["notes"],
};

View File

@@ -319,6 +319,12 @@ export const api = {
({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
"train-scheduling",
"availableDaysForCargo",
(input) => bookingsService.getAvailableDaysForCargo(input),
),
},
contracts: {

View File

@@ -265,4 +265,23 @@ export const bookingsService = {
);
return (data.data as Freight.AvailableDaysResponse).days;
},
// Cargo-aware day pool: only days where a train has remaining capacity AND
// enough matching-type wagons for this cargo. `containers` is serialized as a
// JSON string param (the server parses it).
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
};

View File

@@ -1,354 +1,393 @@
# EDR Freight — How the System Works (Step by Step)
# EDR Freight — System Flow (API)
A plain-language walkthrough of the whole customer journey:
How the freight API drives a shipment end to end:
**Onboarding → Contract → Clearance → Booking → Schedule → Delivery**
**Onboarding → Contract → Booking → Clearance → Operation → Schedule → Delivery**
Every step shows its branches. Read the arrows (`→`) as "then". Read **IF** blocks as the different paths.
This document tracks the **API logic only** — exact statuses, transitions, branches, and endpoints as the `edr-freight-api` implements them. Read arrows (`→`) as "then". **IF** blocks are the branches.
> Two facts shape everything below:
> 1. **Clearance is per-booking, not pre-booking** (except the one-time-customs special case). The contract agrees terms; each booking carries its own customs document loop.
> 2. **Who creates the booking depends on customs.** No customs → the customer. Customs (Path B) → Global Logistics (GL) on the customer's behalf.
---
## 1) Onboarding
> Goal: register the company so it can make bookings. The wizard has **9 steps** in this order.
> Register the company so a profile can transact. Only an **active profile** may create contracts or bookings.
```
1. Nationality 2. Role/Operation 3. Company info 4. Personnel (GM)
5. Contact person 6. Verify phone (OTP) 7. Power of Attorney (optional)
8. Documents 9. Business license per profile
Company → status PENDING
Each operation profile (importer | exporter | freight_forwarder) → status PENDING
Backoffice approves each profile → ACTIVE + reference (IM-00001 / EX-00001 / FF-00001)
→ only an ACTIVE profile can create contracts / bookings
```
### Step 1 — Pick nationality
Documents required depend **only on nationality**:
```
Foreign OR Ethiopian
```
(stored on the company: `nationality = "foreign" | "ethiopian"`)
### Step 2 — Pick operation type(s)
You may pick **more than one**. Each one becomes its own *profile* with its own approval + license.
```
Importer OR Exporter OR Freight Forwarder
(importer | exporter | freight_forwarder)
```
### Steps 37 — Fill company + people
- **Company:** TIN (auto-looked-up from eTrade), name, email, phone, address (region/zone/woreda/kebele/house), VAT, FAN.
- **Personnel:** General Manager name / email / phone.
- **Contact person:** name / phone (+ optional position, email).
- **Verify:** SMS OTP sent to the contact phone — must enter the 6-digit code.
- **Power of Attorney:** all optional.
### Step 8 — Upload company documents → **THIS IS WHERE THE PATH SPLITS**
The required documents depend **only on nationality** (NOT on operation type).
```
IF Ethiopian → upload:
• TIN Certificate
• Commercial License
• National ID
IF Foreign → upload:
• TIN Certificate
• Investment License
• National ID
• Passport
```
All are required (1 file each, pdf/jpg/png, ≤10 MB).
### Step 9 — Business license per profile
For **each** operation type you picked, upload that profile's business/trade license (1+ files each).
### After onboarding finishes
```
Company status → "Pending" (backoffice must approve)
Each profile status → "Pending"
Backoffice approves each profile one by one
→ profile status = "active", gets a reference (e.g. IM-00001 / EX-00001)
→ only then can that profile create contracts/bookings
```
**Branch summary**
| Nationality | Company documents required |
|-------------|----------------------------|
| Nationality | Company documents |
|-------------|-------------------|
| Ethiopian | TIN Certificate · Commercial License · National ID |
| Foreign | TIN Certificate · Investment License · National ID · Passport |
> Operation type changes **nothing** in the document set — only adds one business-license card per profile.
Operation type adds one business-license card per profile — nothing else.
---
## 2) Contract
> Goal: agree the terms (route, cargo, price) and sign. Only an **active profile** can do this.
> Agree terms (route, cargo, price), run the approval chain, sign. Service: `ContractTransitionService`.
### Create — the wizard (4 steps)
### Setup choices that decide later paths
```
Step 0 Setup operation direction (import/export/intercity),
contract kind (ONE_TIME vs GENERAL),
new vs renewal, service type, currency,
first/last mile, customs-clearing on/off, equipment return
Step 1 Cargo+Route container sizes OR bulk commodity, hazardous/reefer flags,
origin & destination yard, extra routes (GENERAL only)
Step 2 Documents required onboarding docs + any contract-specific uploads
Step 3 Review check everything, see quotation, submit (or save draft)
contractKind: ONE_TIME (one shipment per contract)
GENERAL (many shipments over a validity window)
tradeDirection: IMPORT | EXPORT | DOMESTIC
customsClearingEnabled: true → Path B (GL clears + books) — IMPORT/EXPORT
false → Path A (customer self-clears + books) — IMPORT/EXPORT
(DOMESTIC → no clearance at all)
```
Two key choices made here decide later paths:
### Status enum (`CONTRACT_STATUSES`)
```
contract kind: ONE_TIME (one shipment at a time)
GENERAL (ship many times over a validity window)
customs clearing: ENABLED → Path B (Global Logistics clears for you)
DISABLED → Path A (you self-clear) — for IMPORT/EXPORT
(DOMESTIC/intercity → no clearance at all)
DRAFT · SUBMITTED · PRICE_CHANGED_PENDING_CONFIRM · CHANGES_REQUESTED
PENDING_APPROVAL · APPROVED · APPROVED_PENDING_SIGNATURE · CONTRACT_READY
SIGNED_CUSTOMER · FULLY_EXECUTED · CONTRACT_ACTIVE
AWAITING_CLEARANCE_DOCUMENTS · CLEARANCE_UNDER_REVIEW · CLEARANCE_READY_FOR_BOOKING
ACTIVE_SHIPMENT_IN_PROGRESS · CONTRACT_CLOSED · EXPIRED
REJECTED · CANCELLED
RENEWAL_DRAFT · RENEWAL_SUBMITTED · RENEWAL_PENDING_APPROVAL · AMENDMENTS_PROPOSED · ARCHIVED
```
### Status journey (happy path)
Clearance enum (`CONTRACT_CLEARANCE_STATUSES`): `NOT_APPLICABLE · AWAITING_DOCUMENTS · DOCUMENTS_UNDER_REVIEW · CLEARANCE_READY_FOR_BOOKING · SELF_CLEARED · ACTIVE_SHIPMENT_IN_PROGRESS`
### Transition table
| Method | Guard (allowed status) | Result |
|--------|------------------------|--------|
| `submit()` | DRAFT, CHANGES_REQUESTED | freeze rates → `SUBMITTED` |
| `confirmSubmit()` | PRICE_CHANGED_PENDING_CONFIRM | freeze rates → `SUBMITTED` |
| `staffAccept(validityDays)` | SUBMITTED | set validity window, build approval chain → `PENDING_APPROVAL` |
| `requestChanges()` | SUBMITTED | review note → `CHANGES_REQUESTED` |
| `reject()` | SUBMITTED, PENDING_APPROVAL | → `REJECTED` |
| `approveStep(stepId, role)` | PENDING_APPROVAL, APPROVED_PENDING_SIGNATURE | complete step in order; all done → `APPROVED` |
| `generateContract()` | APPROVED, APPROVED_PENDING_SIGNATURE | render PDF → `CONTRACT_READY` |
| `sign(CUSTOMER)` | CONTRACT_READY | apply signature → `SIGNED_CUSTOMER` → auto `counterSign()` |
| `counterSign(STAFF/DIRECTOR/CEO)` | SIGNED_CUSTOMER | **branch on path** ↓ |
| `renew()` | any | clone with `renewalOfId`, version++ → `RENEWAL_DRAFT` |
**Approval chain** (`instantiateApprovalSteps`): `LINE_STAFF` → optional `DIRECTOR` → optional `CEO`. Director required when `freightType = BULK` OR `cargoType.requiresDirectorApproval`.
### The counter-sign branch — THIS is where the model differs from "clearance first"
```
DRAFT
→ SUBMITTED (customer submits; prices frozen)
→ PENDING_APPROVAL (staff accepts intake, sets validity window)
→ APPROVED (approval chain signs: LINE_STAFF → DIRECTOR → CEO)
→ CONTRACT_READY (staff generates the contract PDF)
→ SIGNED_CUSTOMER (customer signs)
→ counter-sign by staff/director/ceo … then it SPLITS ↓
IF GENERAL + customsClearingEnabled (Path B):
NO contract-level clearance cycle.
status → CONTRACT_ACTIVE, clearanceStatus → NOT_APPLICABLE
→ customer requests shipments; GL creates + clears each booking (per-booking)
IF ONE_TIME + customs (Path A self-clear OR one-time-customs):
open a contract clearance cycle
status → AWAITING_CLEARANCE_DOCUMENTS, clearanceStatus → AWAITING_DOCUMENTS
→ contract-level clearance loop (section 3), then booking
IF DOMESTIC (no customs):
status → FULLY_EXECUTED (ONE_TIME) or CONTRACT_ACTIVE (GENERAL)
→ customer books immediately (section 4)
```
### The counter-sign split → which path?
> So contract-level clearance (section 3) only runs for the **one-time + customs** case. The common GENERAL-customs case goes straight to `CONTRACT_ACTIVE` and defers all clearance to the booking (section 5).
### Side branches
```
IF customs clearing ENABLED (IMPORT/EXPORT) → PATH B
status → AWAITING_CLEARANCE_DOCUMENTS
IF customs clearing DISABLED (IMPORT/EXPORT) → PATH A (self-clear)
status → AWAITING_CLEARANCE_DOCUMENTS
IF DOMESTIC / intercity (no clearance) → NO CLEARANCE
status → CONTRACT_ACTIVE (GENERAL) or FULLY_EXECUTED (ONE_TIME)
→ customer can book a shipment right away (skip to section 4)
staff requestChanges → CHANGES_REQUESTED → customer edits → submit → SUBMITTED
staff reject → REJECTED
GENERAL contract → renew → RENEWAL_DRAFT (clone of prior version)
```
**Side branches at any review stage**
### Endpoints (`contracts.controller.ts`)
```
staff requests changes → CHANGES_REQUESTED → customer edits → SUBMITTED again
staff rejects → REJECTED
customer/staff cancels → CANCELLED
GENERAL contract later → renew → RENEWAL_DRAFT (copies the old contract)
POST /contracts/:id/submit submit()
POST /contracts/:id/confirm-submit confirmSubmit()
POST /contracts/:id/staff/accept staffAccept()
POST /contracts/:id/staff/request-changes requestChanges()
POST /contracts/:id/staff/reject reject()
POST /contracts/:id/approval-steps/:stepId/approve approveStep()
POST /contracts/:id/contract/generate generateContract()
GET /contracts/:id/contract/view view PDF/HTML
POST /contracts/:id/contract/sign sign()
POST /contracts/:id/renew renew()
GET /contracts/:id/capacity remaining drawdown (GENERAL)
POST /contracts/:id/bookings create booking under contract (section 4)
GET /contracts/list-summary list
GET /contracts/booking-requests/queue staff shipment-request queue
```
---
## 3) Clearance
## 3) Contract-level clearance (ONE_TIME + customs only)
> Only happens for IMPORT/EXPORT contracts. Two paths. The loop is the same idea:
> **customer uploads → reviewer approves or queries → customer re-uploads → … → finalize.**
> Runs only when the counter-sign branch opened a contract clearance cycle.
> Service: `ContractClearanceService`. Loop: **upload → review (approve/query) → re-upload → finalize.**
### Who reviews?
### Who reviews
```
PATH A (self-clear, customs DISABLED) → reviewed by OPERATIONS team
PATH B (customs, customs ENABLED) reviewed by GLOBAL LOGISTICS (GL)
Path A (customsClearingEnabled = false) → OPERATIONS
Path B (customsClearingEnabled = true) → GLOBAL LOGISTICS Ethiopia (GL ET)
```
### The status sub-states
### Document states & loop
```
AWAITING_CLEARANCE_DOCUMENTS customer must upload
CLEARANCE_UNDER_REVIEW reviewer is checking
CLEARANCE_READY_FOR_BOOKING (Path B) done — GL will make the booking
SELF_CLEARED (Path A) done — customer will make the booking
each document: PENDING → APPROVED (reviewer approves)
→ QUERIED (reviewer demands re-upload, note required)
→ contract back to AWAITING_CLEARANCE_DOCUMENTS
(only queried docs re-upload; approved stay)
1. customer uploads all required docs → CLEARANCE_UNDER_REVIEW, each doc PENDING
2. reviewer goes doc by doc (approve / query)
3. customer re-uploads queried docs → back to step 2
4. all required docs APPROVED → finalize
```
### The review loop (both paths)
### Required-doc resolution (`contract-clearance.util.ts`)
```
1. Customer uploads all required documents
→ status = CLEARANCE_UNDER_REVIEW
→ each document = PENDING
input (customer uploads):
Path B: contract_clearance_{import|export}_{container|bulk}
Path A: contract_clearance_selfclear_{import|export}_{container|bulk}
DOMESTIC: null (no gate)
2. Reviewer goes document by document:
APPROVE → that document = APPROVED
QUERY → that document = QUERIED (note required)
→ contract drops back to AWAITING_CLEARANCE_DOCUMENTS
(only the queried doc needs re-uploading; approved ones stay)
3. Customer re-uploads the queried document → back to step 2
4. When ALL required documents are APPROVED → finalize (below)
output (GL uploads, container customs only):
contract_clearance_output_{import|export}_container
(bulk or non-customs → null)
```
### PATH A — self-clear (Operations)
### Finalize
```
documents the CUSTOMER uploads (examples):
import: customs declaration (IM4/IM5), import release, duty/tax receipt,
delivery order, supporting doc
export: customs declaration (EX3/EX8), export release, transit (T1), supporting doc
no output documents in Path A.
Operations finalize (POST .../clearance/ops-finalize)
requires: every required doc APPROVED
opsFinalize() Path A — requires every required input doc APPROVED
→ clearanceStatus = SELF_CLEARED
→ contract status = CONTRACT_ACTIVE (GENERAL) or FULLY_EXECUTED (ONE_TIME)
→ CUSTOMER creates the booking (section 4)
```
→ contract status = CONTRACT_ACTIVE (GENERAL) | FULLY_EXECUTED (ONE_TIME)
→ CUSTOMER creates booking
### PATH B — customs (Global Logistics)
```
documents the CUSTOMER uploads (examples):
import container: commercial invoice, packing list, import license,
certificate of origin, freight cost, bill of lading,
VGM*, release order*
export container: booking confirmation, invoice, packing list,
shipping instruction, bank permit, export license,
VGM letter*, railway bill, delegation letter
(* = required)
then GL uploads OUTPUT documents (container only):
import: IM4 (required), IM5 (optional), transit screenshot
export: EX3 (required), EX8, export release, T1
GL finalize (POST .../clearance/finalize)
requires: every required customer doc APPROVED
AND every required output doc uploaded
finalize() Path B — requires every required input APPROVED + every required output uploaded
→ clearanceStatus = CLEARANCE_READY_FOR_BOOKING
→ GL (not the customer) creates the booking (section 4)
→ GL creates booking
```
### Cycles (GENERAL contracts)
### Endpoints
A **cycle** is one clearance round. ONE_TIME contracts have a single cycle (#1). GENERAL contracts open a new cycle each time they need clearance before the next shipment.
> Clearance (section 3) is **pre-booking**. After GL creates the booking, the work continues as **GL Phase 2** — see section 4b.
```
GET /contracts/:id/clearance document grid
POST /contracts/:id/clearance/documents customer upload (multipart)
POST /contracts/:id/clearance/review GL approve | query
POST /contracts/:id/clearance/output-documents GL upload output docs
POST /contracts/:id/clearance/finalize GL finalize → CLEARANCE_READY_FOR_BOOKING
POST /contracts/:id/clearance/ops-review Ops approve | query
POST /contracts/:id/clearance/ops-finalize Ops finalize → SELF_CLEARED
GET /contracts/clearance/queue GL ET queue (customs contracts)
GET /contracts/clearance/ops-queue Operations queue (self-clear)
GET /contracts/clearance/history GL completed
GET /contracts/clearance/ops-history Ops completed
```
---
## 4) Booking
## 4) Booking — who creates it & the gate
> Goal: turn a cleared/executed contract into an actual shipment. **Who creates it depends on the path.**
> Turn a ready contract into a shipment. Service: `ContractBookingService` (creation gate), `BookingTransitionService` (lifecycle).
> Endpoint: `POST /contracts/:id/bookings`.
### The gate (`assertGate`)
```
PATH A / DOMESTIC → the CUSTOMER creates the booking
PATH B (customs) → GLOBAL LOGISTICS creates the booking on the customer's behalf
Customs (Path B) — only GL ET (needs contracts.createBooking permission):
ONE_TIME : contract.clearanceStatus = CLEARANCE_READY_FOR_BOOKING
GENERAL : contract.status = CONTRACT_ACTIVE
booking starts in AWAITING_DOCUMENTS (per-booking clearance), createdByRole = GL_ET
No customs (Path A / DOMESTIC) — customer or staff:
contract.status = FULLY_EXECUTED or CONTRACT_ACTIVE
booking starts in OPERATION_REQUEST_PENDING (no clearance gate),
createdByRole = CUSTOMER | STAFF
```
### The gate (who's allowed)
So per-booking clearance applies to **every customs booking** — both the GENERAL drawdown and the one-time case (whose contract cycle already ran). Path A / domestic bookings skip straight to the operation request.
### Booking status enum (`BOOKING_STATUSES`)
```
IF contract has customs clearing (Path B):
only GL, and only when clearanceStatus = CLEARANCE_READY_FOR_BOOKING
IF no customs (Path A / DOMESTIC):
customer (or staff), and only when contract is FULLY_EXECUTED / CONTRACT_ACTIVE
DRAFT · SUBMITTED · PRICE_CHANGED_PENDING_CONFIRM · CHANGES_REQUESTED
PENDING_APPROVAL · APPROVED_PENDING_SIGNATURE · APPROVED · CONTRACT_READY
SIGNED_CUSTOMER · FULLY_EXECUTED
AWAITING_DOCUMENTS · DOCUMENTS_UNDER_REVIEW · CLEARANCE_READY
OPERATION_REQUEST_PENDING · OPERATION_CHANGES_REQUESTED
SELECTED_FOR_BATCH · ROAD_DISPATCH_PENDING · PAID · IN_TRANSIT · COMPLETED
REJECTED · CANCELLED · EXPIRED · PENDING_CONSOLIDATION · CONSOLIDATED
```
### Booking wizard (customer self-booking — 7 steps)
Payment (`paymentStatus`): `PENDING → PNR_GENERATED → VERIFICATION_IN_PROGRESS → PAID` (or `FAILED`).
Scheduling (`schedulingStatus`): `NOT_SCHEDULED · HOLDING · ELIGIBLE · SCHEDULED · DISPATCHED`.
```
0 Operation type import / export / intercity (+ FF variants)
1 Contract type ONE_TIME vs GENERAL ; new vs renewal
2 Service & mile service type, currency (USD/ETB), first/last mile,
equipment return, customs agent / customs on-off
3 Cargo details container list (type, qty, VGM) OR bulk weight,
hazardous / refrigerated flags
4 Route origin & destination yard;
scheduledDate REQUIRED for ONE_TIME (estimate only),
NOT set for GENERAL (a day is chosen later)
5 Documents per-booking document uploads
6 Review notes, submit
```
### Booking status journey
### Customer-self-booking lifecycle (Path A / domestic — same approval shape as a contract)
```
DRAFT
→ generate price → SUBMITTED (if price changed: PRICE_CHANGED_PENDING_CONFIRM → confirm → SUBMITTED)
→ PENDING_APPROVAL (staff accept intake)
→ APPROVED / CONTRACT_READY (approval chain)
SIGNED_CUSTOMER → counter-sign … SPLIT ↓
IF clearance applies → AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY
IF no clearance → FULLY_EXECUTED directly
→ generate-price → submit
price unchanged → SUBMITTED
price changed → PRICE_CHANGED_PENDING_CONFIRM → confirm-submit → SUBMITTED
acceptIntake (staff) → PENDING_APPROVAL (validity window + approval steps)
→ approveStep ×N (LINE_STAFF → DIRECTOR → CEO) → APPROVED (auto-generates contract)
→ CONTRACT_READY → customerSign → SIGNED_CUSTOMER
→ marketingApprove → FULLY_EXECUTED (sets fullyExecutedAt, lockedAt)
→ (then operation request, section 5)
```
> The booking has its **own** document clearance loop, mirroring the contract one
> (upload → APPROVED/QUERIED → re-upload → finalize). Re-uploading a queried doc
> resets it to PENDING. Booking proceeds only when all required docs are APPROVED.
Side branches: `requestChanges → CHANGES_REQUESTED`; `staffReject / reject → REJECTED`; `cancel → CANCELLED` (DRAFT…CONTRACT_READY).
### Pricing & payment
### Transition methods (`BookingTransitionService`)
```
price generated from rule engine + live rates, converted to chosen currency (USD/ETB)
customer pays (Telebirr) once the booking is FULLY_EXECUTED / SELECTED_FOR_BATCH
payment status: PENDING → VERIFICATION_IN_PROGRESS → PAID (or FAILED)
submit · confirmSubmit · requestChanges · acceptIntake · staffReject · reject
approveStep · rejectStep · generateContract · customerSign · marketingApprove
governmentExpedite (govt fast-path → PAID)
requestOperation · proceedToOperation · reviewOperationRequest
submitClearanceDocuments · reviewDocument · uploadClearanceOutputDocuments · finalizeClearance
startTransit · complete · cancel · requestConsolidation · removeConsolidation
```
---
## 4b) Global Logistics — Phase 2 (after the booking exists)
## 5) Per-booking clearance + operation request
> Customs (Path B) shipments keep moving through GL after booking. This phase is
> a **milestone timeline** plus a set of **structured GL actions**. The customer
> only watches and, when asked, pays / uploads a duty slip.
> The customs booking's own document loop, then everyone funnels into the operation request that puts the shipment in the schedule pool.
### Milestone timeline
When GL creates the booking, the system seeds the **post-booking milestones**
for that direction (import ~15, export ~11). Each is `PENDING → COMPLETED`.
### Per-booking clearance (customs bookings only)
```
Import (post-booking): WAGON_REQUESTED → FREIGHT_PAYMENT_SETTLED → WAGON_ALLOCATED
→ GATEPASS_GRANTED → READY_FOR_LOADING → LOADED → DEPARTED_FROM_DJIBOUTI
→ ARRIVED_ETHIOPIA → OFFLOADED → T1_CLOSED → RISK_ASSIGNED
→ IMPORT_RELEASE_GRANTED → IMPORT_PROCESS_COMPLETED
→ STORAGE_INVOICE_RAISED → EXIT_NOTE_GENERATED
Export (post-booking): WAGON_REQUESTED → FREIGHT_PAYMENT_PENDING → FREIGHT_PAYMENT_SETTLED
→ WAGON_ALLOCATED → CARGO_ARRIVED → READY_FOR_LOADING → LOADED
→ DEPARTED_TO_DJIBOUTI → ARRIVED_AT_DJIBOUTI → GATEPASS_GRANTED → OFFLOADED
GL creates booking → AWAITING_DOCUMENTS
submitClearanceDocuments (customer upload) → DOCUMENTS_UNDER_REVIEW
reviewDocument (GL) → doc APPROVED | QUERIED (note)
uploadClearanceOutputDocuments (GL, customs output)
finalizeClearance requires 100% required inputs APPROVED + required outputs present
→ CLEARANCE_READY
proceedToOperation → OPERATION_REQUEST_PENDING
```
Each milestone has an **owner**: ET (GL Ethiopia), DJ (GL Djibouti), OPS (Operations),
CUST (customer). Backoffice shows the timeline with a **Complete** button on the
next pending step; the customer portal shows the same timeline **read-only**.
inputCode / outputCode resolve from `(tradeDirection, freightType, customsClearingEnabled)` — same scheme as the contract loop. Doc review status: `PENDING · APPROVED · QUERIED`.
### GL actions (the structured part)
Plain "Complete" covers most steps. These carry extra data, so they have their
own UI cards on the backoffice **milestones page** (`GlActionsPanel`):
### Operation request (all paths)
```
Station routing → route shipment to a station yard (+ bind GL staff) (GL US-02)
Customs risk → assign GREEN / YELLOW / RED → completes RISK_ASSIGNED
Duty & tax → GL advises amount + declaration serial → completes
DUTY_TAXES_ADVISED → customer uploads slip → DUTY_TAX_PAID
GL documents → upload DO / RO / T1 / import release / interchange /
final declaration → auto-completes the matching milestone
Cargo exception → log SEAL_BROKEN / CONTAINER_OPENED / CONTAINER_DAMAGED /
FLUID_LEAKING with photos → alert GL Ethiopia (GL US-07)
requestOperation() guard CLEARANCE_READY | OPERATION_CHANGES_REQUESTED, valid schedule day
→ OPERATION_REQUEST_PENDING
(Path A / domestic bookings begin life here directly)
reviewOperationRequest(decision):
ACCEPT → acceptOperationRequest():
train service → FULLY_EXECUTED + enqueue batch fill (origin, dest, day)
truck service → ROAD_DISPATCH_PENDING (skips train pool, section 7)
REQUEST_CHANGES → OPERATION_CHANGES_REQUESTED (note; customer resubmits)
```
**Doc-triggered milestones:** uploading the mapped document completes the
milestone automatically — no separate click:
### Clearance / operation endpoints (`bookings.controller.ts`)
| Upload (code) | Completes milestone | Who |
|---------------|---------------------|-----|
```
GET /bookings/:id/clearance docs grid + GL review state
POST /bookings/:id/clearance/documents submitClearanceDocuments → DOCUMENTS_UNDER_REVIEW
POST /bookings/:id/clearance/review reviewClearanceDocument (approve | query)
POST /bookings/:id/clearance/output-documents GL upload output
POST /bookings/:id/clearance/finalize finalizeClearance → CLEARANCE_READY
POST /bookings/:id/clearance/proceed proceedToOperation → OPERATION_REQUEST_PENDING
POST /bookings/:id/operation/review reviewOperationRequest (ACCEPT | REQUEST_CHANGES)
```
Booking lifecycle endpoints (selected):
```
POST /bookings · PATCH /bookings/:id · DELETE /bookings/:id (DRAFT only)
POST /bookings/:id/generate-price · /submit · /confirm-submit · /reject
POST /bookings/:id/staff/accept · /staff/request-changes · /staff/reject
POST /bookings/:id/approval-steps/:stepId/approve · /reject
POST /bookings/:id/contract/generate · /contract/sign · GET /contract/view
POST /bookings/:id/marketing/approve → FULLY_EXECUTED
POST /bookings/:id/government-expedite govt → PAID
POST /bookings/:id/operations/start-transit → IN_TRANSIT
POST /bookings/:id/operations/complete → COMPLETED
POST /bookings/:id/cancel → CANCELLED
GET /bookings/my customer payable list
GET /bookings/queues/:queue intake | approval | signatures | marketing | finance
GET /bookings/:id/tracking shipment tracking
POST /bookings/:id/consolidation · DELETE · GET pair partial-wagon bookings
```
---
## 6) GL Phase 2 — post-booking milestones (customs / Path B)
> Once GL creates a customs booking, the system seeds the **post-booking milestone timeline**.
> Services: `ClearanceMilestoneService` (seed/advance), `GlOperationsService` (structured actions).
> `seedPostBookingMilestones(booking)` runs at GL booking creation. Each milestone: `PENDING → COMPLETED` (or `SKIPPED`).
### Milestone owners
`ET` (GL Ethiopia) · `DJ` (GL Djibouti) · `OPS` (Operations) · `CUST` (Customer)
### Import timeline (catalog order, post-booking segment)
```
WAGON_REQUESTED → FREIGHT_PAYMENT_SETTLED → WAGON_ALLOCATED → GATEPASS_GRANTED
→ READY_FOR_LOADING → LOADED → DEPARTED_FROM_DJIBOUTI [HANDOFF ET↔DJ]
→ ARRIVED_ETHIOPIA → OFFLOADED → T1_CLOSED → RISK_ASSIGNED
→ IMPORT_RELEASE_GRANTED → IMPORT_PROCESS_COMPLETED
→ STORAGE_INVOICE_RAISED → EXIT_NOTE_GENERATED
```
(Pre-booking import milestones — `IMPORT_DOCS_UPLOADED · PENDING_DOCUMENT_REVIEW · DOCUMENTS_APPROVED · UNDER_CUSTOMS_CLEARANCE · DECLARED · DUTY_TAXES_ADVISED · DUTY_TAX_PAID · DO_COLLECTED` — track the clearance loop and end at `DO_COLLECTED`.)
### Export timeline (post-booking segment)
```
WAGON_REQUESTED → FREIGHT_PAYMENT_PENDING → FREIGHT_PAYMENT_SETTLED → WAGON_ALLOCATED
→ CARGO_ARRIVED → READY_FOR_LOADING → LOADED → DEPARTED_TO_DJIBOUTI [HANDOFF]
→ ARRIVED_AT_DJIBOUTI → GATEPASS_GRANTED → OFFLOADED
```
(Pre-booking export: `EXPORT_DOCS_UPLOADED · PENDING_DOCUMENT_REVIEW · DOCUMENTS_APPROVED · RELEASE_ORDER_SECURED · UNDER_CUSTOMS_CLEARANCE · DECLARED · EXPORT_RELEASED`.)
### Advance logic
```
completeForBooking(bookingId, code, userId?, note?) mark COMPLETED + triggeredAt/By
completeForContract(contractId, code, …) pre-booking contract milestones
completeByDocTrigger(scope, code) auto-complete from a doc upload
onHandoff(bookingId, code) fires on DEPARTED_* (notification reserved)
```
### Structured GL actions (`GlOperationsService` + `gl-operations.dto.ts`)
```
assignRisk riskLevel = GREEN | YELLOW | RED → completes RISK_ASSIGNED
adviseDuty {amount, currency, declarationSerial?} → completes DUTY_TAXES_ADVISED
customer uploads slip → DUTY_TAX_PAID
assignStation {stationYardId, staffId?} sets glStationYardId/glAssignedStaffId (GL US-02)
reportIncident incidentType = SEAL_BROKEN | CONTAINER_OPENED | CONTAINER_DAMAGED | FLUID_LEAKING
+ description + photos → ClearanceIncident
uploadDocuments / uploadDutySlip → doc-triggered milestone auto-complete
```
### Doc-triggered milestones (`DOC_CODE_TO_MILESTONE`)
| Upload (code) | Completes | Owner |
|---------------|-----------|-------|
| `delivery_order` | DO_COLLECTED | GL DJ |
| `release_order` | RELEASE_ORDER_SECURED | GL DJ |
| `t1_transport_document` | T1_CLOSED | GL ET |
@@ -356,86 +395,99 @@ milestone automatically — no separate click:
| `full_in_interchange` | OFFLOADED | GL DJ |
| `final_declaration` | IMPORT_PROCESS_COMPLETED | GL ET |
| `duty_tax_receipt` | DUTY_TAX_PAID | **Customer** |
| `incident_photo` | (logging only, no milestone) | — |
### ET ↔ DJ handoff
### Endpoints
```
DEPARTED_FROM_DJIBOUTI (import) → lead returns to GL Ethiopia + Operations
DEPARTED_TO_DJIBOUTI (export) → lead moves to GL Djibouti
```
Ownership region is encoded per-milestone in the catalog; notifications fire on
handoff (notification module pending).
### What the customer does in Phase 2
```
watch the timeline (read-only)
pay duty/tax → upload payment slip (only when GL advised it)
pay freight → Pay button on the booking when batch-selected
that's all — every other step is GL / Ops / Terminal
POST /contracts/bookings/:bookingId/milestones/:code/complete manual complete
POST /contracts/:id/milestones/:code/complete pre-booking contract milestone
POST /contracts/bookings/:bookingId/risk assignRisk
POST /contracts/bookings/:bookingId/duty adviseDuty
POST /contracts/bookings/:bookingId/station-assign assignStation
POST /contracts/bookings/:bookingId/documents GL doc upload (DO/RO/T1/…)
POST /contracts/bookings/:bookingId/duty-slip customer duty slip
GET /contracts/bookings/:bookingId/incidents list
POST /contracts/bookings/:bookingId/incidents reportIncident
GET /contracts/bookings/:bookingId/milestones timeline
```
### Where it lives (Phase 2)
| Area | Files |
|------|-------|
| Milestone seed/advance | `api/.../contracts/clearance-milestone.service.ts`, `clearance-milestone.catalog.ts` |
| GL actions (risk/duty/station/docs/incident) | `api/.../contracts/gl-operations.service.ts`, `dto/gl-operations.dto.ts`, `entities/clearance-incident.entity.ts` |
| Endpoints | `api/.../contracts/contracts.controller.ts` (`bookings/:id/risk` · `/duty` · `/station-assign` · `/documents` · `/incidents` · `/duty-slip`) |
| Backoffice UI | `backoffice/.../pages/contracts/BookingMilestonesPage.tsx`, `components/contracts/ClearanceMilestoneTimeline.tsx`, `components/contracts/gl-actions/*` |
| Portal UI | `portal/.../bookings/BookingDetailPage/components/ShipmentTrackingCard.tsx` |
### Still out of scope (per design doc §18)
Demurrage auto-calc & storage invoicing, finance AP closure, multimodal
(sea/air + MTO/OBL/HBL), truck waybill PDF + POD signing. `STORAGE_INVOICE_RAISED`
and `EXIT_NOTE_GENERATED` exist as **manual milestones** only — no fee engine yet.
> Still manual-only (no fee engine): `STORAGE_INVOICE_RAISED`, `EXIT_NOTE_GENERATED`. Out of scope: demurrage auto-calc, finance AP closure, multimodal, truck waybill/POD.
---
## 5) Schedule (Operations)
## 7) Schedule — demand batching (`booking-batch.service.ts`)
> Goal: put the booking on a train (or dispatch by road). Day-level pooling — the
> customer picks a **day**, the batch engine assigns the actual **train** later.
> Day-level pooling. Customer picks a **day**; the batch engine assigns the actual **train** later.
> Cron groups bookings by `(origin yard, destination yard, day)`. EAT timezone, 3-hour windows (0003 … 2124).
### Pool states (`boardState`, read-only view)
```
1. Customer requests operation pick a day that has an OPEN departure
→ OPERATION_REQUEST_PENDING
READY FULLY_EXECUTED + fullyExecutedAt, no train link yet
SELECTED_FOR_BATCH picked by fill, in pay window (trainScheduleId set, paymentDeadline set)
ALLOCATED linked to train via TrainScheduleBooking, PAID (or govt)
WAITING PAID but not yet linked (staff-reconciled)
EXPIRED failed to pay in window
PENDING_CONTRACT any other non-terminal state
```
2. Operations review the request → one of:
ACCEPT → FULLY_EXECUTED (enters the train batch pool)
(road service instead → ROAD_DISPATCH_PENDING, section 6)
REQUEST_CHANGES → OPERATION_CHANGES_REQUESTED (note required; customer resubmits)
ADJUST_PRICE → OPERATION_PRICE_PENDING_CONFIRM
(customer confirms new price → pool, or rejects → changes requested)
### The cron cycle (every 3h; prod `0 */3 * * *`)
3. Batch engine (cron) groups bookings by (origin yard, destination yard, day):
allocates to open train schedules by priority score
→ SELECTED_FOR_BATCH, assigns trainScheduleId, sets payment deadline
```
1. Fill distribute (route, dest, day) pool across OPEN schedules by priorityScore
pick earliest train; fit bookings (govt preempts commercial)
commercial → reserve: SELECTED_FOR_BATCH + paymentDeadline = now + 1h
govt → allocate: PAID, SCHEDULED
2. Settle 1h after window closes — allocate paid reservations,
expire unpaid (→ EXPIRED, unpin train), top up from waiting list
3. Reconcile link orphaned PAID bookings to a schedule
4. Allocate auto-assign wagon slots to allocated bookings
```
4. Payment (if not already paid) → PAID
`reserve()` → SELECTED_FOR_BATCH. `allocate()` → create `TrainScheduleBooking`, PAID, schedulingStatus SCHEDULED. `expire()` → trainScheduleId null, EXPIRED, schedulingStatus ELIGIBLE (back in pool).
5. IN_TRANSIT → COMPLETED
### Payment → transit
```
SELECTED_FOR_BATCH / AWAITING_PAYMENT → pay (Telebirr) → PAID
PAID → startTransit → IN_TRANSIT → complete → COMPLETED
```
### Wagon math
```
wagons per booking = sum over containers of (qty × wagonsPerUnit), rounded up
wagonsRequired = ⌈ Σ over containers (qty × wagonsPerUnit)
```
### Key endpoints (`train-scheduling.controller.ts`)
```
GET /train-scheduling/available-days days with OPEN departures
GET /train-scheduling/available-days-for-cargo capacity-aware bookable days
GET /train-scheduling/bookable-schedules OPEN same-route schedules
GET /train-scheduling/batch-board monitoring board (states + counts)
GET /train-scheduling/eligible-bookings PAID/FULLY_EXECUTED ready to allocate
POST /train-scheduling/{container|bulk}/schedules create schedule
POST /train-scheduling/schedules/:id/assign-bookings
POST /train-scheduling/schedules/:id/run-batch staff manual fill
POST /train-scheduling/schedules/:id/run-allocation staff manual wagon allocation
POST /train-scheduling/schedules/:id/finalize · /dispatch
PATCH /train-scheduling/schedules/:id/booking-window OPEN | CLOSE
POST /train-scheduling/bookings/:bookingId/mark-paid · /expire · /move-schedule
GET /train-scheduling/schedules/:id/checkpoints · POST /checkpoints tracking events
```
---
## 6) Delivery / Last mile
## 8) Delivery / last mile
```
IF road service:
ACCEPT → ROAD_DISPATCH_PENDING (skips the train pool)
billed by KM, dispatched by truck (First-Mile operations)
Road service: reviewOperationRequest ACCEPT → ROAD_DISPATCH_PENDING (skips train pool)
billed by KM, dispatched by truck (First-Mile operations)
IF first/last mile chosen at booking:
pickup + delivery addresses captured; equipment return = WITH / WITHOUT
last-mile statuses: PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT
First/last mile: pickup + delivery addresses captured at booking; equipment return WITH | WITHOUT
last-mile: PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT
```
---
@@ -444,47 +496,47 @@ IF first/last mile chosen at booking:
```
ONBOARD
nationality ─┬─ Ethiopian → TIN + Commercial License + National ID
└─ Foreign → TIN + Investment License + National ID + Passport
pick profiles (importer/exporter/FF) → upload license per profile
→ backoffice approves profile → profile ACTIVE
profile (importer/exporter/FF) approved → ACTIVE → can transact
CONTRACT
wizard (setup → cargo+route → docs → review) → SUBMITTED
→ staff accept → approval chain → CONTRACT_READY → customer sign → counter-sign
→ SPLIT:
customs ENABLED (import/export) → PATH B clearance
customs DISABLED (import/export) → PATH A clearance
DOMESTIC → no clearance, ready to book
CONTRACT (ContractTransitionService)
submit → staffAccept → approveStep×N (LINE_STAFF→DIRECTOR→CEO) → CONTRACT_READY
→ sign(CUSTOMER) → counterSign → BRANCH:
GENERAL + customs → CONTRACT_ACTIVE (NOT_APPLICABLE) — clearance deferred to booking
ONE_TIME + customs → AWAITING_CLEARANCE_DOCUMENTS — contract clearance cycle (section 3)
DOMESTIC / no customs → FULLY_EXECUTED | CONTRACT_ACTIVE — book now
CLEARANCE (import/export only) loop: upload → approve/query → re-upload → finalize
PATH A: Operations review → SELF_CLEARED → CUSTOMER books
PATH B: GL review + GL output docs → READY_FOR_BOOKING → GL books
BOOKING (POST /contracts/:id/bookings, gate in ContractBookingService)
customs (Path B) → only GL → starts AWAITING_DOCUMENTS
no customs (Path A/domestic) → customer/staff → starts OPERATION_REQUEST_PENDING
BOOKING
created by CUSTOMER (Path A / domestic) or GL (Path B)
price → pay → (its own doc clearance if applicable) → ready to schedule
PER-BOOKING CLEARANCE (customs only, BookingTransitionService)
upload → review(approve/query) → finalize → CLEARANCE_READY → proceed → OPERATION_REQUEST_PENDING
GL PHASE 2 (customs/Path B, after booking)
milestone timeline: wagon → pay → allocate → load → depart → handover
OPERATION REQUEST (all paths)
requestOperation → OPERATION_REQUEST_PENDING
reviewOperationRequest ACCEPT → FULLY_EXECUTED (train) | ROAD_DISPATCH_PENDING (truck)
GL PHASE 2 (customs, post-booking) — ClearanceMilestoneService + GlOperationsService
milestone timeline: wagon → pay → allocate → load → depart [handoff]
→ arrive → offload → T1 close → risk → release → complete
GL actions: station routing · risk (G/Y/R) · duty advise · DO/RO/T1 upload · incident
GL actions: risk (G/Y/R) · duty advise · station assign · DO/RO/T1 upload · incident
customer: watch read-only · upload duty slip · pay freight
SCHEDULE
request a day → Operations accept → batch engine → train assigned
pay → IN_TRANSIT → COMPLETED
(road service → ROAD_DISPATCH_PENDING → truck)
SCHEDULE (booking-batch.service.ts, 3h EAT cron)
fill (route,dest,day)SELECTED_FOR_BATCH (+1h pay) → pay → PAID/ALLOCATED
→ IN_TRANSIT → COMPLETED (road → ROAD_DISPATCH_PENDING → truck)
```
---
### Where this lives in the code (quick map)
### Code map
| Area | Key files |
|------|-----------|
| Onboarding | `portal/.../components/onboarding/OnboardingWizardDialog.tsx`, `api/.../companies/companies.service.ts`, `api/src/seed/file-upload-settings.seeder.ts` |
| Contract | `portal/.../contracts/new-contract-form/`, `api/.../contracts/contract-transition.service.ts`, `entities/contract.entity.ts` |
| Clearance | `api/.../contracts/contract-clearance.service.ts`, `contract-clearance.util.ts`, `portal/.../contracts/ContractClearancePanel.tsx` |
| Booking | `portal/.../bookings/new-booking-form/`, `api/.../bookings/booking-transition.service.ts`, `contract-booking.service.ts` |
| Schedule | `api/.../train-scheduling/booking-batch.service.ts`, `backoffice/.../operations/FirstMilePage.tsx` |
| Area | Key files (`apps/edr-freight-api/src/modules/…`) |
|------|---------------------------------------------------|
| Contract state machine | `contracts/contract-transition.service.ts`, `contracts/entities/contract.entity.ts`, `contracts/contracts.controller.ts` |
| Contract clearance | `contracts/contract-clearance.service.ts`, `contracts/contract-clearance.util.ts` |
| Booking gate | `contracts/contract-booking.service.ts` |
| Booking lifecycle + per-booking clearance | `bookings/booking-transition.service.ts`, `bookings/entities/booking.entity.ts`, `bookings/bookings.controller.ts` |
| GL Phase 2 | `contracts/clearance-milestone.service.ts`, `contracts/clearance-milestone.catalog.ts`, `contracts/gl-operations.service.ts`, `contracts/dto/gl-operations.dto.ts`, `contracts/entities/clearance-incident.entity.ts` |
| Schedule / batch engine | `train-scheduling/booking-batch.service.ts`, `train-scheduling/train-scheduling.service.ts`, `train-scheduling/train-scheduling.controller.ts` |
```

View File

@@ -570,6 +570,74 @@ export interface CreateBookingUnderContractDto {
notes?: string;
}
// ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────────
// On a GENERAL customs contract the customer cannot book directly. They submit a
// shipment request (date + quantities); Global Logistics reviews it, then creates
// the booking on their behalf and per-booking clearance begins.
export const BOOKING_REQUEST_STATUSES = [
"PENDING",
"ACCEPTED",
"REJECTED",
"CANCELLED",
] as const;
export type BookingRequestStatus = (typeof BOOKING_REQUEST_STATUSES)[number];
/** Requested quantities — container lines OR a single bulk line (no per-unit data). */
export interface RequestedShipmentLines {
containers?: Array<{
containerSize: string;
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
}>;
bulk?: {
cargoTypeId?: string | null;
cargoWeightTons?: number;
itemCount?: number;
hazardousQuantity?: number;
};
}
export interface IBookingRequest extends BaseEntity {
reference: string;
contractId: string;
requestedByUserId?: string | null;
contractRouteId?: string | null;
/** Customer's preferred shipment day — informational; GL sets the binding date. */
scheduledDate?: string | null;
status: BookingRequestStatus;
requestedLines: RequestedShipmentLines;
notes?: string | null;
/** Set when GL accepts and creates the booking. */
createdBookingId?: string | null;
reviewedByStaffId?: string | null;
reviewedAt?: string | null;
reviewNote?: string | null;
}
export interface CreateBookingRequestDto {
contractRouteId?: string;
scheduledDate?: string;
containers?: Array<{
containerSize: string;
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
}>;
bulk?: {
cargoTypeId?: string | null;
cargoWeightTons?: number;
itemCount?: number;
hazardousQuantity?: number;
};
notes?: string;
}
export interface ReviewBookingRequestDto {
note?: string;
}
// ── Clearance review / finalize DTOs (Path B) ───────────────────────────────
export interface ReviewContractClearanceDocumentDto {

View File

@@ -592,6 +592,23 @@ export interface AvailableDaysQuery {
destinationYardId?: string;
}
/**
* Cargo-aware availability query: beyond the route yards it carries the cargo
* sizing so the server only returns days where a train has remaining capacity
* AND enough matching-type wagons. Response reuses {@link AvailableDaysResponse}.
*/
export interface AvailableDaysForCargoQuery {
originYardId?: string;
destinationYardId?: string;
freightType: "CONTAINER" | "BULK";
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
cargoTypeCode?: string;
/** Total bulk weight in tons. */
totalWeightTons?: number;
/** Container lines (size + quantity) for container freight. */
containers?: { containerSize: string; quantity: number }[];
}
/**
* Day-level booking pool: the EAT calendar days that have at least one OPEN
* departure on a route. `days` are `yyyy-MM-dd` strings, e.g. `["2026-06-20"]`.

View File

@@ -0,0 +1,248 @@
import { Box, Button, Group, Text } from "@mantine/core";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
export interface OperationDatePickerProps {
/** Selectable days as `yyyy-MM-dd` strings. */
availableDays: string[];
/** Show the loading state instead of the grid. */
isLoading?: boolean;
/** Currently selected day as `yyyy-MM-dd`, or "" when none. */
value: string;
/** Called with the picked `yyyy-MM-dd` day. */
onChange: (date: string) => void;
}
/** `yyyy-MM-dd` for a local date. */
function fmtDay(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/**
* Presentational month calendar for picking a binding shipment day. Only the
* `availableDays` (passed in by the caller, which owns the query) are
* selectable; every other day is disabled. Framework-light: no data fetching,
* no date library — both the portal and backoffice feed it their own
* availability results so the picker renders identically in each app.
*/
export function OperationDatePicker({
availableDays,
isLoading = false,
value,
onChange,
}: OperationDatePickerProps) {
// First-of-month for the visible month; defaults to the current month.
const [month, setMonth] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
// Monday-first grid: JS getDay() Sun=0..Sat=6 → shift so Mon=0.
const lead = (first.getDay() + 6) % 7;
const start = new Date(first);
start.setDate(first.getDate() - lead);
const today = new Date();
const todayStr = fmtDay(today);
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(start);
date.setDate(start.getDate() + i);
const dateString = fmtDay(date);
return {
dateString,
day: date.getDate(),
inMonth: date.getMonth() === month.getMonth(),
today: dateString === todayStr,
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
const shiftMonth = (delta: number) =>
setMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => shiftMonth(-1)}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{MONTH_NAMES[month.getMonth()]} {month.getFullYear()}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => shiftMonth(1)}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected:{" "}
{new Date(value + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})}
</Text>
)}
{departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}
export default OperationDatePicker;

View File

@@ -0,0 +1,5 @@
export {
OperationDatePicker,
default,
} from "./OperationDatePicker";
export type { OperationDatePickerProps } from "./OperationDatePicker";

View File

@@ -20,6 +20,9 @@ export type {
ViewableFile,
} from "./components/FileViewer";
export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
export { Badge } from "./components/badge";
// export type { BadgeProps } from "./components/badge";