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