mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
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 train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
|
||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — operation review', () => {
|
describe('BookingTransitionService — operation review', () => {
|
||||||
function makeService(serviceTypeCode: string) {
|
function makeService(serviceTypeCode: string) {
|
||||||
@@ -78,18 +77,4 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
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);
|
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) ───────────────────────────
|
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -861,17 +837,17 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Operations team reviews a pending operation request (capacity, documents,
|
* Operations team reviews a pending operation request (capacity, documents,
|
||||||
* route). Three outcomes:
|
* route). Two outcomes:
|
||||||
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
||||||
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
* - 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(
|
async reviewOperationRequest(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
|
decision: 'ACCEPT' | 'REQUEST_CHANGES',
|
||||||
actorId: string,
|
actorId: string,
|
||||||
options: { note?: string; amount?: number } = {},
|
options: { note?: string } = {},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
||||||
@@ -894,48 +870,10 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(bookingId);
|
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.
|
// ACCEPT — enter the batch holding pool.
|
||||||
return this.acceptOperationRequest(booking);
|
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.
|
* 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 { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
AcceptIntakeDto,
|
AcceptIntakeDto,
|
||||||
AdjustPriceDto,
|
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
RejectBookingDto,
|
RejectBookingDto,
|
||||||
@@ -52,7 +51,6 @@ import {
|
|||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
RequestOperationDto,
|
RequestOperationDto,
|
||||||
OperationReviewDto,
|
OperationReviewDto,
|
||||||
ConfirmOperationPriceDto,
|
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
@@ -404,24 +402,7 @@ export class BookingsController {
|
|||||||
id,
|
id,
|
||||||
dto.decision,
|
dto.decision,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
{ note: dto.note, amount: dto.amount },
|
{ note: dto.note },
|
||||||
);
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -521,25 +502,6 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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')
|
@Post(':id/government-expedite')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||||
|
|||||||
@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
outputCode: string | null;
|
outputCode: string | null;
|
||||||
includesCustoms: boolean;
|
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 {
|
return {
|
||||||
inputCode: clearanceSettingCode(
|
inputCode: clearanceSettingCode(
|
||||||
booking.tradeDirection,
|
booking.tradeDirection,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Max,
|
Max,
|
||||||
@@ -70,22 +68,6 @@ export class RejectBookingDto {
|
|||||||
reason?: string;
|
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 {
|
export class ReviewDocumentDto {
|
||||||
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -117,12 +99,12 @@ export class OperationReviewDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
|
'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 ' +
|
'returns it to the customer with a note. The booking price is computed ' +
|
||||||
'customer must re-confirm before it proceeds.',
|
'from the contract and cannot be adjusted by staff.',
|
||||||
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
|
enum: ['ACCEPT', 'REQUEST_CHANGES'],
|
||||||
})
|
})
|
||||||
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
|
@IsIn(['ACCEPT', 'REQUEST_CHANGES'])
|
||||||
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
|
decision!: 'ACCEPT' | 'REQUEST_CHANGES';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
|
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
|
||||||
@@ -130,22 +112,4 @@ export class OperationReviewDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
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 reference = await this.generateReference();
|
||||||
const freightType = contract.freightType;
|
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.
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
@@ -105,7 +111,7 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -165,9 +171,11 @@ export class ContractBookingService {
|
|||||||
warnings.push(...computed.warnings);
|
warnings.push(...computed.warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path B side effects: link the clearance cycle, seed post-booking
|
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||||
// milestones onto the booking, and advance the contract.
|
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||||
if (contract.customsClearingEnabled) {
|
// 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);
|
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||||
@@ -180,6 +188,14 @@ export class ContractBookingService {
|
|||||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
} as never);
|
} 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);
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
@@ -192,14 +208,24 @@ export class ContractBookingService {
|
|||||||
*/
|
*/
|
||||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer
|
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
|
||||||
// once GL has finalized the pre-booking clearance. The customer never
|
// The customer never books a customs contract himself.
|
||||||
// books a customs contract himself.
|
|
||||||
if (!isGlActor) {
|
if (!isGlActor) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
'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') {
|
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Contract clearance is not ready for booking yet.',
|
'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
|
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
|
||||||
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
|
* 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(
|
private async assertWithinQuantityCap(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FileRecord } from '../files/entities/file.entity';
|
|||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.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 { FilesService } from '../files/files.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { ContractPricingService } from './contract-pricing.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 { ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
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. */
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
if (!allowed.includes(contract.status)) {
|
if (!allowed.includes(contract.status)) {
|
||||||
@@ -46,6 +55,7 @@ export class ContractTransitionService {
|
|||||||
private readonly pricingService: ContractPricingService,
|
private readonly pricingService: ContractPricingService,
|
||||||
private readonly approvalRulesService: ApprovalRulesService,
|
private readonly approvalRulesService: ApprovalRulesService,
|
||||||
private readonly cargoTypesService: CargoTypesService,
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly signaturesService: SignaturesService,
|
private readonly signaturesService: SignaturesService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
@@ -101,6 +111,8 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertValidityDaysConfigured(validityDays);
|
||||||
|
|
||||||
const validFrom = new Date();
|
const validFrom = new Date();
|
||||||
const validUntil = new Date(validFrom);
|
const validUntil = new Date(validFrom);
|
||||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||||
@@ -118,6 +130,37 @@ export class ContractTransitionService {
|
|||||||
return this.contractsService.findById(contractId);
|
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:
|
* Build contract approval steps from the system approval_rules chain (US-06:
|
||||||
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
||||||
@@ -520,7 +563,16 @@ export class ContractTransitionService {
|
|||||||
contract.customsClearingEnabled ?? false,
|
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
|
// 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
|
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||||
// distinction is enforced at the review/finalize endpoints, not here.
|
// distinction is enforced at the review/finalize endpoints, not here.
|
||||||
@@ -531,7 +583,8 @@ export class ContractTransitionService {
|
|||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} 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 =
|
updates.status =
|
||||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { ContractClearanceService } from './contract-clearance.service';
|
|||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
|
import { BookingRequestService } from './booking-request.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { CreateContractDto } from './dto/create-contract.dto';
|
import { CreateContractDto } from './dto/create-contract.dto';
|
||||||
import { UpdateContractDto } from './dto/update-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 { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
|
import {
|
||||||
|
CreateBookingRequestDto,
|
||||||
|
ReviewBookingRequestDto,
|
||||||
|
} from './dto/create-booking-request.dto';
|
||||||
import {
|
import {
|
||||||
AdviseDutyDto,
|
AdviseDutyDto,
|
||||||
AssignRiskDto,
|
AssignRiskDto,
|
||||||
@@ -78,9 +83,78 @@ export class ContractsController {
|
|||||||
private readonly contractBookingService: ContractBookingService,
|
private readonly contractBookingService: ContractBookingService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
|
private readonly bookingRequestService: BookingRequestService,
|
||||||
private readonly signaturesService: SignaturesService,
|
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()
|
@Post()
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FilesModule } from '../files/files.module';
|
|||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.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 { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ import { ContractClearanceService } from './contract-clearance.service';
|
|||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { GlOperationsService } from './gl-operations.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 { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.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 { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { ClearanceIncident } from './entities/clearance-incident.entity';
|
import { ClearanceIncident } from './entities/clearance-incident.entity';
|
||||||
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
|
|
||||||
@@ -54,11 +58,13 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractDocumentReview,
|
ContractDocumentReview,
|
||||||
ClearanceMilestone,
|
ClearanceMilestone,
|
||||||
ClearanceIncident,
|
ClearanceIncident,
|
||||||
|
BookingRequest,
|
||||||
Booking,
|
Booking,
|
||||||
BookingContainerUnit,
|
BookingContainerUnit,
|
||||||
]),
|
]),
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
@@ -82,6 +88,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractBookingService,
|
ContractBookingService,
|
||||||
ClearanceMilestoneService,
|
ClearanceMilestoneService,
|
||||||
GlOperationsService,
|
GlOperationsService,
|
||||||
|
BookingRequestService,
|
||||||
|
BookingRequestRepository,
|
||||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||||
// helpers reused from src/contracts/.
|
// helpers reused from src/contracts/.
|
||||||
ContractTemplateResolver,
|
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 { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||||
import { AvailableDaysQueryDto } from "./dto/available-days-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 { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.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")
|
@Get("container/eligible-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List eligible container bookings" })
|
@ApiOperation({ summary: "List eligible container bookings" })
|
||||||
|
|||||||
@@ -2074,7 +2074,18 @@ export class TrainSchedulingService {
|
|||||||
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
||||||
* returns schedules whose route passes through both yards in the correct order.
|
* 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({
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
where: {
|
where: {
|
||||||
bookingWindowStatus: 'OPEN',
|
bookingWindowStatus: 'OPEN',
|
||||||
@@ -2089,7 +2100,7 @@ export class TrainSchedulingService {
|
|||||||
order: { scheduledDepartureDate: 'ASC' },
|
order: { scheduledDepartureDate: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredSchedules = schedules
|
return schedules
|
||||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||||
.filter((s) => {
|
.filter((s) => {
|
||||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||||
@@ -2128,10 +2139,15 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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() };
|
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. */
|
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||||
async existsOpenScheduleOnRouteDay(
|
async existsOpenScheduleOnRouteDay(
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
import { useState } from "react";
|
import { Banknote, Receipt } from "lucide-react";
|
||||||
import { Banknote, Pencil, Receipt } from "lucide-react";
|
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
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 type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
|
||||||
|
|
||||||
import { SectionCard } from "./detail/SectionCard";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { detailStyles } from "./detail/booking-detail.styles";
|
import { detailStyles } from "./detail/booking-detail.styles";
|
||||||
|
|
||||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||||
const qc = useQueryClient();
|
|
||||||
const computed = Number(booking.totalAmount);
|
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 =
|
const isAdjusted =
|
||||||
booking.adjustedTotalAmount !== null &&
|
booking.adjustedTotalAmount !== null &&
|
||||||
booking.adjustedTotalAmount !== undefined;
|
booking.adjustedTotalAmount !== undefined;
|
||||||
@@ -29,21 +18,6 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
|
|
||||||
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
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) =>
|
const fmt = (n: number) =>
|
||||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
@@ -51,102 +25,23 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||||
<Group justify="space-between" align="flex-start">
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
<div>
|
{isAdjusted ? "Adjusted total" : "Total amount"}
|
||||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
</Text>
|
||||||
{isAdjusted ? "Adjusted total" : "Total amount"}
|
<Text
|
||||||
</Text>
|
size="xl"
|
||||||
<Text
|
fw={700}
|
||||||
size="xl"
|
c="edr-green.9"
|
||||||
fw={700}
|
mt={4}
|
||||||
c="edr-green.9"
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||||
mt={4}
|
>
|
||||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
{fmt(effective)}
|
||||||
>
|
</Text>
|
||||||
{fmt(effective)}
|
{isAdjusted && (
|
||||||
</Text>
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
{isAdjusted && (
|
Computed: {fmt(computed)}
|
||||||
<Text size="xs" c="dimmed" mt={2}>
|
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
|
||||||
Computed: {fmt(computed)}
|
</Text>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ function isValidValidityDays(value: string): boolean {
|
|||||||
return Number.isInteger(days) && days >= 1 && days <= 365;
|
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(
|
export function useBookingActionDialog(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
context: BookingActionContext,
|
context: BookingActionContext,
|
||||||
@@ -93,15 +86,6 @@ export function useBookingActionDialog(
|
|||||||
{ onSuccess },
|
{ onSuccess },
|
||||||
);
|
);
|
||||||
break;
|
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": {
|
case "approve": {
|
||||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
@@ -151,8 +135,7 @@ export function useBookingActionDialog(
|
|||||||
(pendingAction?.input === "file" && !selectedFile) ||
|
(pendingAction?.input === "file" && !selectedFile) ||
|
||||||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||||||
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
||||||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
|
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
|
||||||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -19,9 +21,13 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
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>;
|
type Mutations = ReturnType<typeof useContractMutations>;
|
||||||
|
|
||||||
interface ContractActionsToolbarProps {
|
interface ContractActionsToolbarProps {
|
||||||
@@ -49,12 +55,34 @@ export function ContractActionsToolbar({
|
|||||||
const { status } = contract;
|
const { status } = contract;
|
||||||
|
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
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 [changesOpen, setChangesOpen] = useState(false);
|
||||||
const [changesNote, setChangesNote] = useState("");
|
const [changesNote, setChangesNote] = useState("");
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState("");
|
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)) {
|
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -189,22 +217,48 @@ export function ContractActionsToolbar({
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<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>
|
</Text>
|
||||||
<NumberInput
|
{validityOptions.length > 0 ? (
|
||||||
label="Validity (days)"
|
<Select
|
||||||
min={1}
|
label="Validity"
|
||||||
value={validityDays}
|
placeholder="Select a validity period"
|
||||||
onChange={setValidityDays}
|
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
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={mutations.staffAccept.isPending}
|
loading={mutations.staffAccept.isPending}
|
||||||
onClick={() =>
|
disabled={!validityDays}
|
||||||
mutations.staffAccept.mutate(Number(validityDays) || 365, {
|
onClick={() => {
|
||||||
|
const days = Number(validityDays);
|
||||||
|
if (!days) return;
|
||||||
|
mutations.staffAccept.mutate(days, {
|
||||||
onSuccess: () => setAcceptOpen(false),
|
onSuccess: () => setAcceptOpen(false),
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import {
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -22,6 +27,7 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -32,10 +38,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { OperationDatePicker } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import {
|
import {
|
||||||
useContractCapacity,
|
useContractCapacity,
|
||||||
useContractDetail,
|
useContractDetail,
|
||||||
@@ -74,16 +83,61 @@ function emptyUnit(): UnitDraft {
|
|||||||
|
|
||||||
export default function GlCreateBookingForm() {
|
export default function GlCreateBookingForm() {
|
||||||
const { id } = useParams<{ id: string }>();
|
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 navigate = useNavigate();
|
||||||
const { data: contract, isLoading } = useContractDetail(id);
|
const { data: contract, isLoading } = useContractDetail(id);
|
||||||
const { data: capacity = [] } = useContractCapacity(id);
|
const { data: capacity = [] } = useContractCapacity(id);
|
||||||
const mutations = useContractMutations(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 [scheduledDate, setScheduledDate] = useState("");
|
||||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
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
|
// Price-confirm modal — GL reviews the estimate before booking on behalf of
|
||||||
// the customer, mirroring the portal customer flow.
|
// the customer, mirroring the portal customer flow.
|
||||||
const [priceOpen, setPriceOpen] = useState(false);
|
const [priceOpen, setPriceOpen] = useState(false);
|
||||||
@@ -146,6 +200,60 @@ export default function GlCreateBookingForm() {
|
|||||||
[contract, quantities],
|
[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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -265,8 +373,20 @@ export default function GlCreateBookingForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutations.createBooking.mutate(payload, {
|
mutations.createBooking.mutate(payload, {
|
||||||
onSuccess: (booking) =>
|
onSuccess: async (booking) => {
|
||||||
navigate(`/dashboard/bookings/${booking.id}/milestones`),
|
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>
|
</Group>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<SectionCard icon={FileText} title="Schedule">
|
{bookingRequest ? (
|
||||||
<Grid gap="md">
|
<Alert
|
||||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
color="edr-green"
|
||||||
<TextInput
|
variant="light"
|
||||||
label="Scheduled date"
|
radius="md"
|
||||||
type="date"
|
icon={<FileText size={16} />}
|
||||||
description="Binding shipment day"
|
title="From shipment request"
|
||||||
value={scheduledDate}
|
>
|
||||||
onChange={(e) => setScheduledDate(e.currentTarget.value)}
|
Booking on behalf of the customer for request{" "}
|
||||||
required
|
<b>{bookingRequest.reference}</b>.
|
||||||
/>
|
{bookingRequest.scheduledDate ? (
|
||||||
</Grid.Col>
|
<>
|
||||||
{needsRouteSelect && (
|
{" "}
|
||||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
Customer requested{" "}
|
||||||
<Select
|
<b>
|
||||||
label="Route"
|
{new Intl.DateTimeFormat("en-GB", {
|
||||||
placeholder="Select contract route"
|
day: "2-digit",
|
||||||
value={contractRouteId}
|
month: "short",
|
||||||
onChange={setContractRouteId}
|
year: "numeric",
|
||||||
data={routes.map((r) => ({
|
}).format(new Date(bookingRequest.scheduledDate))}
|
||||||
value: r.id,
|
</b>{" "}
|
||||||
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"} → ${
|
— set the binding shipment date below.
|
||||||
r.destinationYard?.label ??
|
</>
|
||||||
r.destinationYard?.code ??
|
) : null}
|
||||||
"Destination"
|
</Alert>
|
||||||
}`,
|
) : null}
|
||||||
}))}
|
<SectionCard icon={FileText} title="Route">
|
||||||
required
|
{needsRouteSelect ? (
|
||||||
/>
|
<Select
|
||||||
</Grid.Col>
|
label="Contract route"
|
||||||
)}
|
placeholder="Select contract route"
|
||||||
</Grid>
|
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>
|
</SectionCard>
|
||||||
|
|
||||||
{isContainer ? (
|
{isContainer ? (
|
||||||
@@ -610,6 +752,40 @@ export default function GlCreateBookingForm() {
|
|||||||
</SectionCard>
|
</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">
|
<SectionCard icon={FileText} title="Notes">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Internal GL notes (optional)"
|
placeholder="Internal GL notes (optional)"
|
||||||
|
|||||||
@@ -154,6 +154,15 @@ export const URL_CONSTANTS = {
|
|||||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||||
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
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`,
|
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/milestones`,
|
`/contracts/bookings/${bookingId}/milestones`,
|
||||||
@@ -197,6 +206,7 @@ export const URL_CONSTANTS = {
|
|||||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||||
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||||
|
AVAILABLE_DAYS_FOR_CARGO: "/train-scheduling/available-days-for-cargo",
|
||||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Coins,
|
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Play,
|
Play,
|
||||||
@@ -36,7 +35,6 @@ export type BookingActionId =
|
|||||||
| "complete"
|
| "complete"
|
||||||
| "operationAccept"
|
| "operationAccept"
|
||||||
| "operationRequestChanges"
|
| "operationRequestChanges"
|
||||||
| "operationAdjustPrice"
|
|
||||||
| "cancel";
|
| "cancel";
|
||||||
|
|
||||||
export type BookingActionInputKind =
|
export type BookingActionInputKind =
|
||||||
@@ -196,20 +194,6 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
|||||||
inputLabel: "Message to customer",
|
inputLabel: "Message to customer",
|
||||||
inputPlaceholder: "Describe what needs to change…",
|
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 = {
|
const CANCEL_ACTION: BookingActionDef = {
|
||||||
@@ -280,7 +264,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||||
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
|
|
||||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,9 +63,8 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
|
|
||||||
const reviewOperation = useMutation({
|
const reviewOperation = useMutation({
|
||||||
mutationFn: (payload: {
|
mutationFn: (payload: {
|
||||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
decision: "ACCEPT" | "REQUEST_CHANGES";
|
||||||
note?: string;
|
note?: string;
|
||||||
amount?: number;
|
|
||||||
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
|
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
|
||||||
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
|
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
|
||||||
onError: () => toast.error("Failed to review operation request"),
|
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>(
|
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"track",
|
"track",
|
||||||
@@ -1838,13 +1864,12 @@ export const api = {
|
|||||||
reviewOperation: endpoint<
|
reviewOperation: endpoint<
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
decision: "ACCEPT" | "REQUEST_CHANGES";
|
||||||
note?: string;
|
note?: string;
|
||||||
amount?: number;
|
|
||||||
},
|
},
|
||||||
BookingDetail
|
BookingDetail
|
||||||
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
|
>("bookings", "reviewOperation", ({ id, decision, note }) =>
|
||||||
bookingsService.reviewOperation(id, decision, { note, amount }),
|
bookingsService.reviewOperation(id, decision, { note }),
|
||||||
),
|
),
|
||||||
|
|
||||||
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
||||||
|
|||||||
@@ -212,21 +212,14 @@ export const bookingsService = {
|
|||||||
/** Marketing/operations review of a drawdown order's operation request. */
|
/** Marketing/operations review of a drawdown order's operation request. */
|
||||||
reviewOperation: (
|
reviewOperation: (
|
||||||
id: string,
|
id: string,
|
||||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
|
decision: "ACCEPT" | "REQUEST_CHANGES",
|
||||||
options: { note?: string; amount?: number } = {},
|
options: { note?: string } = {},
|
||||||
) =>
|
) =>
|
||||||
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
|
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
|
||||||
decision,
|
decision,
|
||||||
...options,
|
...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) =>
|
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
|
||||||
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,37 @@ export const contractsService = {
|
|||||||
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
|
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 ──
|
// ── Clearance milestones ──
|
||||||
listMilestonesForContract: async (
|
listMilestonesForContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
import { api as client } from "../auth/http";
|
import { api as client } from "../auth/http";
|
||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
@@ -127,6 +128,24 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data).days;
|
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> => {
|
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||||
const response = await client.post<BatchBoardScheduleDetail>(
|
const response = await client.post<BatchBoardScheduleDetail>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export const URL_CONSTANTS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||||
|
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
|
||||||
},
|
},
|
||||||
|
|
||||||
PAYMENTS: {
|
PAYMENTS: {
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
import { Box, Button, Group, Text } from "@mantine/core";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { OperationDatePicker as DatePicker } from "@edr/ui-common";
|
||||||
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 { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
@@ -29,11 +11,9 @@ interface OperationDatePickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact month calendar for picking the binding shipment day at the
|
* Route-based day picker for the operation-request step: a thin query wrapper
|
||||||
* operation-request step. Only days that have an OPEN scheduled departure on the
|
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
|
||||||
* booking route are selectable; all other days are disabled.
|
* Only days with an OPEN scheduled departure on the route are selectable.
|
||||||
*
|
|
||||||
* Shared by the booking detail clearance card and the home-page action modal.
|
|
||||||
*/
|
*/
|
||||||
export function OperationDatePicker({
|
export function OperationDatePicker({
|
||||||
originYardId,
|
originYardId,
|
||||||
@@ -41,8 +21,6 @@ export function OperationDatePicker({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: OperationDatePickerProps) {
|
}: OperationDatePickerProps) {
|
||||||
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
|
||||||
|
|
||||||
const { data: availableDays, isLoading } = useQuery(
|
const { data: availableDays, isLoading } = useQuery(
|
||||||
api.bookings.getAvailableDays.queryOptions({
|
api.bookings.getAvailableDays.queryOptions({
|
||||||
input: { originYardId, destinationYardId },
|
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 (
|
return (
|
||||||
<Box
|
<DatePicker
|
||||||
style={{
|
availableDays={availableDays ?? []}
|
||||||
border: "1px solid #E6ECF2",
|
isLoading={isLoading}
|
||||||
borderRadius: 12,
|
value={value}
|
||||||
padding: 14,
|
onChange={onChange}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default OperationDatePicker;
|
||||||
|
|||||||
@@ -285,13 +285,12 @@ export default function NewContractPage() {
|
|||||||
const isContainer = data.cargoType === "container";
|
const isContainer = data.cargoType === "container";
|
||||||
|
|
||||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
// 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.
|
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
|
||||||
const isGeneral = data.contractKind === "general_contract";
|
const isGeneral = data.contractKind === "general_contract";
|
||||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||||
? data.enabledContainerSizes.map((size) => ({
|
? data.enabledContainerSizes.map((size) => ({
|
||||||
containerSize: size,
|
containerSize: size,
|
||||||
cargoTypeId: data.cargoCommodityId || undefined,
|
|
||||||
quantityCap:
|
quantityCap:
|
||||||
isGeneral && data.containerSizeCaps[size]
|
isGeneral && data.containerSizeCaps[size]
|
||||||
? data.containerSizeCaps[size]
|
? data.containerSizeCaps[size]
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { OperationDatePicker } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { OperationDatePicker } from "@/pages/bookings/clearance/OperationDatePicker";
|
|
||||||
import {
|
import {
|
||||||
SelectField,
|
SelectField,
|
||||||
StepCard,
|
StepCard,
|
||||||
@@ -264,8 +264,8 @@ export default function NewShipmentPage() {
|
|||||||
{/* Single-step form — all sections on one page. */}
|
{/* Single-step form — all sections on one page. */}
|
||||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||||
<RouteStep form={form} contract={contract} routes={routes} />
|
<RouteStep form={form} contract={contract} routes={routes} />
|
||||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
|
||||||
<CargoStep form={form} contract={contract} />
|
<CargoStep form={form} contract={contract} />
|
||||||
|
<ScheduleStep form={form} contract={contract} routes={routes} />
|
||||||
<NotesSection form={form} />
|
<NotesSection form={form} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -486,6 +486,7 @@ function RouteStep({
|
|||||||
|
|
||||||
function ScheduleStep({
|
function ScheduleStep({
|
||||||
form,
|
form,
|
||||||
|
contract,
|
||||||
routes,
|
routes,
|
||||||
}: {
|
}: {
|
||||||
form: ShipmentForm;
|
form: ShipmentForm;
|
||||||
@@ -494,35 +495,96 @@ function ScheduleStep({
|
|||||||
}) {
|
}) {
|
||||||
const contractRouteId = form.watch("contractRouteId");
|
const contractRouteId = form.watch("contractRouteId");
|
||||||
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
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 (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={<CalendarDays size={22} />}
|
icon={<CalendarDays size={22} />}
|
||||||
title="Schedule"
|
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
|
{cargoQuery === null ? (
|
||||||
name="scheduledDate"
|
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
control={form.control}
|
Enter your cargo details first — available shipment days depend on the
|
||||||
render={({ field, fieldState }) => (
|
wagons your cargo needs.
|
||||||
<Box>
|
</Alert>
|
||||||
<StepLabel>Shipment day *</StepLabel>
|
) : (
|
||||||
<Box mt={10}>
|
<Controller
|
||||||
<OperationDatePicker
|
name="scheduledDate"
|
||||||
originYardId={route?.originYardId}
|
control={form.control}
|
||||||
destinationYardId={route?.destinationYardId}
|
render={({ field, fieldState }) => (
|
||||||
value={field.value ?? ""}
|
<Box>
|
||||||
onChange={(d) => field.onChange(d)}
|
<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>
|
</Box>
|
||||||
{fieldState.error?.message && (
|
)}
|
||||||
<Text fz="xs" c="red" mt={6}>
|
/>
|
||||||
{fieldState.error.message}
|
)}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,8 +158,6 @@ export const contractFormSchema = z
|
|||||||
// Container scope: the enabled sizes (min 1). Each becomes a
|
// Container scope: the enabled sizes (min 1). Each becomes a
|
||||||
// contract_cargo_scope row.
|
// contract_cargo_scope row.
|
||||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
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
|
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||||
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
|
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
|
||||||
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
|
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
|
||||||
@@ -279,7 +277,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
|
|
||||||
cargoType: "container",
|
cargoType: "container",
|
||||||
enabledContainerSizes: ["20ft"],
|
enabledContainerSizes: ["20ft"],
|
||||||
cargoCommodityId: "",
|
|
||||||
containerSizeCaps: {},
|
containerSizeCaps: {},
|
||||||
cargoTypePath: [],
|
cargoTypePath: [],
|
||||||
cargoFreeText: "",
|
cargoFreeText: "",
|
||||||
@@ -318,7 +315,6 @@ export const contractStepFields: Record<
|
|||||||
1: [
|
1: [
|
||||||
"cargoType",
|
"cargoType",
|
||||||
"enabledContainerSizes",
|
"enabledContainerSizes",
|
||||||
"cargoCommodityId",
|
|
||||||
"containerSizeCaps",
|
"containerSizeCaps",
|
||||||
"cargoTypePath",
|
"cargoTypePath",
|
||||||
"cargoFreeText",
|
"cargoFreeText",
|
||||||
|
|||||||
@@ -100,17 +100,6 @@ export function Step3CargoScope({
|
|||||||
);
|
);
|
||||||
}, [referenceData, parentId]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -183,27 +172,6 @@ export function Step3CargoScope({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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. */}
|
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
|
||||||
{cargoType === "bulk" && (
|
{cargoType === "bulk" && (
|
||||||
<Stack gap={12} mt={18}>
|
<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
|
// Shipment booking under a contract (doc §8.1, Path A customer). Captures the
|
||||||
// EXECUTION details the contract scope deliberately omits: a binding scheduled
|
// EXECUTION details the contract scope deliberately omits: a binding scheduled
|
||||||
// date, container quantities + per-unit numbers/seals/VGM, or bulk tonnage.
|
// 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 = [
|
export const SHIPMENT_STEPS = [
|
||||||
{ id: 0, label: "Route", short: "Route" },
|
{ id: 0, label: "Route", short: "Route" },
|
||||||
{ id: 1, label: "Schedule", short: "Schedule" },
|
{ id: 1, label: "Cargo Details", short: "Cargo" },
|
||||||
{ id: 2, label: "Cargo Details", short: "Cargo" },
|
{ id: 2, label: "Schedule", short: "Schedule" },
|
||||||
{ id: 3, label: "Review", short: "Review" },
|
{ id: 3, label: "Review", short: "Review" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -82,7 +85,7 @@ export const shipmentStepFields: Record<
|
|||||||
Array<Path<ShipmentFormValues>>
|
Array<Path<ShipmentFormValues>>
|
||||||
> = {
|
> = {
|
||||||
0: ["contractRouteId"],
|
0: ["contractRouteId"],
|
||||||
1: ["scheduledDate"],
|
1: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
|
||||||
2: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
|
2: ["scheduledDate"],
|
||||||
3: ["notes"],
|
3: ["notes"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ export const api = {
|
|||||||
({ originYardId, destinationYardId }) =>
|
({ originYardId, destinationYardId }) =>
|
||||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
|
||||||
|
"train-scheduling",
|
||||||
|
"availableDaysForCargo",
|
||||||
|
(input) => bookingsService.getAvailableDaysForCargo(input),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
contracts: {
|
contracts: {
|
||||||
|
|||||||
@@ -265,4 +265,23 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return (data.data as Freight.AvailableDaysResponse).days;
|
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
|
## 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)
|
Company → status PENDING
|
||||||
5. Contact person 6. Verify phone (OTP) 7. Power of Attorney (optional)
|
Each operation profile (importer | exporter | freight_forwarder) → status PENDING
|
||||||
8. Documents 9. Business license per profile
|
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**:
|
||||||
|
|
||||||
```
|
| Nationality | Company documents |
|
||||||
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 |
|
|
||||||
|-------------|----------------------------|
|
|
||||||
| Ethiopian | TIN Certificate · Commercial License · National ID |
|
| Ethiopian | TIN Certificate · Commercial License · National ID |
|
||||||
| Foreign | TIN Certificate · Investment License · National ID · Passport |
|
| 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
|
## 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),
|
contractKind: ONE_TIME (one shipment per contract)
|
||||||
contract kind (ONE_TIME vs GENERAL),
|
GENERAL (many shipments over a validity window)
|
||||||
new vs renewal, service type, currency,
|
|
||||||
first/last mile, customs-clearing on/off, equipment return
|
tradeDirection: IMPORT | EXPORT | DOMESTIC
|
||||||
Step 1 Cargo+Route container sizes OR bulk commodity, hazardous/reefer flags,
|
|
||||||
origin & destination yard, extra routes (GENERAL only)
|
customsClearingEnabled: true → Path B (GL clears + books) — IMPORT/EXPORT
|
||||||
Step 2 Documents required onboarding docs + any contract-specific uploads
|
false → Path A (customer self-clears + books) — IMPORT/EXPORT
|
||||||
Step 3 Review check everything, see quotation, submit (or save draft)
|
(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)
|
DRAFT · SUBMITTED · PRICE_CHANGED_PENDING_CONFIRM · CHANGES_REQUESTED
|
||||||
GENERAL (ship many times over a validity window)
|
PENDING_APPROVAL · APPROVED · APPROVED_PENDING_SIGNATURE · CONTRACT_READY
|
||||||
|
SIGNED_CUSTOMER · FULLY_EXECUTED · CONTRACT_ACTIVE
|
||||||
customs clearing: ENABLED → Path B (Global Logistics clears for you)
|
AWAITING_CLEARANCE_DOCUMENTS · CLEARANCE_UNDER_REVIEW · CLEARANCE_READY_FOR_BOOKING
|
||||||
DISABLED → Path A (you self-clear) — for IMPORT/EXPORT
|
ACTIVE_SHIPMENT_IN_PROGRESS · CONTRACT_CLOSED · EXPIRED
|
||||||
(DOMESTIC/intercity → no clearance at all)
|
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
|
IF GENERAL + customsClearingEnabled (Path B):
|
||||||
→ SUBMITTED (customer submits; prices frozen)
|
NO contract-level clearance cycle.
|
||||||
→ PENDING_APPROVAL (staff accepts intake, sets validity window)
|
status → CONTRACT_ACTIVE, clearanceStatus → NOT_APPLICABLE
|
||||||
→ APPROVED (approval chain signs: LINE_STAFF → DIRECTOR → CEO)
|
→ customer requests shipments; GL creates + clears each booking (per-booking)
|
||||||
→ CONTRACT_READY (staff generates the contract PDF)
|
|
||||||
→ SIGNED_CUSTOMER (customer signs)
|
IF ONE_TIME + customs (Path A self-clear OR one-time-customs):
|
||||||
→ counter-sign by staff/director/ceo … then it SPLITS ↓
|
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
|
staff requestChanges → CHANGES_REQUESTED → customer edits → submit → SUBMITTED
|
||||||
status → AWAITING_CLEARANCE_DOCUMENTS
|
staff reject → REJECTED
|
||||||
|
GENERAL contract → renew → RENEWAL_DRAFT (clone of prior version)
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Side branches at any review stage**
|
### Endpoints (`contracts.controller.ts`)
|
||||||
|
|
||||||
```
|
```
|
||||||
staff requests changes → CHANGES_REQUESTED → customer edits → SUBMITTED again
|
POST /contracts/:id/submit submit()
|
||||||
staff rejects → REJECTED
|
POST /contracts/:id/confirm-submit confirmSubmit()
|
||||||
customer/staff cancels → CANCELLED
|
POST /contracts/:id/staff/accept staffAccept()
|
||||||
GENERAL contract later → renew → RENEWAL_DRAFT (copies the old contract)
|
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:
|
> Runs only when the counter-sign branch opened a contract clearance cycle.
|
||||||
> **customer uploads → reviewer approves or queries → customer re-uploads → … → finalize.**
|
> 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 A (customsClearingEnabled = false) → OPERATIONS
|
||||||
PATH B (customs, customs ENABLED) → reviewed by GLOBAL LOGISTICS (GL)
|
Path B (customsClearingEnabled = true) → GLOBAL LOGISTICS Ethiopia (GL ET)
|
||||||
```
|
```
|
||||||
|
|
||||||
### The status sub-states
|
### Document states & loop
|
||||||
|
|
||||||
```
|
```
|
||||||
AWAITING_CLEARANCE_DOCUMENTS customer must upload
|
each document: PENDING → APPROVED (reviewer approves)
|
||||||
CLEARANCE_UNDER_REVIEW reviewer is checking
|
→ QUERIED (reviewer demands re-upload, note required)
|
||||||
CLEARANCE_READY_FOR_BOOKING (Path B) done — GL will make the booking
|
→ contract back to AWAITING_CLEARANCE_DOCUMENTS
|
||||||
SELF_CLEARED (Path A) done — customer will make the booking
|
(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
|
input (customer uploads):
|
||||||
→ status = CLEARANCE_UNDER_REVIEW
|
Path B: contract_clearance_{import|export}_{container|bulk}
|
||||||
→ each document = PENDING
|
Path A: contract_clearance_selfclear_{import|export}_{container|bulk}
|
||||||
|
DOMESTIC: null (no gate)
|
||||||
|
|
||||||
2. Reviewer goes document by document:
|
output (GL uploads, container customs only):
|
||||||
APPROVE → that document = APPROVED
|
contract_clearance_output_{import|export}_container
|
||||||
QUERY → that document = QUERIED (note required)
|
(bulk or non-customs → null)
|
||||||
→ 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### PATH A — self-clear (Operations)
|
### Finalize
|
||||||
|
|
||||||
```
|
```
|
||||||
documents the CUSTOMER uploads (examples):
|
opsFinalize() Path A — requires every required input doc APPROVED
|
||||||
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
|
|
||||||
→ clearanceStatus = SELF_CLEARED
|
→ clearanceStatus = SELF_CLEARED
|
||||||
→ contract status = CONTRACT_ACTIVE (GENERAL) or FULLY_EXECUTED (ONE_TIME)
|
→ contract status = CONTRACT_ACTIVE (GENERAL) | FULLY_EXECUTED (ONE_TIME)
|
||||||
→ CUSTOMER creates the booking (section 4)
|
→ CUSTOMER creates booking
|
||||||
```
|
|
||||||
|
|
||||||
### PATH B — customs (Global Logistics)
|
finalize() Path B — requires every required input APPROVED + every required output uploaded
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
→ clearanceStatus = CLEARANCE_READY_FOR_BOOKING
|
→ 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.
|
```
|
||||||
|
GET /contracts/:id/clearance document grid
|
||||||
> Clearance (section 3) is **pre-booking**. After GL creates the booking, the work continues as **GL Phase 2** — see section 4b.
|
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
|
Customs (Path B) — only GL ET (needs contracts.createBooking permission):
|
||||||
PATH B (customs) → GLOBAL LOGISTICS creates the booking on the customer's behalf
|
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):
|
DRAFT · SUBMITTED · PRICE_CHANGED_PENDING_CONFIRM · CHANGES_REQUESTED
|
||||||
only GL, and only when clearanceStatus = CLEARANCE_READY_FOR_BOOKING
|
PENDING_APPROVAL · APPROVED_PENDING_SIGNATURE · APPROVED · CONTRACT_READY
|
||||||
|
SIGNED_CUSTOMER · FULLY_EXECUTED
|
||||||
IF no customs (Path A / DOMESTIC):
|
AWAITING_DOCUMENTS · DOCUMENTS_UNDER_REVIEW · CLEARANCE_READY
|
||||||
customer (or staff), and only when contract is FULLY_EXECUTED / CONTRACT_ACTIVE
|
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`.
|
||||||
|
|
||||||
```
|
### Customer-self-booking lifecycle (Path A / domestic — same approval shape as a contract)
|
||||||
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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
DRAFT
|
DRAFT
|
||||||
→ generate price → SUBMITTED (if price changed: PRICE_CHANGED_PENDING_CONFIRM → confirm → SUBMITTED)
|
→ generate-price → submit
|
||||||
→ PENDING_APPROVAL (staff accept intake)
|
price unchanged → SUBMITTED
|
||||||
→ APPROVED / CONTRACT_READY (approval chain)
|
price changed → PRICE_CHANGED_PENDING_CONFIRM → confirm-submit → SUBMITTED
|
||||||
→ SIGNED_CUSTOMER → counter-sign … SPLIT ↓
|
→ acceptIntake (staff) → PENDING_APPROVAL (validity window + approval steps)
|
||||||
|
→ approveStep ×N (LINE_STAFF → DIRECTOR → CEO) → APPROVED (auto-generates contract)
|
||||||
IF clearance applies → AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY
|
→ CONTRACT_READY → customerSign → SIGNED_CUSTOMER
|
||||||
IF no clearance → FULLY_EXECUTED directly
|
→ marketingApprove → FULLY_EXECUTED (sets fullyExecutedAt, lockedAt)
|
||||||
|
→ (then operation request, section 5)
|
||||||
```
|
```
|
||||||
|
|
||||||
> The booking has its **own** document clearance loop, mirroring the contract one
|
Side branches: `requestChanges → CHANGES_REQUESTED`; `staffReject / reject → REJECTED`; `cancel → CANCELLED` (DRAFT…CONTRACT_READY).
|
||||||
> (upload → APPROVED/QUERIED → re-upload → finalize). Re-uploading a queried doc
|
|
||||||
> resets it to PENDING. Booking proceeds only when all required docs are APPROVED.
|
|
||||||
|
|
||||||
### Pricing & payment
|
### Transition methods (`BookingTransitionService`)
|
||||||
|
|
||||||
```
|
```
|
||||||
price generated from rule engine + live rates, converted to chosen currency (USD/ETB)
|
submit · confirmSubmit · requestChanges · acceptIntake · staffReject · reject
|
||||||
customer pays (Telebirr) once the booking is FULLY_EXECUTED / SELECTED_FOR_BATCH
|
approveStep · rejectStep · generateContract · customerSign · marketingApprove
|
||||||
payment status: PENDING → VERIFICATION_IN_PROGRESS → PAID (or FAILED)
|
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
|
> The customs booking's own document loop, then everyone funnels into the operation request that puts the shipment in the schedule pool.
|
||||||
> a **milestone timeline** plus a set of **structured GL actions**. The customer
|
|
||||||
> only watches and, when asked, pays / uploads a duty slip.
|
|
||||||
|
|
||||||
### Milestone timeline
|
### Per-booking clearance (customs bookings only)
|
||||||
|
|
||||||
When GL creates the booking, the system seeds the **post-booking milestones**
|
|
||||||
for that direction (import ~15, export ~11). Each is `PENDING → COMPLETED`.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Import (post-booking): WAGON_REQUESTED → FREIGHT_PAYMENT_SETTLED → WAGON_ALLOCATED
|
GL creates booking → AWAITING_DOCUMENTS
|
||||||
→ GATEPASS_GRANTED → READY_FOR_LOADING → LOADED → DEPARTED_FROM_DJIBOUTI
|
submitClearanceDocuments (customer upload) → DOCUMENTS_UNDER_REVIEW
|
||||||
→ ARRIVED_ETHIOPIA → OFFLOADED → T1_CLOSED → RISK_ASSIGNED
|
reviewDocument (GL) → doc APPROVED | QUERIED (note)
|
||||||
→ IMPORT_RELEASE_GRANTED → IMPORT_PROCESS_COMPLETED
|
uploadClearanceOutputDocuments (GL, customs output)
|
||||||
→ STORAGE_INVOICE_RAISED → EXIT_NOTE_GENERATED
|
finalizeClearance requires 100% required inputs APPROVED + required outputs present
|
||||||
|
→ CLEARANCE_READY
|
||||||
Export (post-booking): WAGON_REQUESTED → FREIGHT_PAYMENT_PENDING → FREIGHT_PAYMENT_SETTLED
|
proceedToOperation → OPERATION_REQUEST_PENDING
|
||||||
→ WAGON_ALLOCATED → CARGO_ARRIVED → READY_FOR_LOADING → LOADED
|
|
||||||
→ DEPARTED_TO_DJIBOUTI → ARRIVED_AT_DJIBOUTI → GATEPASS_GRANTED → OFFLOADED
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each milestone has an **owner**: ET (GL Ethiopia), DJ (GL Djibouti), OPS (Operations),
|
inputCode / outputCode resolve from `(tradeDirection, freightType, customsClearingEnabled)` — same scheme as the contract loop. Doc review status: `PENDING · APPROVED · QUERIED`.
|
||||||
CUST (customer). Backoffice shows the timeline with a **Complete** button on the
|
|
||||||
next pending step; the customer portal shows the same timeline **read-only**.
|
|
||||||
|
|
||||||
### GL actions (the structured part)
|
### Operation request (all paths)
|
||||||
|
|
||||||
Plain "Complete" covers most steps. These carry extra data, so they have their
|
|
||||||
own UI cards on the backoffice **milestones page** (`GlActionsPanel`):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Station routing → route shipment to a station yard (+ bind GL staff) (GL US-02)
|
requestOperation() guard CLEARANCE_READY | OPERATION_CHANGES_REQUESTED, valid schedule day
|
||||||
Customs risk → assign GREEN / YELLOW / RED → completes RISK_ASSIGNED
|
→ OPERATION_REQUEST_PENDING
|
||||||
Duty & tax → GL advises amount + declaration serial → completes
|
(Path A / domestic bookings begin life here directly)
|
||||||
DUTY_TAXES_ADVISED → customer uploads slip → DUTY_TAX_PAID
|
|
||||||
GL documents → upload DO / RO / T1 / import release / interchange /
|
reviewOperationRequest(decision):
|
||||||
final declaration → auto-completes the matching milestone
|
ACCEPT → acceptOperationRequest():
|
||||||
Cargo exception → log SEAL_BROKEN / CONTAINER_OPENED / CONTAINER_DAMAGED /
|
train service → FULLY_EXECUTED + enqueue batch fill (origin, dest, day)
|
||||||
FLUID_LEAKING with photos → alert GL Ethiopia (GL US-07)
|
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
|
### Clearance / operation endpoints (`bookings.controller.ts`)
|
||||||
milestone automatically — no separate click:
|
|
||||||
|
|
||||||
| 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 |
|
| `delivery_order` | DO_COLLECTED | GL DJ |
|
||||||
| `release_order` | RELEASE_ORDER_SECURED | GL DJ |
|
| `release_order` | RELEASE_ORDER_SECURED | GL DJ |
|
||||||
| `t1_transport_document` | T1_CLOSED | GL ET |
|
| `t1_transport_document` | T1_CLOSED | GL ET |
|
||||||
@@ -356,86 +395,99 @@ milestone automatically — no separate click:
|
|||||||
| `full_in_interchange` | OFFLOADED | GL DJ |
|
| `full_in_interchange` | OFFLOADED | GL DJ |
|
||||||
| `final_declaration` | IMPORT_PROCESS_COMPLETED | GL ET |
|
| `final_declaration` | IMPORT_PROCESS_COMPLETED | GL ET |
|
||||||
| `duty_tax_receipt` | DUTY_TAX_PAID | **Customer** |
|
| `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
|
POST /contracts/bookings/:bookingId/milestones/:code/complete manual complete
|
||||||
DEPARTED_TO_DJIBOUTI (export) → lead moves to GL Djibouti
|
POST /contracts/:id/milestones/:code/complete pre-booking contract milestone
|
||||||
```
|
POST /contracts/bookings/:bookingId/risk assignRisk
|
||||||
Ownership region is encoded per-milestone in the catalog; notifications fire on
|
POST /contracts/bookings/:bookingId/duty adviseDuty
|
||||||
handoff (notification module pending).
|
POST /contracts/bookings/:bookingId/station-assign assignStation
|
||||||
|
POST /contracts/bookings/:bookingId/documents GL doc upload (DO/RO/T1/…)
|
||||||
### What the customer does in Phase 2
|
POST /contracts/bookings/:bookingId/duty-slip customer duty slip
|
||||||
|
GET /contracts/bookings/:bookingId/incidents list
|
||||||
```
|
POST /contracts/bookings/:bookingId/incidents reportIncident
|
||||||
watch the timeline (read-only)
|
GET /contracts/bookings/:bookingId/milestones timeline
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Where it lives (Phase 2)
|
> 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.
|
||||||
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
> Day-level pooling. Customer picks a **day**; the batch engine assigns the actual **train** later.
|
||||||
> 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
|
READY FULLY_EXECUTED + fullyExecutedAt, no train link yet
|
||||||
→ OPERATION_REQUEST_PENDING
|
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:
|
### The cron cycle (every 3h; prod `0 */3 * * *`)
|
||||||
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)
|
|
||||||
|
|
||||||
3. Batch engine (cron) groups bookings by (origin yard, destination yard, day):
|
```
|
||||||
allocates to open train schedules by priority score
|
1. Fill distribute (route, dest, day) pool across OPEN schedules by priorityScore
|
||||||
→ SELECTED_FOR_BATCH, assigns trainScheduleId, sets payment deadline
|
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
|
### 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:
|
Road service: reviewOperationRequest ACCEPT → ROAD_DISPATCH_PENDING (skips train pool)
|
||||||
ACCEPT → ROAD_DISPATCH_PENDING (skips the train pool)
|
billed by KM, dispatched by truck (First-Mile operations)
|
||||||
billed by KM, dispatched by truck (First-Mile operations)
|
|
||||||
|
|
||||||
IF first/last mile chosen at booking:
|
First/last mile: pickup + delivery addresses captured at booking; equipment return WITH | WITHOUT
|
||||||
pickup + delivery addresses captured; equipment return = WITH / WITHOUT
|
last-mile: PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT
|
||||||
last-mile statuses: PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -444,47 +496,47 @@ IF first/last mile chosen at booking:
|
|||||||
|
|
||||||
```
|
```
|
||||||
ONBOARD
|
ONBOARD
|
||||||
nationality ─┬─ Ethiopian → TIN + Commercial License + National ID
|
profile (importer/exporter/FF) approved → ACTIVE → can transact
|
||||||
└─ Foreign → TIN + Investment License + National ID + Passport
|
|
||||||
pick profiles (importer/exporter/FF) → upload license per profile
|
|
||||||
→ backoffice approves profile → profile ACTIVE
|
|
||||||
|
|
||||||
CONTRACT
|
CONTRACT (ContractTransitionService)
|
||||||
wizard (setup → cargo+route → docs → review) → SUBMITTED
|
submit → staffAccept → approveStep×N (LINE_STAFF→DIRECTOR→CEO) → CONTRACT_READY
|
||||||
→ staff accept → approval chain → CONTRACT_READY → customer sign → counter-sign
|
→ sign(CUSTOMER) → counterSign → BRANCH:
|
||||||
→ SPLIT:
|
GENERAL + customs → CONTRACT_ACTIVE (NOT_APPLICABLE) — clearance deferred to booking
|
||||||
customs ENABLED (import/export) → PATH B clearance
|
ONE_TIME + customs → AWAITING_CLEARANCE_DOCUMENTS — contract clearance cycle (section 3)
|
||||||
customs DISABLED (import/export) → PATH A clearance
|
DOMESTIC / no customs → FULLY_EXECUTED | CONTRACT_ACTIVE — book now
|
||||||
DOMESTIC → no clearance, ready to book
|
|
||||||
|
|
||||||
CLEARANCE (import/export only) loop: upload → approve/query → re-upload → finalize
|
BOOKING (POST /contracts/:id/bookings, gate in ContractBookingService)
|
||||||
PATH A: Operations review → SELF_CLEARED → CUSTOMER books
|
customs (Path B) → only GL → starts AWAITING_DOCUMENTS
|
||||||
PATH B: GL review + GL output docs → READY_FOR_BOOKING → GL books
|
no customs (Path A/domestic) → customer/staff → starts OPERATION_REQUEST_PENDING
|
||||||
|
|
||||||
BOOKING
|
PER-BOOKING CLEARANCE (customs only, BookingTransitionService)
|
||||||
created by CUSTOMER (Path A / domestic) or GL (Path B)
|
upload → review(approve/query) → finalize → CLEARANCE_READY → proceed → OPERATION_REQUEST_PENDING
|
||||||
price → pay → (its own doc clearance if applicable) → ready to schedule
|
|
||||||
|
|
||||||
GL PHASE 2 (customs/Path B, after booking)
|
OPERATION REQUEST (all paths)
|
||||||
milestone timeline: wagon → pay → allocate → load → depart → handover
|
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
|
→ 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
|
customer: watch read-only · upload duty slip · pay freight
|
||||||
|
|
||||||
SCHEDULE
|
SCHEDULE (booking-batch.service.ts, 3h EAT cron)
|
||||||
request a day → Operations accept → batch engine → train assigned
|
fill (route,dest,day) → SELECTED_FOR_BATCH (+1h pay) → pay → PAID/ALLOCATED
|
||||||
→ pay → IN_TRANSIT → COMPLETED
|
→ IN_TRANSIT → COMPLETED (road → ROAD_DISPATCH_PENDING → truck)
|
||||||
(road service → ROAD_DISPATCH_PENDING → truck)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Where this lives in the code (quick map)
|
### Code map
|
||||||
|
|
||||||
| Area | Key files |
|
| Area | Key files (`apps/edr-freight-api/src/modules/…`) |
|
||||||
|------|-----------|
|
|------|---------------------------------------------------|
|
||||||
| Onboarding | `portal/.../components/onboarding/OnboardingWizardDialog.tsx`, `api/.../companies/companies.service.ts`, `api/src/seed/file-upload-settings.seeder.ts` |
|
| Contract state machine | `contracts/contract-transition.service.ts`, `contracts/entities/contract.entity.ts`, `contracts/contracts.controller.ts` |
|
||||||
| Contract | `portal/.../contracts/new-contract-form/`, `api/.../contracts/contract-transition.service.ts`, `entities/contract.entity.ts` |
|
| Contract clearance | `contracts/contract-clearance.service.ts`, `contracts/contract-clearance.util.ts` |
|
||||||
| Clearance | `api/.../contracts/contract-clearance.service.ts`, `contract-clearance.util.ts`, `portal/.../contracts/ContractClearancePanel.tsx` |
|
| Booking gate | `contracts/contract-booking.service.ts` |
|
||||||
| Booking | `portal/.../bookings/new-booking-form/`, `api/.../bookings/booking-transition.service.ts`, `contract-booking.service.ts` |
|
| Booking lifecycle + per-booking clearance | `bookings/booking-transition.service.ts`, `bookings/entities/booking.entity.ts`, `bookings/bookings.controller.ts` |
|
||||||
| Schedule | `api/.../train-scheduling/booking-batch.service.ts`, `backoffice/.../operations/FirstMilePage.tsx` |
|
| 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;
|
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) ───────────────────────────────
|
// ── Clearance review / finalize DTOs (Path B) ───────────────────────────────
|
||||||
|
|
||||||
export interface ReviewContractClearanceDocumentDto {
|
export interface ReviewContractClearanceDocumentDto {
|
||||||
|
|||||||
@@ -592,6 +592,23 @@ export interface AvailableDaysQuery {
|
|||||||
destinationYardId?: string;
|
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
|
* 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"]`.
|
* 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,
|
ViewableFile,
|
||||||
} from "./components/FileViewer";
|
} from "./components/FileViewer";
|
||||||
|
|
||||||
|
export { OperationDatePicker } from "./components/OperationDatePicker";
|
||||||
|
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
||||||
|
|
||||||
export { Badge } from "./components/badge";
|
export { Badge } from "./components/badge";
|
||||||
// export type { BadgeProps } from "./components/badge";
|
// export type { BadgeProps } from "./components/badge";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user