mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
auto allocation and batch managemnt, tracking the train
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts"
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { deriveTradeDirection } from './derive-trade-direction.util';
|
||||
|
||||
describe('deriveTradeDirection', () => {
|
||||
it('returns IMPORT when origin is Djibouti', () => {
|
||||
expect(deriveTradeDirection({ country: 'Djibouti' }, { country: 'Ethiopia' })).toBe(
|
||||
'IMPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns EXPORT when destination is Djibouti and origin is not', () => {
|
||||
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Djibouti' })).toBe(
|
||||
'EXPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns DOMESTIC for intra-Ethiopia routes', () => {
|
||||
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' })).toBe(
|
||||
'DOMESTIC',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
/** Derive booking/schedule trade direction from origin and destination yard countries. */
|
||||
export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface {
|
||||
name = 'AddSelectedForBatchStatus1781000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET
|
||||
status = 'SELECTED_FOR_BATCH',
|
||||
selected_for_batch_at = COALESCE(
|
||||
payment_deadline - INTERVAL '5 minutes',
|
||||
updated_at
|
||||
)
|
||||
WHERE status = 'AWAITING_PAYMENT'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET status = 'AWAITING_PAYMENT'
|
||||
WHERE status = 'SELECTED_FOR_BATCH'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS selected_for_batch_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings).
|
||||
*/
|
||||
export class AddDomesticWeightLimitTradeDirection1781000000004
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddDomesticWeightLimitTradeDirection1781000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values safely.
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -20,6 +22,7 @@ import { assertBookingStatus } from './booking-status.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
@@ -33,6 +36,8 @@ export class BookingContractService {
|
||||
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
buildContractSummary(booking: Booking): string {
|
||||
@@ -203,6 +208,9 @@ export class BookingContractService {
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
if (role === 'STAFF' && updated?.trainScheduleId) {
|
||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||
}
|
||||
try {
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
|
||||
@@ -22,7 +22,7 @@ export class BookingPaymentService {
|
||||
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'AWAITING_PAYMENT', '']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkEtb: Rate = {
|
||||
id: 'rate-intercity-bulk-etb',
|
||||
rateType: 'INTERCITY_BULK',
|
||||
currency: 'ETB',
|
||||
rateValue: 1900,
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerEtb: Rate = {
|
||||
id: 'rate-intercity-container-etb',
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
currency: 'ETB',
|
||||
rateValue: 25000,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices domestic bulk using INTERCITY_BULK and cargo tons', async () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].amount).toBe(1900 * 120);
|
||||
});
|
||||
|
||||
it('prices domestic container using INTERCITY_CONTAINER fallback', async () => {
|
||||
const booking = {
|
||||
id: 'b-2',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -275,7 +275,9 @@ export class BookingPricingService {
|
||||
? isBulk
|
||||
? 'BULK_EXPORT'
|
||||
: 'CONTAINER_EXPORT'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
: isBulk
|
||||
? 'INTERCITY_BULK'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
@@ -301,7 +303,10 @@ export class BookingPricingService {
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const amount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
@@ -29,6 +29,7 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,6 +43,7 @@ import { PaymentModule } from '../payment/payment.module';
|
||||
BookingContractSignature,
|
||||
]),
|
||||
PaymentModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
|
||||
@@ -720,6 +720,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
findBatchPool(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
@@ -748,13 +749,33 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status = 'AWAITING_PAYMENT'`)
|
||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
|
||||
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
'scheduleBooking',
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status = 'PAID'`)
|
||||
.andWhere('scheduleBooking.id IS NULL')
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
@@ -54,6 +57,36 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
) {}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
provided?: string,
|
||||
): Promise<string> {
|
||||
const yards = await this.dataSource.getRepository(Yard).find({
|
||||
where: { id: In([originYardId, destinationYardId]) },
|
||||
});
|
||||
const origin = yards.find((y) => y.id === originYardId);
|
||||
const destination = yards.find((y) => y.id === destinationYardId);
|
||||
if (!origin) {
|
||||
throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||||
}
|
||||
if (!destination) {
|
||||
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
throw new BadRequestException('Origin and destination yards must differ');
|
||||
}
|
||||
|
||||
const expected = deriveTradeDirection(origin, destination);
|
||||
if (provided && provided !== expected) {
|
||||
throw new BadRequestException(
|
||||
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Generate a unique booking reference number. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -230,6 +263,12 @@ export class BookingsService {
|
||||
containers,
|
||||
});
|
||||
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
@@ -240,7 +279,7 @@ export class BookingsService {
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isGovernment,
|
||||
allowConsolidation,
|
||||
@@ -267,7 +306,7 @@ export class BookingsService {
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
@@ -362,6 +401,14 @@ export class BookingsService {
|
||||
|
||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||
|
||||
const originYardId = dto.originYardId ?? existing.originYardId;
|
||||
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(
|
||||
@@ -375,7 +422,7 @@ export class BookingsService {
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
@@ -401,6 +448,7 @@ export class BookingsService {
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
|
||||
@@ -26,6 +26,8 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'EXPIRED',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
@@ -278,10 +280,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
/** End of the 1h pay window once the booking is AWAITING_PAYMENT. */
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
/** When the batch engine picked this booking and opened the pay window. */
|
||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||
selectedForBatchAt?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class CreateLocomotiveDto {
|
||||
@ApiProperty({ example: 'LOCO-001' })
|
||||
@@ -24,6 +28,11 @@ export class CreateLocomotiveDto {
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@@ -13,4 +17,9 @@ export class FilterLocomotivesDto {
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveReadiness,
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,6 +24,7 @@ export class LocomotivesService {
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}),
|
||||
},
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
@@ -34,6 +42,7 @@ export class LocomotivesService {
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
readiness: (dto.readiness as LocomotiveReadiness) ?? WagonReadiness.ImportReady,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
@@ -67,6 +76,10 @@ export class LocomotivesService {
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
readiness:
|
||||
dto.readiness === undefined
|
||||
? locomotive.readiness
|
||||
: (dto.readiness as LocomotiveReadiness),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
@@ -7,11 +7,12 @@ import { PaymentRepository } from "./payment.repository";
|
||||
import { WebhookController } from "./webhooks/webhook.controller";
|
||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||
import { TelebirrProvider } from "@edr/payment-providers";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule],
|
||||
imports: [HttpModule, ConfigModule, forwardRef(() => TrainSchedulingModule)],
|
||||
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||
controllers: [PaymentController, WebhookController],
|
||||
exports: [PaymentService]
|
||||
})
|
||||
export class PaymentModule { }
|
||||
export class PaymentModule { }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from "@edr/payment-providers";
|
||||
import { ProviderInitiationInput } from "@edr/types"
|
||||
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
|
||||
@@ -33,6 +36,8 @@ export class PaymentService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
async initBookingTelebirr(
|
||||
@@ -125,15 +130,12 @@ export class PaymentService {
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
const booking = await mg.findOne(Booking, { where: { id: resp.refId } })
|
||||
if (booking?.status === "AWAITING_PAYMENT") {
|
||||
// Batch flow: mark paid but keep the reservation — the batch settle job allocates it.
|
||||
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
|
||||
} else {
|
||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||
}
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
||||
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
|
||||
})
|
||||
if (resp.type === "booking") {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(resp.refId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: result.status
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||
import { PaymentRepository } from '../../payment.repository';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Booking } from '../../../bookings/entities/booking.entity';
|
||||
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
|
||||
import { BookingBatchService } from '../../../train-scheduling/booking-batch.service';
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
@@ -13,6 +14,8 @@ export class TelebirrWebhookService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||
@@ -40,6 +43,7 @@ export class TelebirrWebhookService {
|
||||
{ id: payment.refId },
|
||||
{ paymentStatus: "PAID" },
|
||||
);
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(payment.refId);
|
||||
}
|
||||
break;
|
||||
case ProviderPaymentStatus.FAILED:
|
||||
@@ -50,4 +54,4 @@ export class TelebirrWebhookService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
|
||||
export class CreateWeightLimitRuleDto {
|
||||
@ApiProperty({ description: 'FK to container_types.id' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' })
|
||||
@ApiProperty({
|
||||
enum: TRADE_DIRECTIONS,
|
||||
description: 'Trade direction: IMPORT, EXPORT, BOTH, or DOMESTIC',
|
||||
})
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
getBatchWindowForTimestamp,
|
||||
listBatchWindowsForDate,
|
||||
listBatchWindowsForBookings,
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
} from './batch-window.util';
|
||||
|
||||
describe('batch-window.util', () => {
|
||||
it('maps 20:15 EAT to the 19:00–22:00 window', () => {
|
||||
// 20:15 EAT = 17:15 UTC on 11 Jun 2026
|
||||
const ts = new Date('2026-06-11T17:15:00.000Z');
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
|
||||
expect(window.label).toContain('19:00');
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('maps 08:30 EAT to the 07:00–10:00 window', () => {
|
||||
const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('10:00');
|
||||
});
|
||||
|
||||
it('maps 02:00 EAT to the previous day 22:00–07:00 window', () => {
|
||||
const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('lists six windows for a calendar day', () => {
|
||||
const ref = new Date('2026-06-11T12:00:00.000Z');
|
||||
const windows = listBatchWindowsForDate(ref);
|
||||
expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length);
|
||||
expect(windows[0].label).toContain('07:00');
|
||||
expect(windows[windows.length - 1].label).toContain('22:00');
|
||||
});
|
||||
|
||||
it('includes cross-day overnight window when booking signed at 00:02 EAT', () => {
|
||||
// 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:00–07:00 window
|
||||
const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z');
|
||||
const scheduleDate = new Date('2026-06-12T06:00:00.000Z');
|
||||
const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate);
|
||||
const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00'));
|
||||
expect(overnight).toBeDefined();
|
||||
expect(overnight!.label).toContain('11 Jun 2026');
|
||||
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
|
||||
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;
|
||||
|
||||
export interface BatchWindow {
|
||||
key: string;
|
||||
label: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
type EatDateParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function eatParts(date: Date): EatDateParts {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? 0);
|
||||
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute = 0,
|
||||
): Date {
|
||||
// EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
|
||||
// for Africa/Addis_Ababa the offset is fixed.
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
|
||||
return new Date(utcMs);
|
||||
}
|
||||
|
||||
function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
|
||||
const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${endTime} EAT`;
|
||||
}
|
||||
|
||||
function windowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BatchWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
let endYear = year;
|
||||
let endMonth = month;
|
||||
let endDay = day;
|
||||
let endHour: number;
|
||||
let endHourLabel: string;
|
||||
|
||||
const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
|
||||
if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
|
||||
endHour = 7;
|
||||
endHourLabel = '07:00';
|
||||
const next = new Date(eatToUtc(year, month, day, 0));
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
const nextParts = eatParts(next);
|
||||
endYear = nextParts.year;
|
||||
endMonth = nextParts.month;
|
||||
endDay = nextParts.day;
|
||||
} else {
|
||||
endHour = BATCH_WINDOW_START_HOURS[idx + 1];
|
||||
endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
const end = eatToUtc(endYear, endMonth, endDay, endHour);
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endHourLabel),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
|
||||
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
|
||||
if (hour < 7) {
|
||||
const prev = new Date(eatToUtc(year, month, day, 0));
|
||||
prev.setUTCDate(prev.getUTCDate() - 1);
|
||||
const prevParts = eatParts(prev);
|
||||
return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
|
||||
}
|
||||
|
||||
let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
|
||||
for (const h of BATCH_WINDOW_START_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
|
||||
return windowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/** All six intake windows for an EAT calendar day (includes overnight 22:00–07:00). */
|
||||
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
|
||||
const { year, month, day } = eatParts(reference);
|
||||
return BATCH_WINDOW_START_HOURS.map((startHour) =>
|
||||
windowFromEatStart(year, month, day, startHour),
|
||||
);
|
||||
}
|
||||
|
||||
export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
|
||||
return a.start.getTime() - b.start.getTime();
|
||||
}
|
||||
|
||||
/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
|
||||
export function listBatchWindowsForBookings(
|
||||
timestamps: Array<Date | null | undefined>,
|
||||
referenceDate: Date,
|
||||
): BatchWindow[] {
|
||||
const byKey = new Map<string, BatchWindow>();
|
||||
for (const w of listBatchWindowsForDate(referenceDate)) {
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
for (const ts of timestamps) {
|
||||
if (!ts) continue;
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
return [...byKey.values()].sort(compareBatchWindows);
|
||||
}
|
||||
|
||||
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
|
||||
export function groupByBatchWindow<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
referenceDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BatchWindow | null; items: T[] }> {
|
||||
const timestamps = items.map(getTimestamp);
|
||||
const windows = listBatchWindowsForBookings(timestamps, referenceDate);
|
||||
const map = new Map<string, { window: BatchWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of windows) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestamp(item);
|
||||
if (!ts) {
|
||||
map.get(pendingKey)!.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
if (!map.has(w.key)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -4,14 +4,15 @@
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
||||
export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '*/3 * * * *';
|
||||
export const BATCH_CRON = '*/5 * * * *';
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||
export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
@@ -22,3 +23,9 @@ export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
* against the locomotive's max train length.
|
||||
*/
|
||||
export const DEFAULT_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default NW5 flat wagon length for container bookings (m). */
|
||||
export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
describe('BookingBatchService — PAID reconcile', () => {
|
||||
const scheduleId = 'schedule-1';
|
||||
const bookingId = 'booking-1';
|
||||
|
||||
const paidBooking = {
|
||||
id: bookingId,
|
||||
reference: 'BK-2026-000034',
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
cargoTotalWeightVgm: 20,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let service: BookingBatchService;
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let trainScheduleBookingsRepository: {
|
||||
existsForBooking: jest.Mock;
|
||||
createMany: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: {
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
existsForBooking: jest.fn().mockResolvedValue(false),
|
||||
createMany: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
maxWagons: 10,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
tryAutoWagonAllocation: jest.fn().mockResolvedValue({
|
||||
assignedBookingIds: [],
|
||||
deferred: [],
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||
const manager = {
|
||||
getRepository: () => bookingRepo,
|
||||
};
|
||||
await fn(manager);
|
||||
}),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||||
[{ trainScheduleId: scheduleId, bookingId }],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => {
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated is idempotent when already linked', async () => {
|
||||
trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true);
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
||||
|
||||
await service.processSchedule(scheduleId);
|
||||
|
||||
expect(fillSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(settleSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(reconcileSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
|
||||
const fillOrder = fillSpy.mock.invocationCallOrder[0];
|
||||
const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0];
|
||||
const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0];
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
});
|
||||
@@ -18,13 +18,24 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import {
|
||||
groupByBatchWindow,
|
||||
} from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
DEFAULT_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from './booking-batch.constants';
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -33,9 +44,12 @@ interface Capacity {
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| 'ALLOCATED'
|
||||
| 'AWAITING_PAYMENT'
|
||||
| 'SELECTED_FOR_BATCH'
|
||||
| 'READY'
|
||||
| 'WAITING'
|
||||
| 'PENDING_CONTRACT'
|
||||
| 'EXPIRED';
|
||||
@@ -47,10 +61,57 @@ export interface BatchBoardBooking {
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| 'NOT_ATTEMPTED'
|
||||
| 'ASSIGNED'
|
||||
| 'DEFERRED'
|
||||
| 'FAILED';
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: BatchBoardSchedule['locomotive'];
|
||||
capacity: BatchBoardSchedule['capacity'];
|
||||
counts: BatchBoardSchedule['counts'];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
@@ -67,15 +128,19 @@ export interface BatchBoardSchedule {
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
maxWagons: number;
|
||||
usedWagons: number;
|
||||
remainingWagons: number;
|
||||
/** Wagons on bookings already linked to the train (ALLOCATED only). */
|
||||
allocatedWagons: number;
|
||||
/** Train length used by allocated bookings (from wagon-type dimensions). */
|
||||
allocatedLengthMeters: number;
|
||||
maxLengthMeters: number | null;
|
||||
/** Weight committed on the train (allocated + selected-for-batch). */
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
awaitingPayment: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
@@ -103,20 +168,121 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** On boot, re-arm a settle timeout for any schedule that still has live reservations. */
|
||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
for (const s of open) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
||||
.where(`b.status = 'AWAITING_PAYMENT'`)
|
||||
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.andWhere('b.train_schedule_id IS NOT NULL')
|
||||
.getRawMany<{ scheduleId: string }>();
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
||||
enqueueScheduleProcessing(scheduleId: string): void {
|
||||
void this.processSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||
async processSchedule(scheduleId: string): Promise<void> {
|
||||
await this.fillSchedule(scheduleId);
|
||||
await this.settleDueReservations(scheduleId);
|
||||
await this.reconcilePaidUnlinked(scheduleId);
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||
*/
|
||||
async ensurePaidBookingAllocated(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!booking?.trainScheduleId) return;
|
||||
|
||||
const isBatchPaid =
|
||||
booking.status === 'SELECTED_FOR_BATCH' ||
|
||||
booking.status === 'AWAITING_PAYMENT' ||
|
||||
booking.status === 'PAID' ||
|
||||
booking.paymentStatus === 'PAID';
|
||||
if (!isBatchPaid) return;
|
||||
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
|
||||
} else if (booking.paymentStatus !== 'PAID') {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID' });
|
||||
}
|
||||
|
||||
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
await this.allocate(booking.trainScheduleId, booking, 'paid');
|
||||
this.logger.log(
|
||||
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
}
|
||||
|
||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (result.assignedBookingIds.length) {
|
||||
this.logger.log(
|
||||
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
|
||||
);
|
||||
}
|
||||
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
|
||||
const issue = result.issues.find((i) => i.bookingId === bookingId);
|
||||
this.logger.warn(
|
||||
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
||||
async confirmPaidAndAllocate(bookingId: string): Promise<void> {
|
||||
await this.ensurePaidBookingAllocated(bookingId);
|
||||
}
|
||||
|
||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
||||
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
|
||||
for (const booking of unlinked) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
this.logger.log(
|
||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cron entry point -----------------------------------------------------
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
@@ -127,7 +293,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
||||
for (const s of open) {
|
||||
try {
|
||||
await this.fillSchedule(s.id);
|
||||
await this.processSchedule(s.id);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -152,8 +318,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const perWagonLength = this.perWagonLength(rules);
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
@@ -165,7 +330,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, perWagonLength);
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
@@ -175,60 +340,223 @@ export class BookingBatchService implements OnModuleInit {
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
lengthMeters: need.lengthMeters,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
};
|
||||
});
|
||||
|
||||
const usedWagons = items
|
||||
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
|
||||
.reduce((sum, i) => sum + i.wagons, 0);
|
||||
const usedWeight = items
|
||||
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
|
||||
.reduce((sum, i) => sum + i.weightTons, 0);
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
board.push({
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: {
|
||||
maxWagons: s.maxWagons ?? 0,
|
||||
usedWagons,
|
||||
remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons),
|
||||
usedWeightTons: Math.round(usedWeight * 100) / 100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
},
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
bookings: items,
|
||||
});
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
|
||||
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
|
||||
throw new BadRequestException('Schedule is no longer active');
|
||||
}
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
let allocationPreview: Awaited<
|
||||
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
|
||||
>;
|
||||
try {
|
||||
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
} catch {
|
||||
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
|
||||
}
|
||||
const allocationByBooking = new Map(
|
||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||
);
|
||||
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
? (b.governmentInstitution ?? 'Government')
|
||||
: (b.company?.name ?? '—'),
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
lengthMeters: need.lengthMeters,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
|
||||
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
|
||||
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
|
||||
allocationIssue: alloc?.issue ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
const referenceDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupByBatchWindow(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
referenceDate,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
});
|
||||
|
||||
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
|
||||
const counts = emptyCounts();
|
||||
for (const b of bookingsInWindow) {
|
||||
if (b.state === 'ALLOCATED') counts.allocated += 1;
|
||||
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
|
||||
else if (b.state === 'READY') counts.ready += 1;
|
||||
else if (b.state === 'WAITING') counts.waiting += 1;
|
||||
else if (b.state === 'EXPIRED') counts.expired += 1;
|
||||
else counts.pendingContract += 1;
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const windows: BatchWindowGroup[] = [];
|
||||
for (const [key, bucket] of windowBuckets) {
|
||||
if (key === 'pending-contract' || !bucket.window) continue;
|
||||
const w = bucket.window;
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
bookings: bucket.items,
|
||||
});
|
||||
}
|
||||
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
|
||||
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
|
||||
ready: items.filter((i) => i.state === 'READY').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
windows,
|
||||
pendingContract: {
|
||||
key: 'pending-contract',
|
||||
label: 'Pending contract',
|
||||
start: '',
|
||||
end: '',
|
||||
counts: countFor(pendingBookings),
|
||||
bookings: pendingBookings,
|
||||
},
|
||||
allocationViolations: allocationPreview.violations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
|
||||
async runWagonAllocation(scheduleId: string) {
|
||||
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
private computeBoardCapacity(
|
||||
items: Array<{
|
||||
state: BatchBoardBookingState;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
): BatchBoardSchedule['capacity'] {
|
||||
const allocated = items.filter((i) => i.state === 'ALLOCATED');
|
||||
const committed = items.filter(
|
||||
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
|
||||
);
|
||||
return {
|
||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||
allocatedLengthMeters:
|
||||
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
|
||||
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
|
||||
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildScheduleSummary(
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
|
||||
ready: items.filter((i) => i.state === 'READY').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
bookings: items.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
|
||||
if (linked) return 'ALLOCATED';
|
||||
if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT';
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
return 'SELECTED_FOR_BATCH';
|
||||
}
|
||||
if (booking.status === 'EXPIRED') return 'EXPIRED';
|
||||
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
|
||||
if (booking.status === 'PAID') return 'WAITING';
|
||||
return 'PENDING_CONTRACT';
|
||||
}
|
||||
@@ -246,9 +574,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const perWagonLength = this.perWagonLength(rules);
|
||||
const limits = this.capacityLimits(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, perWagonLength);
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (budget.wagons <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
return;
|
||||
@@ -258,11 +587,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
let armed = false;
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, perWagonLength);
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
|
||||
if (!this.fits(need, budget)) {
|
||||
if (booking.isGovernment) {
|
||||
budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength);
|
||||
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
||||
@@ -281,6 +610,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
let anySettled = false;
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
|
||||
const expired = booking.paymentDeadline
|
||||
? booking.paymentDeadline.getTime() <= now
|
||||
: false;
|
||||
|
||||
if (paid) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
anySettled = true;
|
||||
} else if (expired) {
|
||||
await this.expire(booking);
|
||||
anySettled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anySettled) await this.fillSchedule(scheduleId);
|
||||
}
|
||||
|
||||
// ---- settle (1h after a batch) -------------------------------------------
|
||||
@@ -306,6 +660,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
await this.fillSchedule(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
private triggerWagonAllocation(scheduleId: string): void {
|
||||
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
|
||||
this.logger.warn(
|
||||
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- staff override actions ----------------------------------------------
|
||||
@@ -330,6 +693,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,6 +739,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: restoredStatus,
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
}
|
||||
@@ -391,14 +756,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its 1h pay window. */
|
||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
const deadline = new Date(Date.now() + PAYMENT_WINDOW_MS);
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'AWAITING_PAYMENT',
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
this.notifier.payNow(booking, deadline);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||
@@ -423,9 +790,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: new Date(),
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
@@ -434,6 +803,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
@@ -446,7 +816,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
scheduleId: string,
|
||||
need: Capacity,
|
||||
budget: Capacity,
|
||||
perWagonLength: number,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
@@ -472,10 +842,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
freed = this.add(freed, this.needFor(victim, perWagonLength));
|
||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
@@ -494,12 +865,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
private needFor(booking: Booking, perWagonLength: number): Capacity {
|
||||
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
|
||||
const wagons = this.wagonsFor(booking);
|
||||
return {
|
||||
wagons,
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
lengthMeters: wagons * perWagonLength,
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -527,29 +901,71 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */
|
||||
private capacityLimits(
|
||||
schedule: TrainSchedule,
|
||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): Capacity {
|
||||
const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity;
|
||||
const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity;
|
||||
const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity;
|
||||
const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity;
|
||||
): Promise<Capacity> {
|
||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
||||
? Number(rules.maxTrainWeightTons)
|
||||
: undefined,
|
||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
||||
? Number(rules.maxTrainLengthMeters)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
return {
|
||||
wagons: schedule.maxWagons ?? 0,
|
||||
weightTons: Math.min(locoWeight, ruleWeight),
|
||||
lengthMeters: Math.min(locoLength, ruleLength),
|
||||
wagons: derived.maxWagonSlots,
|
||||
weightTons: derived.maxWeightTons,
|
||||
lengthMeters: derived.maxLengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */
|
||||
private perWagonLength(rules: TrainSchedulingGlobalRules | null): number {
|
||||
const len = rules ? Number(rules.maxTrainLengthMeters) : 0;
|
||||
const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0;
|
||||
if (len > 0 && wagons > 0) return len / wagons;
|
||||
return DEFAULT_WAGON_LENGTH_METERS;
|
||||
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): Promise<void> {
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(schedule.id, { maxWagons: limits.wagons });
|
||||
schedule.maxWagons = limits.wagons;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
||||
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
||||
];
|
||||
}
|
||||
|
||||
private async loadWagonLengths(): Promise<WagonLengths> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
|
||||
return {
|
||||
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
|
||||
@@ -560,14 +976,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async remainingCapacity(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
perWagonLength: number,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, perWagonLength)),
|
||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
);
|
||||
return this.subtract(limits, used);
|
||||
|
||||
@@ -1,38 +1,64 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
|
||||
|
||||
/**
|
||||
* Stub notifier for the batch flow — **console.log only** for now.
|
||||
* Injectable so it can later be swapped for the real NotificationsService without
|
||||
* touching the batch engine.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger('BookingNotifier');
|
||||
private readonly logger = new Logger(BookingNotifierService.name);
|
||||
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
payNow(b: Booking, deadline: Date): void {
|
||||
this.logger.log(
|
||||
`PAY NOW — ${this.ref(b)} selected for schedule ${b.trainScheduleId}; pay before ${deadline.toISOString()} (1h).`,
|
||||
);
|
||||
private async notifyContact(
|
||||
b: Booking,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW');
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
this.logger.log(
|
||||
`ALLOCATED — ${this.ref(b)} secured on schedule ${b.trainScheduleId}${
|
||||
reason === 'gov' ? ' (government, unpaid)' : ''
|
||||
}.`,
|
||||
);
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`EXPIRED — ${this.ref(b)} did not pay in time; can move to another schedule or cancel (no re-approval).`,
|
||||
);
|
||||
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
||||
void this.notifyContact(b, msg, 'EXPIRED');
|
||||
}
|
||||
|
||||
scheduleFull(b: Booking): void {
|
||||
@@ -42,8 +68,7 @@ export class BookingNotifierService {
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`DISPLACED — ${this.ref(b)} bumped by a government booking; move to another schedule or cancel.`,
|
||||
);
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
type ContainerUnitForPlacement,
|
||||
} from './container-placement.util';
|
||||
|
||||
describe('container-placement.util', () => {
|
||||
const units: ContainerUnitForPlacement[] = [
|
||||
{
|
||||
bookingId: 'b1',
|
||||
bookingContainerId: 'c1',
|
||||
unitIndex: 0,
|
||||
label: 'REF · 1/1 · 20GP',
|
||||
teuSlots: 1,
|
||||
sizeFt: 20,
|
||||
containerNumber: 'ABCD1234567',
|
||||
},
|
||||
{
|
||||
bookingId: 'b2',
|
||||
bookingContainerId: 'c2',
|
||||
unitIndex: 0,
|
||||
label: 'REF2 · 1/1 · 40GP',
|
||||
teuSlots: 2,
|
||||
sizeFt: 40,
|
||||
containerNumber: null,
|
||||
},
|
||||
];
|
||||
|
||||
it('auto-fills placements across slots', () => {
|
||||
const placements = autoFillPlacements(units, [1, 2]);
|
||||
expect(placements).toHaveLength(2);
|
||||
expect(placements[0].sequenceNo).toBe(1);
|
||||
expect(placements[1].sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('reports missing container numbers only when placement is empty', () => {
|
||||
const placements = autoFillPlacements(units, [1, 2]);
|
||||
const issues = findMissingContainerNumberIssues(units, placements);
|
||||
expect(issues).toHaveLength(0);
|
||||
expect(placements[1].containerNumber).toMatch(/^TBD-/);
|
||||
});
|
||||
|
||||
it('generates TBD placeholder for missing container numbers', () => {
|
||||
const single: ContainerUnitForPlacement[] = [
|
||||
{
|
||||
bookingId: 'b2',
|
||||
bookingReference: 'BK-2026-000033',
|
||||
bookingContainerId: 'c2',
|
||||
unitIndex: 0,
|
||||
label: 'REF2 · 1/1 · 40GP',
|
||||
teuSlots: 2,
|
||||
sizeFt: 40,
|
||||
containerNumber: null,
|
||||
},
|
||||
];
|
||||
const placements = autoFillPlacements(single, [1]);
|
||||
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ContainerPlacementInput } from './wagon-plan.util';
|
||||
|
||||
export type ContainerUnitForPlacement = {
|
||||
bookingId: string;
|
||||
bookingReference?: string | null;
|
||||
bookingContainerId: string;
|
||||
unitIndex: number;
|
||||
label: string;
|
||||
teuSlots?: number;
|
||||
sizeFt?: number;
|
||||
containerNumber?: string | null;
|
||||
};
|
||||
|
||||
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||
const ref = unit.bookingReference ?? unit.bookingId.slice(0, 8);
|
||||
return `TBD-${ref}-${unit.unitIndex + 1}`;
|
||||
}
|
||||
|
||||
export function isPlaceholderContainerNumber(value: string | null | undefined): boolean {
|
||||
return Boolean(value?.trim().startsWith('TBD-'));
|
||||
}
|
||||
|
||||
export function resolveContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||
const trimmed = unit.containerNumber?.trim();
|
||||
return trimmed || placeholderContainerNumber(unit);
|
||||
}
|
||||
|
||||
export function autoFillPlacements(
|
||||
units: ContainerUnitForPlacement[],
|
||||
containerSlots: number[],
|
||||
): ContainerPlacementInput[] {
|
||||
if (!units.length || !containerSlots.length) return [];
|
||||
|
||||
const placements: ContainerPlacementInput[] = [];
|
||||
const MAX_TEU_PER_WAGON = 2;
|
||||
let currentSlotIndex = 0;
|
||||
let teuInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
||||
|
||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
|
||||
currentSlotIndex += 1;
|
||||
teuInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const sequenceNo =
|
||||
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
|
||||
containerSlots[containerSlots.length - 1] ??
|
||||
containerSlots[0];
|
||||
|
||||
placements.push({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo,
|
||||
containerNumber: resolveContainerNumber(unit),
|
||||
});
|
||||
|
||||
teuInCurrentSlot += teu;
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function findMissingContainerNumberIssues(
|
||||
units: ContainerUnitForPlacement[],
|
||||
placements: ContainerPlacementInput[],
|
||||
): Array<{ bookingId: string; issue: string }> {
|
||||
const issues: Array<{ bookingId: string; issue: string }> = [];
|
||||
const byUnit = new Map(
|
||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const placement = byUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||
if (!placement?.containerNumber?.trim()) {
|
||||
issues.push({
|
||||
bookingId: unit.bookingId,
|
||||
issue: `Missing container number for ${unit.label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function placementsForBookings(
|
||||
placements: ContainerPlacementInput[],
|
||||
bookingIds: Set<string>,
|
||||
units: ContainerUnitForPlacement[],
|
||||
): ContainerPlacementInput[] {
|
||||
const unitBookingIds = new Map(
|
||||
units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u.bookingId]),
|
||||
);
|
||||
return placements.filter((p) => {
|
||||
const bookingId = unitBookingIds.get(`${p.bookingContainerId}:${p.unitIndex}`);
|
||||
return bookingId ? bookingIds.has(bookingId) : false;
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,4 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
export function deriveScheduleDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
|
||||
export const deriveScheduleDirection = deriveTradeDirection;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableLocomotivesQueryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
const nw5 = { lengthMeters: 14, capacityTons: 70 };
|
||||
|
||||
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
|
||||
const heavyLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
|
||||
});
|
||||
|
||||
it('uses shortest wagon type when mixed types are present', () => {
|
||||
const longBulk = { lengthMeters: 18, capacityTons: 80 };
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, longBulk],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(
|
||||
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
|
||||
);
|
||||
});
|
||||
|
||||
it('computes booking length by freight type', () => {
|
||||
expect(
|
||||
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
|
||||
).toBe(28);
|
||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
|
||||
export type WagonTypeDimensions = {
|
||||
lengthMeters: number;
|
||||
capacityTons: number;
|
||||
};
|
||||
|
||||
export type LocomotiveLimits = {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
};
|
||||
|
||||
export type DerivedTrainCapacity = {
|
||||
maxWeightTons: number;
|
||||
maxLengthMeters: number;
|
||||
maxWagonSlots: number;
|
||||
};
|
||||
|
||||
const DEFAULT_WAGON_LENGTH_M = 14;
|
||||
const DEFAULT_WAGON_CAPACITY_T = 70;
|
||||
|
||||
/**
|
||||
* Derive train capacity from locomotive pull weight and train length.
|
||||
* Wagon count is NOT a fixed 53 — it is the minimum of:
|
||||
* - floor(maxLength / shortest wagon type length)
|
||||
* - floor(maxWeight / lightest wagon type capacity)
|
||||
*/
|
||||
export function deriveTrainCapacityFromLocomotive(
|
||||
locomotive: LocomotiveLimits,
|
||||
wagonTypes: WagonTypeDimensions[],
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): DerivedTrainCapacity {
|
||||
const maxWeightTons = Math.min(
|
||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
);
|
||||
const maxLengthMeters = Math.min(
|
||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
);
|
||||
|
||||
const types =
|
||||
wagonTypes.length > 0
|
||||
? wagonTypes
|
||||
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
|
||||
|
||||
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
|
||||
const minCapacity = Math.min(
|
||||
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
|
||||
);
|
||||
|
||||
const byLength =
|
||||
minLength > 0 && Number.isFinite(maxLengthMeters)
|
||||
? Math.floor(maxLengthMeters / minLength)
|
||||
: 0;
|
||||
const byWeight =
|
||||
minCapacity > 0 && Number.isFinite(maxWeightTons)
|
||||
? Math.floor(maxWeightTons / minCapacity)
|
||||
: byLength;
|
||||
|
||||
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
|
||||
maxWagonSlots,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
wagonCount: number,
|
||||
lengths: { container: number; bulk: number },
|
||||
): number {
|
||||
const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container;
|
||||
return wagonCount * perWagon;
|
||||
}
|
||||
|
||||
export function wagonTypeDimensionsFromEntity(wt: {
|
||||
lengthMeters?: number | string | null;
|
||||
capacityTons?: number | string | null;
|
||||
}): WagonTypeDimensions {
|
||||
return {
|
||||
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { AvailableLocomotivesQueryDto } from './dto/available-locomotives-query.dto';
|
||||
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
@@ -64,6 +65,22 @@ export class TrainSchedulingController {
|
||||
return this.bookingBatchService.getBatchBoard();
|
||||
}
|
||||
|
||||
@Get('batch-board/:scheduleId')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Batch board detail for one schedule with EAT 3h windows' })
|
||||
getBatchBoardDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Get('available-locomotives')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: 'List AVAILABLE locomotives filtered by route corridor readiness',
|
||||
})
|
||||
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
|
||||
}
|
||||
|
||||
@Get('bookable-schedules')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
|
||||
@@ -191,7 +208,14 @@ export class TrainSchedulingController {
|
||||
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
|
||||
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.bookingBatchService.fillSchedule(id);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
return this.bookingBatchService.getBatchBoardDetail(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/run-allocation')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Run wagon-level allocation for all eligible linked bookings' })
|
||||
async runAllocation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingBatchService.runWagonAllocation(id);
|
||||
}
|
||||
|
||||
@Patch('schedules/:id/booking-window')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
@@ -21,6 +21,7 @@ import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -35,7 +36,8 @@ import { BookingNotifierService } from './booking-notifier.service';
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainCheckpointEvent,
|
||||
]),
|
||||
BookingsModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
NotificationsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
@@ -25,6 +25,7 @@ const locomotive = {
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
};
|
||||
|
||||
const cw3 = {
|
||||
@@ -420,6 +421,9 @@ describe('TrainSchedulingService', () => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
|
||||
}
|
||||
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
|
||||
});
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
|
||||
@@ -572,4 +576,202 @@ describe('TrainSchedulingService', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
|
||||
const exportBooking = makeBooking(
|
||||
'exp-1',
|
||||
'BKG-EXP',
|
||||
50,
|
||||
1,
|
||||
'40FT',
|
||||
1,
|
||||
'2026-06-20T08:00:00.000Z',
|
||||
'yard-addis',
|
||||
'yard-djibouti',
|
||||
{
|
||||
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
|
||||
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
|
||||
},
|
||||
);
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
|
||||
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
wagonNumber: `WGN-${index}`,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.previewContainerTrainSchedule({
|
||||
bookingIds: ['exp-1'],
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-addis',
|
||||
destinationStationId: 'yard-djibouti',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
|
||||
const scheduleId = 'sched-assign-1';
|
||||
const trainSetId = 'train-set-1';
|
||||
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
direction: 'IMPORT',
|
||||
});
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId,
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
trainSet: {
|
||||
id: trainSetId,
|
||||
locomotive,
|
||||
wagons: [],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const wagonRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const trainSetWagonRepo = {
|
||||
delete: jest.fn(),
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (rows) =>
|
||||
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
|
||||
...r,
|
||||
id: `slot-${i + 1}`,
|
||||
})),
|
||||
),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Wagon) return wagonRepo;
|
||||
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
if (entity === TrainSetWagon) return trainSetWagonRepo;
|
||||
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
|
||||
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
|
||||
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
|
||||
return {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
}
|
||||
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
|
||||
cb(manager),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.assignBookingsToSchedule(
|
||||
scheduleId,
|
||||
{ bookingIds: ['b-pin'], containerPlacements: [] },
|
||||
'CONTAINER',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
|
||||
const routeId = 'route-export';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
}),
|
||||
};
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
});
|
||||
|
||||
it('returns all available locomotives on domestic routes', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
}),
|
||||
};
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,17 +75,56 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util';
|
||||
import {
|
||||
flipReadiness,
|
||||
requiredWagonReadiness,
|
||||
wagonReadinessMatchesSchedule,
|
||||
} from './wagon-readiness.util';
|
||||
import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
} from './booking-batch.constants';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
isPlaceholderContainerNumber,
|
||||
placementsForBookings,
|
||||
type ContainerUnitForPlacement,
|
||||
} from './container-placement.util';
|
||||
|
||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||
|
||||
export type BookingWagonAllocationStatus =
|
||||
| 'NOT_ATTEMPTED'
|
||||
| 'ASSIGNED'
|
||||
| 'DEFERRED'
|
||||
| 'FAILED';
|
||||
|
||||
export interface BookingWagonAllocationIssue {
|
||||
bookingId: string;
|
||||
status: BookingWagonAllocationStatus;
|
||||
issue: string | null;
|
||||
}
|
||||
|
||||
export interface WagonAllocationAttemptResult {
|
||||
assignedBookingIds: string[];
|
||||
deferred: DeferredBookingRow[];
|
||||
issues: BookingWagonAllocationIssue[];
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
maxWagonsPerTrain: 53,
|
||||
maxWagonsPerTrain: Math.floor(760 / 14),
|
||||
max20ftContainerWeightTons: 30,
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
};
|
||||
@@ -245,7 +284,9 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
|
||||
).maxWagonsPerTrain,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
|
||||
@@ -294,10 +335,11 @@ export class TrainSchedulingService {
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
maxTrainWeightTons: dto.maxTrainWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||||
};
|
||||
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto);
|
||||
const locomotive = schedule.trainSet.locomotive;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
freightType ?? null,
|
||||
@@ -329,7 +371,6 @@ export class TrainSchedulingService {
|
||||
const totalWeightTons = validation.summary.totalWeightTons;
|
||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||
|
||||
const locomotive = schedule.trainSet.locomotive;
|
||||
if (!locomotive) {
|
||||
throw new BadRequestException('Schedule train set has no locomotive');
|
||||
}
|
||||
@@ -473,6 +514,7 @@ export class TrainSchedulingService {
|
||||
(sb) => sb.bookingId !== bookingId,
|
||||
);
|
||||
if (remainingBookings.length === 0) {
|
||||
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||||
schedule.trainSetId,
|
||||
manager,
|
||||
@@ -617,7 +659,7 @@ export class TrainSchedulingService {
|
||||
paymentDeadline: null,
|
||||
})
|
||||
.where('train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`status = 'AWAITING_PAYMENT'`)
|
||||
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.execute();
|
||||
});
|
||||
|
||||
@@ -1068,6 +1110,14 @@ export class TrainSchedulingService {
|
||||
bulkWagonType,
|
||||
});
|
||||
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
wagonPlan,
|
||||
scheduleDirection,
|
||||
targetScheduleId,
|
||||
)),
|
||||
);
|
||||
|
||||
const placementRules = {
|
||||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
||||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||
@@ -1120,11 +1170,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
const availableLocomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
});
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
|
||||
if (!availableLocomotives.length) {
|
||||
violations.push('No available locomotive exists for scheduling');
|
||||
const readinessHint = requiredWagonReadiness(scheduleDirection);
|
||||
violations.push(
|
||||
readinessHint
|
||||
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule`
|
||||
: 'No available locomotive exists for scheduling',
|
||||
);
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
(l) =>
|
||||
@@ -1168,11 +1225,14 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTrainLimitConfig(dto?: {
|
||||
maxTrainWeightTons?: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
}): Promise<Required<TrainLimitConfig>> {
|
||||
private async resolveTrainLimitConfig(
|
||||
dto?: {
|
||||
maxTrainWeightTons?: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
},
|
||||
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
maxTrainWeightTons?: number;
|
||||
@@ -1180,25 +1240,73 @@ export class TrainSchedulingService {
|
||||
maxWagonsPerTrain?: number;
|
||||
}>('app.trainScheduling');
|
||||
|
||||
const ruleWeightCap =
|
||||
dto?.maxTrainWeightTons ??
|
||||
(row?.maxTrainWeightTons != null
|
||||
? Number(row.maxTrainWeightTons)
|
||||
: configured?.maxTrainWeightTons);
|
||||
const ruleLengthCap =
|
||||
dto?.maxTrainLengthMeters ??
|
||||
(row?.maxTrainLengthMeters != null
|
||||
? Number(row.maxTrainLengthMeters)
|
||||
: configured?.maxTrainLengthMeters);
|
||||
|
||||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||
|
||||
if (locomotive) {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
maxTrainWeightTons: ruleWeightCap,
|
||||
maxTrainLengthMeters: ruleLengthCap,
|
||||
},
|
||||
);
|
||||
return {
|
||||
maxWeightTons: derived.maxWeightTons,
|
||||
maxLengthMeters: derived.maxLengthMeters,
|
||||
maxWagonsPerTrain:
|
||||
dto?.maxWagonsPerTrain != null
|
||||
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
|
||||
: derived.maxWagonSlots,
|
||||
max20ftContainerWeightTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftContainerWeightTons) ||
|
||||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||||
),
|
||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftPairWeightDiffTons) ||
|
||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const maxWeightTons = this.positiveNumber(
|
||||
dto?.maxTrainWeightTons,
|
||||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
||||
);
|
||||
const maxLengthMeters = this.positiveNumber(
|
||||
dto?.maxTrainLengthMeters,
|
||||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
||||
);
|
||||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
||||
wagonTypes,
|
||||
);
|
||||
|
||||
return {
|
||||
maxWeightTons: this.positiveNumber(
|
||||
dto?.maxTrainWeightTons,
|
||||
Number(row?.maxTrainWeightTons) ||
|
||||
configured?.maxTrainWeightTons ||
|
||||
DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
||||
),
|
||||
maxLengthMeters: this.positiveNumber(
|
||||
dto?.maxTrainLengthMeters,
|
||||
Number(row?.maxTrainLengthMeters) ||
|
||||
configured?.maxTrainLengthMeters ||
|
||||
DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
||||
),
|
||||
maxWeightTons,
|
||||
maxLengthMeters,
|
||||
maxWagonsPerTrain: Math.floor(
|
||||
this.positiveNumber(
|
||||
dto?.maxWagonsPerTrain,
|
||||
Number(row?.maxWagonsPerTrain) ||
|
||||
configured?.maxWagonsPerTrain ||
|
||||
DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain,
|
||||
row?.maxWagonsPerTrain != null
|
||||
? Number(row.maxWagonsPerTrain)
|
||||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
||||
),
|
||||
),
|
||||
max20ftContainerWeightTons: this.positiveNumber(
|
||||
@@ -1213,6 +1321,19 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSchedulingWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
||||
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
||||
];
|
||||
}
|
||||
|
||||
private async resolveScheduleDirection(
|
||||
targetScheduleId: string | undefined,
|
||||
bookings: Booking[],
|
||||
@@ -1282,26 +1403,48 @@ export class TrainSchedulingService {
|
||||
slots: TrainSetWagon[],
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||||
|
||||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||||
const candidates = wagons.filter((wagon) => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
|
||||
const planSlots = [...slots]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((slot) => ({
|
||||
sequenceNo: slot.sequenceNo,
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||||
trainSetWagonId: slot.id,
|
||||
}));
|
||||
|
||||
const unpinnable = this.findUnpinnableWagonSlots(
|
||||
planSlots,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
message: 'Insufficient physical wagons to pin all train slots',
|
||||
violations: unpinnable,
|
||||
});
|
||||
}
|
||||
|
||||
const physical = candidates[0];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
for (const slot of planSlots) {
|
||||
const physical = this.pickPhysicalWagonForSlot(
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
await manager.getRepository(TrainSetWagon).update(slot.id, {
|
||||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||||
physicalWagonId: physical.id,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await manager.getRepository(Wagon).update(physical.id, {
|
||||
trainSetWagonId: slot.id,
|
||||
trainSetWagonId: slot.trainSetWagonId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
@@ -1309,6 +1452,76 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-assign check: every planned slot must have a matching physical wagon. */
|
||||
private async validatePhysicalFleetForPlan(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
scheduleDirection: string | null,
|
||||
targetScheduleId?: string,
|
||||
): Promise<string[]> {
|
||||
if (!wagonPlan.length) return [];
|
||||
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find();
|
||||
return this.findUnpinnableWagonSlots(
|
||||
wagonPlan.map((slot) => ({
|
||||
sequenceNo: slot.sequenceNo,
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: slot.wagonTypeCode,
|
||||
})),
|
||||
wagons,
|
||||
targetScheduleId,
|
||||
scheduleDirection,
|
||||
);
|
||||
}
|
||||
|
||||
private findUnpinnableWagonSlots(
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
const required = requiredWagonReadiness(scheduleDirection);
|
||||
const readinessLabel = required ?? 'any readiness';
|
||||
|
||||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||||
const physical = this.pickPhysicalWagonForSlot(
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assignedPhysicalIds.add(physical.id);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private pickPhysicalWagonForSlot(
|
||||
slot: { wagonTypeId: string },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
): Wagon | undefined {
|
||||
return wagons.find((wagon) => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = scheduleId
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
|
||||
});
|
||||
}
|
||||
|
||||
private positiveNumber(value: number | undefined, fallback: number): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||||
@@ -1639,6 +1852,27 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
const requiredReadiness = requiredWagonReadiness(direction);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
|
||||
if (!requiredReadiness) {
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
|
||||
}
|
||||
|
||||
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
@@ -1796,4 +2030,221 @@ export class TrainSchedulingService {
|
||||
}
|
||||
return SchedulingStatus.Eligible;
|
||||
}
|
||||
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
): Promise<WagonAllocationAttemptResult> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
return this.buildAllocationAttempt(schedule, false);
|
||||
}
|
||||
|
||||
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
|
||||
async tryAutoWagonAllocation(
|
||||
scheduleId: string,
|
||||
): Promise<WagonAllocationAttemptResult> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
return this.buildAllocationAttempt(schedule, true);
|
||||
}
|
||||
|
||||
private async buildAllocationAttempt(
|
||||
schedule: TrainSchedule,
|
||||
performAssign: boolean,
|
||||
): Promise<WagonAllocationAttemptResult> {
|
||||
const empty: WagonAllocationAttemptResult = {
|
||||
assignedBookingIds: [],
|
||||
deferred: [],
|
||||
issues: [],
|
||||
violations: [],
|
||||
};
|
||||
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
return {
|
||||
...empty,
|
||||
violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
|
||||
};
|
||||
}
|
||||
|
||||
const linkedBookings = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const eligible = linkedBookings.filter(
|
||||
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
|
||||
const previewDto = {
|
||||
bookingIds: eligible.map((b) => b.id),
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
try {
|
||||
validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
false,
|
||||
limits,
|
||||
schedule.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Validation failed';
|
||||
return {
|
||||
...empty,
|
||||
violations: [message],
|
||||
issues: eligible.map((b) => ({
|
||||
bookingId: b.id,
|
||||
status: 'FAILED' as const,
|
||||
issue: message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const fittingIds = new Set(validation.bookings.map((b) => b.id));
|
||||
const deferredMap = new Map(
|
||||
validation.deferredBookings.map((d) => [d.id, d.reason]),
|
||||
);
|
||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
const missingNumbers = findMissingContainerNumberIssues(units, placements);
|
||||
const missingByBooking = new Map<string, string>();
|
||||
for (const m of missingNumbers) {
|
||||
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
|
||||
}
|
||||
const placeholderWarnings = new Map<string, string>();
|
||||
for (const p of placements) {
|
||||
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
|
||||
const unit = units.find(
|
||||
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
|
||||
);
|
||||
if (unit && !placeholderWarnings.has(unit.bookingId)) {
|
||||
placeholderWarnings.set(
|
||||
unit.bookingId,
|
||||
'Container number auto-assigned — verify before dispatch.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const assignableIds = validation.bookings
|
||||
.filter((b) => !missingByBooking.has(b.id))
|
||||
.map((b) => b.id);
|
||||
const assignableSet = new Set(assignableIds);
|
||||
const assignPlacements = placementsForBookings(
|
||||
placements,
|
||||
assignableSet,
|
||||
units,
|
||||
);
|
||||
|
||||
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
|
||||
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
|
||||
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
|
||||
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
|
||||
}
|
||||
if (missingByBooking.has(b.id)) {
|
||||
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
|
||||
}
|
||||
if (deferredMap.has(b.id)) {
|
||||
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
|
||||
}
|
||||
if (!fittingIds.has(b.id)) {
|
||||
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
|
||||
return {
|
||||
bookingId: b.id,
|
||||
status: 'FAILED',
|
||||
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
|
||||
};
|
||||
}
|
||||
if (wagonAssignedIds.has(b.id)) {
|
||||
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
|
||||
}
|
||||
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
|
||||
});
|
||||
|
||||
const result: WagonAllocationAttemptResult = {
|
||||
assignedBookingIds: [],
|
||||
deferred: validation.deferredBookings,
|
||||
issues,
|
||||
violations: validation.violations,
|
||||
};
|
||||
|
||||
if (!performAssign || !assignableIds.length) return result;
|
||||
|
||||
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
|
||||
if (needsPlacements && !assignPlacements.length) {
|
||||
return {
|
||||
...result,
|
||||
violations: [...result.violations, 'Container placements could not be generated'],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.assignBookingsToSchedule(
|
||||
schedule.id,
|
||||
{
|
||||
bookingIds: assignableIds,
|
||||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
result.assignedBookingIds = assignableIds;
|
||||
for (const issue of result.issues) {
|
||||
if (assignableSet.has(issue.bookingId)) {
|
||||
issue.status = 'ASSIGNED';
|
||||
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof BadRequestException
|
||||
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
|
||||
'; ',
|
||||
) ??
|
||||
(err.getResponse() as { message?: string }).message ??
|
||||
err.message)
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Allocation failed';
|
||||
result.violations = [...result.violations, message];
|
||||
for (const issue of result.issues) {
|
||||
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
|
||||
issue.status = 'FAILED';
|
||||
issue.issue = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||||
if (!wagonIds.length) return new Set();
|
||||
|
||||
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
|
||||
where: { trainSetWagonId: In(wagonIds) },
|
||||
select: ['bookingId'],
|
||||
});
|
||||
return new Set(allocations.map((a) => a.bookingId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,8 +415,10 @@ export function validateTrainLimits(
|
||||
const violations: string[] = [];
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.floor(maxLengthMeters / wagonLength);
|
||||
|
||||
const totalWeightTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||
@@ -451,9 +453,13 @@ export function validateMixedTrainLimits(
|
||||
wagonTypes: WagonType[],
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const minWagonLength = Math.min(
|
||||
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
|
||||
14,
|
||||
);
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
|
||||
|
||||
return validateTrainLimits(
|
||||
wagonPlan,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListWagonsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WagonStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonStatus)
|
||||
status?: WagonStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: WagonReadiness })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonReadiness)
|
||||
readiness?: WagonReadiness;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
wagonTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'wagonNumber' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
@@ -29,7 +30,7 @@ export class WagonsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all wagons' })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
findAll(@Query() query: ListWagonsQueryDto) {
|
||||
return this.wagonsService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
@@ -31,16 +32,16 @@ export class WagonsService {
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
||||
const search = query.search?.trim();
|
||||
const status = query.status?.trim();
|
||||
const readiness = query.readiness?.trim();
|
||||
const trainId = query.trainId?.trim();
|
||||
const filters = {
|
||||
...(status ? { status: status as Wagon['status'] } : {}),
|
||||
...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}),
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
const filters: FindOptionsWhere<Wagon> = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.readiness ? { readiness: query.readiness } : {}),
|
||||
...(trainId ? { trainId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
};
|
||||
|
||||
if (search) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity
|
||||
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
|
||||
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
|
||||
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
|
||||
import { Route } from "../modules/routes/entities/route.entity";
|
||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
|
||||
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
|
||||
@@ -32,6 +34,7 @@ export class PricingDataSeeder {
|
||||
const rRepo = manager.getRepository(Rate);
|
||||
|
||||
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
|
||||
await this.seedDomesticRoute(manager, yRepo);
|
||||
await this.seedWeightLimits(wlRepo, ctRepo);
|
||||
await this.seedPriorityRules(prRepo);
|
||||
const containerTypes = await ctRepo.find();
|
||||
@@ -287,6 +290,40 @@ export class PricingDataSeeder {
|
||||
);
|
||||
}
|
||||
|
||||
private async seedDomesticRoute(manager: any, yRepo: any): Promise<void> {
|
||||
const addis = await yRepo.findOneBy({ code: "ADDIS_ABABA" });
|
||||
const direDawa = await yRepo.findOneBy({ code: "DIRE_DAWA" });
|
||||
if (!addis || !direDawa) return;
|
||||
|
||||
const routeRepo = manager.getRepository(Route);
|
||||
const milestoneRepo = manager.getRepository(RouteMilestone);
|
||||
const routeName = "Addis Ababa → Dire Dawa";
|
||||
let route = await routeRepo.findOneBy({ name: routeName });
|
||||
if (!route) {
|
||||
route = await routeRepo.save(
|
||||
routeRepo.create({
|
||||
name: routeName,
|
||||
originYardId: addis.id,
|
||||
destinationYardId: direDawa.id,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
await milestoneRepo.save([
|
||||
milestoneRepo.create({
|
||||
routeId: route.id,
|
||||
yardId: addis.id,
|
||||
sequenceNo: 1,
|
||||
}),
|
||||
milestoneRepo.create({
|
||||
routeId: route.id,
|
||||
yardId: direDawa.id,
|
||||
sequenceNo: 2,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa");
|
||||
}
|
||||
}
|
||||
|
||||
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
await wlRepo.createQueryBuilder().delete().execute();
|
||||
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
||||
@@ -317,6 +354,18 @@ export class PricingDataSeeder {
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: twenty.id,
|
||||
tradeDirection: "DOMESTIC",
|
||||
maxVgmTons: 26,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: forty.id,
|
||||
tradeDirection: "DOMESTIC",
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
]);
|
||||
this.logger.log("Seeded weight limit rules");
|
||||
}
|
||||
@@ -472,6 +521,20 @@ export class PricingDataSeeder {
|
||||
rateValue: 25000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 35,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 1900,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
|
||||
@@ -39,6 +39,7 @@ import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
@@ -265,6 +266,10 @@ const App = () => {
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={<BatchScheduleDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
|
||||
@@ -136,9 +136,17 @@ export function AllocateBookingWizard({
|
||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(
|
||||
scheduleMode === "new" && routeId ? routeId : undefined,
|
||||
);
|
||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleMode === "new") {
|
||||
setLocomotiveId("");
|
||||
}
|
||||
}, [routeId, scheduleMode]);
|
||||
|
||||
const matchingSchedules = useMemo(
|
||||
() =>
|
||||
(schedulesQuery.data ?? []).filter(
|
||||
@@ -511,6 +519,7 @@ export function AllocateBookingWizard({
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code} · ${
|
||||
@@ -520,6 +529,10 @@ export function AllocateBookingWizard({
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
@@ -43,12 +43,15 @@ export const QUERY_KEYS = {
|
||||
ROOT: ["train-scheduling"] as const,
|
||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
||||
locomotives: () => ["train-scheduling", "locomotives"] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -138,8 +138,12 @@ export const URL_CONSTANTS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
|
||||
@@ -154,9 +154,15 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-amber-700",
|
||||
stage: 3,
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_PAYMENT: {
|
||||
title: "Awaiting Payment",
|
||||
description: "Selected in a batch — pay within 1 hour to secure the slot.",
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { FleetListFilters, FleetResourceSlug } from "@/services/fleet/fleet.service";
|
||||
import { fleetService } from "@/services/fleet/fleet.service";
|
||||
|
||||
export function useFleetList(slug: FleetResourceSlug) {
|
||||
export function useFleetList(slug: FleetResourceSlug, filters?: FleetListFilters) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.FLEET.list(slug),
|
||||
queryFn: () => fleetService.list(slug),
|
||||
queryKey: [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
|
||||
queryFn: () => fleetService.list(slug, filters),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,27 @@ export const useBatchBoard = () =>
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useBatchBoardDetail = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getBatchBoardDetail(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useRunAllocation = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => trainSchedulingService.runAllocation(scheduleId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""),
|
||||
@@ -43,10 +64,11 @@ export const useEligibleBookings = (
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useAvailableLocomotives = () =>
|
||||
export const useAvailableLocomotives = (routeId?: string) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(routeId),
|
||||
enabled: routeId ? Boolean(routeId) : true,
|
||||
});
|
||||
|
||||
export const useBatchActions = (scheduleId?: string) => {
|
||||
@@ -54,10 +76,14 @@ export const useBatchActions = (scheduleId?: string) => {
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
|
||||
if (scheduleId) {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { wagonService } from '@/services/wagon.service';
|
||||
|
||||
export type WagonListFilters = import('@/services/wagon.service').WagonListFilters;
|
||||
|
||||
export const wagonKeys = {
|
||||
all: ['wagons'] as const,
|
||||
list: (filters?: WagonListFilters) => [...wagonKeys.all, 'list', filters ?? {}] as const,
|
||||
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
|
||||
details: () => [...wagonKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...wagonKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useWagons() {
|
||||
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
|
||||
export function useWagons(filters?: WagonListFilters) {
|
||||
return useQuery({
|
||||
queryKey: wagonKeys.list(filters),
|
||||
queryFn: () => wagonService.getAll(filters ?? {}).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export const useGetWagons = useWagons;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -113,6 +114,35 @@ const newLine = (): ContainerLine => ({
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
|
||||
|
||||
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
|
||||
function deriveTradeDirectionFromYards(
|
||||
origin?: RefNamed | null,
|
||||
destination?: RefNamed | null,
|
||||
): TradeDirection | null {
|
||||
const originCountry = origin?.country?.trim();
|
||||
const destinationCountry = destination?.country?.trim();
|
||||
if (!originCountry || !destinationCountry) return null;
|
||||
if (originCountry === "Djibouti") return "IMPORT";
|
||||
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
|
||||
const tradeDirectionLabel: Record<TradeDirection, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
DOMESTIC: "Domestic",
|
||||
};
|
||||
|
||||
const parseBookingError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/** Section card with a colored icon chip header. */
|
||||
function FormSection({
|
||||
icon: Icon,
|
||||
@@ -168,7 +198,6 @@ export default function NewBookingPage() {
|
||||
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
|
||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [tradeDirection, setTradeDirection] = useState("IMPORT");
|
||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||
|
||||
// container freight
|
||||
@@ -223,7 +252,16 @@ export default function NewBookingPage() {
|
||||
? new Date(scheduledDate).toISOString()
|
||||
: "";
|
||||
|
||||
const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const yardRecords = refData?.yard ?? [];
|
||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setTrainScheduleId(null);
|
||||
}, [originYardId, destinationYardId]);
|
||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
|
||||
@@ -265,13 +303,18 @@ export default function NewBookingPage() {
|
||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||
|
||||
const scheduleSatisfied =
|
||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(originYardId) &&
|
||||
Boolean(destinationYardId) &&
|
||||
!sameYard &&
|
||||
Boolean(trainScheduleId) &&
|
||||
Boolean(tradeDirection) &&
|
||||
scheduleSatisfied &&
|
||||
Boolean(serviceTypeId) &&
|
||||
(Boolean(selectedSchedule) || Boolean(scheduledDate)) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
@@ -291,7 +334,7 @@ export default function NewBookingPage() {
|
||||
freightType,
|
||||
contractType: "NEW",
|
||||
equipmentReturn,
|
||||
tradeDirection,
|
||||
tradeDirection: tradeDirection!,
|
||||
paymentCurrency,
|
||||
isHazardous,
|
||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||
@@ -324,7 +367,7 @@ export default function NewBookingPage() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
},
|
||||
onError: () => toast.error("Failed to create booking"),
|
||||
onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -460,23 +503,30 @@ export default function NewBookingPage() {
|
||||
error={sameYard ? "Same as origin" : undefined}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label="Train schedule"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
/>
|
||||
<Group grow>
|
||||
{hasBookableSchedules ? (
|
||||
<Select
|
||||
label="Train schedule"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
/>
|
||||
) : originYardId && destinationYardId ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No open train schedule on this route — set a preferred departure below. Staff can
|
||||
link a schedule later.
|
||||
</Text>
|
||||
) : null}
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Service type"
|
||||
placeholder="Select service"
|
||||
@@ -486,16 +536,24 @@ export default function NewBookingPage() {
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
data={[
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
{ value: "DOMESTIC", label: "Domestic" },
|
||||
]}
|
||||
value={tradeDirection}
|
||||
onChange={(v) => setTradeDirection(v ?? "IMPORT")}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Trade direction
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={
|
||||
tradeDirection === "DOMESTIC"
|
||||
? "grape"
|
||||
: tradeDirection === "EXPORT"
|
||||
? "orange"
|
||||
: "blue"
|
||||
}
|
||||
>
|
||||
{tradeDirection ? tradeDirectionLabel[tradeDirection] : "Select yards"}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
|
||||
import { useContainers } from "@/hooks/useContainers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWagons } from "@/hooks/useWagons";
|
||||
import type { FleetListFilters } from "@/services/fleet/fleet.service";
|
||||
import {
|
||||
FLEET_SELECT_NONE,
|
||||
getFleetResource,
|
||||
@@ -38,12 +39,30 @@ const FleetResourcePage = () => {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug);
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
if (slug !== "wagons" && slug !== "locomotives") return undefined;
|
||||
const filters: FleetListFilters = {};
|
||||
const status = listFilterValues.status;
|
||||
const readiness = listFilterValues.readiness;
|
||||
if (status && status !== "ALL") {
|
||||
filters.status = status as FleetListFilters["status"];
|
||||
}
|
||||
if (readiness && readiness !== "ALL") {
|
||||
filters.readiness = readiness as FleetListFilters["readiness"];
|
||||
}
|
||||
if (slug === "wagons" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
}
|
||||
return filters;
|
||||
}, [slug, listFilterValues, search]);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
|
||||
const { create, update, remove } = useFleetMutations(slug);
|
||||
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
|
||||
@@ -56,12 +75,18 @@ const FleetResourcePage = () => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
setStatusFilter("ALL");
|
||||
setListFilterValues({});
|
||||
}, [slug, setPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
if (!hasStatusColumn) return [];
|
||||
if (!hasStatusColumn || usesServerListFilters) return [];
|
||||
const statuses = new Set(
|
||||
allRows
|
||||
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
|
||||
@@ -71,7 +96,19 @@ const FleetResourcePage = () => {
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...[...statuses].sort().map((status) => ({ value: status, label: status })),
|
||||
];
|
||||
}, [allRows, hasStatusColumn]);
|
||||
}, [allRows, hasStatusColumn, usesServerListFilters]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
return config.listFilters.map((filter) => ({
|
||||
...filter,
|
||||
value: listFilterValues[filter.key] ?? "ALL",
|
||||
data: [
|
||||
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
|
||||
...filter.options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||
],
|
||||
}));
|
||||
}, [config?.listFilters, listFilterValues]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
|
||||
@@ -125,6 +162,7 @@ const FleetResourcePage = () => {
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
if (usesServerListFilters) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
@@ -138,7 +176,7 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -247,7 +285,27 @@ const FleetResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
listFilterSelects ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
key={filter.key}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
label={filter.label}
|
||||
value={filter.value}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setListFilterValues((prev) => ({ ...prev, [filter.key]: v }));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={filter.data}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
|
||||
@@ -11,6 +11,10 @@ export type FleetResourceSlug =
|
||||
|
||||
export const FLEET_SELECT_NONE = "__none__";
|
||||
|
||||
import type { WagonListFilters } from "@/services/wagon.service";
|
||||
|
||||
export type FleetListFilters = WagonListFilters;
|
||||
|
||||
export type FleetDynamicOptions =
|
||||
| "wagonTypes"
|
||||
| "containerTypes"
|
||||
@@ -30,6 +34,13 @@ export interface FleetFormFieldDef extends FormFieldDef {
|
||||
noneOption?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetListFilterDef {
|
||||
key: "status" | "readiness" | "wagonTypeId" | "trainId";
|
||||
label: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
allLabel?: string;
|
||||
}
|
||||
|
||||
export interface FleetResourceConfig {
|
||||
slug: FleetResourceSlug;
|
||||
label: string;
|
||||
@@ -39,6 +50,8 @@ export interface FleetResourceConfig {
|
||||
entityLabel: string;
|
||||
searchPlaceholder: string;
|
||||
supportsSearch: boolean;
|
||||
/** Server-side list filters (e.g. wagon status / readiness). */
|
||||
listFilters?: FleetListFilterDef[];
|
||||
columns: FleetResourceColumn[];
|
||||
formFields: FleetFormFieldDef[];
|
||||
emptyValues: Record<string, unknown>;
|
||||
@@ -101,12 +114,27 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
removeSuccessMessage: "Locomotive decommissioned",
|
||||
cardTitleKey: "name",
|
||||
cardCodeKey: "code",
|
||||
cardSubtitleKey: "locomotiveType",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status"],
|
||||
listFilters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
allLabel: "All statuses",
|
||||
options: LOCOMOTIVE_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
},
|
||||
],
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
|
||||
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
@@ -116,6 +144,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
|
||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
@@ -127,6 +156,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
readiness: Freight.WagonReadiness.ImportReady,
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
@@ -187,6 +217,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
searchPlaceholder: "Search wagons…",
|
||||
supportsSearch: true,
|
||||
removeAction: "delete",
|
||||
listFilters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
allLabel: "All statuses",
|
||||
options: WAGON_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
},
|
||||
],
|
||||
cardTitleKey: "wagonNumber",
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -18,79 +17,30 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Train,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type {
|
||||
BatchBoardBooking,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardSchedule,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
|
||||
AWAITING_PAYMENT: { label: "Awaiting payment", color: "orange", icon: Clock },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ booking }: { booking: BatchBoardBooking }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm" py={6} px="xs">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape" radius="sm">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.wagons}w · {fmtTons(booking.weightTons)}
|
||||
</Text>
|
||||
<StateBadge state={booking.state} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
const wagonPct =
|
||||
capacity.maxWagons > 0 ? (capacity.usedWagons / capacity.maxWagons) * 100 : 0;
|
||||
const lengthPct =
|
||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
@@ -103,9 +53,34 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
? "orange"
|
||||
: "gray";
|
||||
|
||||
const totalBookings =
|
||||
counts.allocated +
|
||||
counts.selectedForBatch +
|
||||
counts.ready +
|
||||
counts.waiting +
|
||||
counts.pendingContract +
|
||||
counts.expired;
|
||||
|
||||
return (
|
||||
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
|
||||
<Box style={{ height: 3, background: "linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))" }} />
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
@@ -147,18 +122,37 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Capacity meters */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Wagons
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{capacity.usedWagons}/{capacity.maxWagons}
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={wagonPct} color={wagonPct >= 100 ? "orange" : "green"} radius="xl" size="sm" />
|
||||
</Box>
|
||||
{capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
@@ -172,20 +166,29 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={weightPct} color={weightPct >= 100 ? "red" : "teal"} radius="xl" size="sm" />
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Count chips */}
|
||||
<Group gap={6}>
|
||||
<Tooltip label="Allocated to the train">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{counts.allocated} allocated
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Notified — 1h to pay">
|
||||
<Tooltip label="Picked by batch — customer notified to pay">
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{counts.awaitingPayment} to pay
|
||||
{counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Contract signed — waiting for batch pick">
|
||||
<Badge variant="light" color="teal" radius="sm">
|
||||
{counts.ready} ready
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Paid, waiting for a slot">
|
||||
@@ -200,20 +203,11 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* Bookings */}
|
||||
{schedule.bookings.length ? (
|
||||
<ScrollArea.Autosize mah={220}>
|
||||
<Stack gap={2}>
|
||||
{schedule.bookings.map((b) => (
|
||||
<BookingRow key={b.id} booking={b} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center" py="sm">
|
||||
No bookings targeting this schedule yet.
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{totalBookings
|
||||
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
|
||||
: "No bookings yet · click to open"}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -221,11 +215,12 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
radius="md"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.scheduleId}`)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
View batch windows
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -238,9 +233,7 @@ export default function BatchBoardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[{ label: "Operations" }, { label: "Batch board" }]}
|
||||
/>
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
|
||||
|
||||
<Paper
|
||||
radius="xl"
|
||||
@@ -265,8 +258,8 @@ export default function BatchBoardPage() {
|
||||
Batch board
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Every active schedule with its bookings grouped by state — allocated, awaiting
|
||||
payment, paid-waiting and expired. Filling is automatic; this is the live view.
|
||||
Active schedules — click a card to see EAT 3-hour batch windows, bookings, and
|
||||
wagon allocation status.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
Layers,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Weight,
|
||||
Ruler,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
useBatchBoardDetail,
|
||||
useRunAllocation,
|
||||
useScheduleDetail,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchWindowGroup,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
|
||||
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
const ALLOC_META: Record<
|
||||
BookingAllocationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ASSIGNED: { label: "Wagons assigned", color: "green" },
|
||||
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
|
||||
DEFERRED: { label: "Deferred", color: "orange" },
|
||||
FAILED: { label: "Allocation failed", color: "red" },
|
||||
};
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationBadge({
|
||||
status,
|
||||
issue,
|
||||
}: {
|
||||
status: BookingAllocationStatus;
|
||||
issue: string | null;
|
||||
}) {
|
||||
const meta = ALLOC_META[status];
|
||||
const badge = (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
if (!issue) return badge;
|
||||
return (
|
||||
<Tooltip label={issue} multiline maw={320} withArrow>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{badge}
|
||||
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||||
if (!bookings.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No bookings in this batch window.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Contract signed</Table.Th>
|
||||
<Table.Th>Selected for batch</Table.Th>
|
||||
<Table.Th>Capacity</Table.Th>
|
||||
<Table.Th>Batch state</Table.Th>
|
||||
<Table.Th>Wagon allocation</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.company}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{fmtDateTime(b.fullyExecutedAt)} EAT</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{b.selectedForBatchAt ? (
|
||||
<>
|
||||
<Text size="sm">{fmtDateTime(b.selectedForBatchAt)} EAT</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange">
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{b.wagons}w · {fmtTons(b.weightTons)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StateBadge state={b.state} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
const total = window.bookings.length;
|
||||
const hasIssues = window.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion.Item value={window.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
{window.label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{hasIssues ? (
|
||||
<Badge variant="light" color="red" size="sm">
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{total} booking{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={window.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchScheduleDetailPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId);
|
||||
const runAllocation = useRunAllocation(scheduleId ?? "");
|
||||
|
||||
const hasAssignedWagons = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
const scheduleDetailQuery = useScheduleDetail(
|
||||
hasAssignedWagons ? scheduleId : undefined,
|
||||
"CONTAINER",
|
||||
);
|
||||
|
||||
const defaultOpen = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
|
||||
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
|
||||
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
|
||||
}, [data]);
|
||||
|
||||
const handleRunAllocation = () => {
|
||||
runAllocation
|
||||
.mutateAsync()
|
||||
.then((result) => {
|
||||
const failed = result.issues.filter((i) => i.status === "FAILED").length;
|
||||
const deferred = result.deferred.length;
|
||||
toast({
|
||||
title: "Allocation run complete",
|
||||
description:
|
||||
failed || deferred
|
||||
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
|
||||
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
void refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const lengthPct =
|
||||
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
|
||||
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
const weightPct =
|
||||
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
|
||||
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper radius="xl" p="xl" mt="md" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<Train size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.origin ?? "—"} → {data.destination ?? "—"} ·{" "}
|
||||
{data.scheduleDate
|
||||
? new Date(data.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Badge variant="light" color="green">
|
||||
{data.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray">
|
||||
{data.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{data.locomotive ? (
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" mt="md">
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{data.capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{data.capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtMeters(data.capacity.allocatedLengthMeters)}/
|
||||
{fmtMeters(data.capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{data.capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Weight size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap={6} mt="md">
|
||||
<Badge variant="light" color="green">
|
||||
{data.counts.allocated} allocated
|
||||
</Badge>
|
||||
<Badge variant="light" color="orange">
|
||||
{data.counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
<Badge variant="light" color="teal">
|
||||
{data.counts.ready} ready
|
||||
</Badge>
|
||||
<Badge variant="light" color="blue">
|
||||
{data.counts.waiting} waiting
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray">
|
||||
{data.counts.pendingContract} pending contract
|
||||
</Badge>
|
||||
{data.counts.expired ? (
|
||||
<Badge variant="light" color="red">
|
||||
{data.counts.expired} expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />} title="Allocation constraints">
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Batch windows (EAT)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Bookings are grouped by contract signing time (<code>fullyExecutedAt</code>). Expand a
|
||||
window to see bookings and wagon allocation issues.
|
||||
</Text>
|
||||
|
||||
<Accordion multiple defaultValue={defaultOpen} variant="separated">
|
||||
{data.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
) : null}
|
||||
</Accordion>
|
||||
</Paper>
|
||||
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Train composition
|
||||
</Title>
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
@@ -81,7 +81,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
|
||||
const { create, cancel } = useScheduleMutations();
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
@@ -89,6 +89,23 @@ export default function TrainScheduleV2ListPage() {
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
const locomotiveReadinessHint = useMemo(() => {
|
||||
if (!selectedRoute) return "Select a route first";
|
||||
const origin = selectedRoute.originYard?.country?.trim();
|
||||
const dest = selectedRoute.destinationYard?.country?.trim();
|
||||
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only";
|
||||
if (dest === "Djibouti" && origin !== "Djibouti") {
|
||||
return "Export corridor — export-ready locomotives only";
|
||||
}
|
||||
return "Domestic corridor — any readiness";
|
||||
}, [selectedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocomotiveId("");
|
||||
}, [routeId]);
|
||||
|
||||
const allSchedules = schedulesQuery.data ?? [];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -507,6 +524,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
{routeId ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{locomotiveReadinessHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
@@ -518,7 +540,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder="Select locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""} · ${
|
||||
@@ -528,6 +550,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { cargoService, type Cargo } from "@/services/cargoService";
|
||||
import { containerService, type Container } from "@/services/containerService";
|
||||
import { locomotivesService, type Locomotive } from "@/services/locomotives.service";
|
||||
import {
|
||||
locomotivesService,
|
||||
type Locomotive,
|
||||
type LocomotiveListFilters,
|
||||
} from "@/services/locomotives.service";
|
||||
import { trainService, type Train } from "@/services/trains.service";
|
||||
import { wagonService, type Wagon } from "@/services/wagon.service";
|
||||
import { wagonService, type Wagon, type WagonListFilters } from "@/services/wagon.service";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
|
||||
export type FleetRecord = Locomotive | Train | Wagon | Container | Cargo;
|
||||
|
||||
const listHandlers: Record<FleetResourceSlug, () => Promise<FleetRecord[]>> = {
|
||||
locomotives: () => locomotivesService.getAll().then((r) => r.data),
|
||||
export type FleetListFilters = WagonListFilters & LocomotiveListFilters;
|
||||
|
||||
const listHandlers: Record<
|
||||
FleetResourceSlug,
|
||||
(filters?: FleetListFilters) => Promise<FleetRecord[]>
|
||||
> = {
|
||||
locomotives: (filters) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
|
||||
trains: () => trainService.getAll().then((r) => r.data),
|
||||
wagons: () => wagonService.getAll().then((r) => r.data),
|
||||
wagons: (filters) => wagonService.getAll(filters ?? {}).then((r) => r.data),
|
||||
containers: () => containerService.getAll().then((r) => r.data),
|
||||
cargoes: () => cargoService.getAll().then((r) => r.data),
|
||||
};
|
||||
@@ -43,7 +52,7 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
|
||||
};
|
||||
|
||||
export const fleetService = {
|
||||
list: (slug: FleetResourceSlug) => listHandlers[slug](),
|
||||
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
||||
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
|
||||
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
||||
updateHandlers[slug](id, data),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
@@ -9,12 +11,18 @@ export type LocomotiveStatus =
|
||||
| 'ASSIGNED'
|
||||
| 'OUT_OF_SERVICE';
|
||||
|
||||
export interface LocomotiveListFilters {
|
||||
status?: LocomotiveStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
}
|
||||
|
||||
export interface Locomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
locomotiveType: LocomotiveType;
|
||||
status: LocomotiveStatus;
|
||||
readiness: Freight.WagonReadiness;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
powerKw?: number | null;
|
||||
@@ -30,7 +38,15 @@ export type SaveLocomotivePayload = Omit<
|
||||
>;
|
||||
|
||||
export const locomotivesService = {
|
||||
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
|
||||
getAll: (filters: LocomotiveListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.readiness) params.set('readiness', filters.readiness);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<Locomotive[]>(
|
||||
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
|
||||
create: (data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unwrap } from '@/utils/endpoint';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CreateTrainSchedulePayload,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainTrackResponse,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
} from '@/types/trainScheduling';
|
||||
|
||||
@@ -86,6 +88,13 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.get<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBookableSchedules: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
@@ -97,14 +106,22 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runBatch: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => {
|
||||
const response = await client.post<WagonAllocationAttemptResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setBookingWindow: async (
|
||||
scheduleId: string,
|
||||
status: "OPEN" | "CLOSED",
|
||||
@@ -232,7 +249,14 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
|
||||
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => {
|
||||
if (routeId) {
|
||||
const response = await client.get<LocomotiveRecord[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
|
||||
{ params: { routeId } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
}
|
||||
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
|
||||
params: { status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
@@ -15,8 +15,25 @@ export interface Wagon {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WagonListFilters {
|
||||
search?: string;
|
||||
status?: Freight.WagonStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
wagonTypeId?: string;
|
||||
trainId?: string;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
getAll: () => apiClient.get<Wagon[]>('/wagons'),
|
||||
getAll: (filters: WagonListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search?.trim()) params.set('search', filters.search.trim());
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.readiness) params.set('readiness', filters.readiness);
|
||||
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
|
||||
if (filters.trainId) params.set('trainId', filters.trainId);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
|
||||
@@ -180,7 +180,8 @@ export interface BookableSchedule {
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
| "AWAITING_PAYMENT"
|
||||
| "SELECTED_FOR_BATCH"
|
||||
| "READY"
|
||||
| "WAITING"
|
||||
| "PENDING_CONTRACT"
|
||||
| "EXPIRED";
|
||||
@@ -192,6 +193,7 @@ export interface BatchBoardBooking {
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
@@ -212,15 +214,16 @@ export interface BatchBoardSchedule {
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
maxWagons: number;
|
||||
usedWagons: number;
|
||||
remainingWagons: number;
|
||||
allocatedWagons: number;
|
||||
allocatedLengthMeters: number;
|
||||
maxLengthMeters: number | null;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
awaitingPayment: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
@@ -228,6 +231,63 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| "NOT_ATTEMPTED"
|
||||
| "ASSIGNED"
|
||||
| "DEFERRED"
|
||||
| "FAILED";
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
export interface WagonAllocationAttemptResult {
|
||||
assignedBookingIds: string[];
|
||||
deferred: Array<{ id: string; reference: string; reason: string }>;
|
||||
issues: Array<{
|
||||
bookingId: string;
|
||||
status: BookingAllocationStatus;
|
||||
issue: string | null;
|
||||
}>;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface TrainScheduleWagonAllocation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
|
||||
@@ -53,8 +53,10 @@ export enum BookingStatus {
|
||||
FullyExecuted = "FULLY_EXECUTED",
|
||||
PnrGenerated = "PNR_GENERATED",
|
||||
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
/** Selected in a batch and notified to pay within the 1h window. */
|
||||
AwaitingPayment = "AWAITING_PAYMENT",
|
||||
/** Selected in a batch and notified to pay within the pay window. */
|
||||
SelectedForBatch = "SELECTED_FOR_BATCH",
|
||||
/** @deprecated Use SelectedForBatch */
|
||||
AwaitingPayment = "SELECTED_FOR_BATCH",
|
||||
/** Missed the 1h pay window — recoverable via move/cancel (no re-approval). */
|
||||
Expired = "EXPIRED",
|
||||
Paid = "PAID",
|
||||
|
||||
Reference in New Issue
Block a user